Compare commits

..

No commits in common. "master" and "2.2.0" have entirely different histories.

126 changed files with 3005 additions and 11071 deletions

227
README.md
View file

@ -1,148 +1,149 @@
# Note Status for Obsidian
[![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://paypal.me/aleixsoler)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/aleixsoler)
[![GitHub release](https://img.shields.io/github/v/release/devonthesofa/obsidian-note-status)](https://github.com/devonthesofa/obsidian-note-status/releases)
[![Build Status](https://img.shields.io/github/actions/workflow/status/devonthesofa/obsidian-note-status/release.yml)](https://github.com/devonthesofa/obsidian-note-status/actions)
[![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22note-status%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json)](https://obsidian.md/plugins?id=note-status)
[![GitHub License](https://img.shields.io/github/license/devonthesofa/obsidian-note-status)](https://github.com/devonthesofa/obsidian-note-status/blob/master/LICENSE)
[![GitHub Stars](https://img.shields.io/github/stars/devonthesofa/obsidian-note-status)](https://github.com/devonthesofa/obsidian-note-status/stargazers)
Track the lifecycle of every file in your vault using statuses you fully control. Note Status brings visual clarity and structured workflows to Obsidian—whether you're managing research notes, writing projects, tasks, or long-term archives.
Track the status of your notes with a powerful, customizable status management system. Mark notes as active, completed, on hold, or create your own custom workflows.
It works on both Markdown and non-Markdown files, integrates into multiple UI surfaces, and provides configurable dashboards, commands, and fast actions to keep information flowing.
![Status assignment workflow demonstration](images/status-assignment-demo.png)
## Contents
_Alt: Demonstration of assigning status to a note using the toolbar dropdown_
- [Feature Guide](docs/features.md): what the plugin can do across commands, UI surfaces, and workflows.
- [Settings Reference](docs/settings.md): every option, grouped like the in-app settings.
## 🎯 Features
![Big picture 1](images/big-picture-1.png)
![Big picture 1](images/big-picture-2.png)
- **📋 Status Assignment** - Mark notes with workflow statuses (active, completed, on hold, etc.)
- **🎯 Multi-Status Support** - Assign multiple statuses per note
- **📁 File Explorer Icons** - Visual indicators next to filenames with configurable positioning
- **🧭 Status Bar Integration** - View current status at bottom of editor
- **⬇️ Toolbar Dropdown** - Change statuses quickly from editor toolbar
- **📊 Status Dashboard** - Vault overview with statistics and quick actions
- **📈 Grouped View** - Browse notes organized by status with search and filtering
- **⚡ Batch Operations** - Update multiple files simultaneously
- **🎨 Custom Statuses** - Create your own statuses with icons, colors, and descriptions
- **🏷️ Template Scoping** - Organize statuses by note templates
- **🚀 Performance Optimized** - Works with large vaults (40k+ notes tested)
## Quick start
![File explorer with status icons](images/file-explorer-status-icons.png)
1. Open Settings → Note Status.
2. Expand **Templates & Statuses** and keep a built-in template or add your own/custom-only set.
3. Pick where to see/change status: **Status Bar** or **Editor Toolbar**.
4. Set a status from the badge or run `Change current note status`.
5. Watch file explorer icons update; hover for the overview popup or click to change again.
6. If enabled, open the **Status Dashboard** or **Grouped Status View** from the ribbon/commands.
_Alt: File explorer showing multiple notes with different status icons positioned next to note names and the tooltip with status overview_
## Key features
![Status dashboard with vault statistics](images/status-dashboard-overview1.png)
- Status picker everywhere: status bar badge, editor toolbar, explorer context menus, commands.
- Multi-status or single-status workflows; quick-status commands for hotkeys.
- File explorer icons with configurable placement, frames, and visual treatments (row tint, underline, dot, border).
- Status bar badges with accent/filled/dot styles and icon/text options; template names can auto-show on conflicts.
- Dashboards and grouped views (gated by experimental toggles and vault size limit) for vault-wide visibility and quick actions.
- Built-in templates (Colorful, Minimal, Academic, Project) plus full custom templates and standalone custom statuses.
- Works with YAML frontmatter and non-Markdown via a synced JSON store; frontmatter mappings let you write to multiple keys.
_Alt: Status dashboard showing current note information, vault statistics with total notes and coverage percentages_
![Status dashboard with vault statistics](images/status-dashboard-overview2.png)
## Data model
_Alt: Status distribution chart showing different statuses with colored bars and note counts for each status type_
![Status dashboard with vault statistics](images/status-dashboard-overview3.png)
- **Markdown**: statuses live under the tag prefix (default `obsidian-note-status`). Map templates/statuses to extra YAML keys and optionally also write to the default.
- **Non-Markdown**: stored in `.obsidian/plugins/obsidian-note-status/non-markdown-statuses.json`, kept in sync on rename/delete.
- **Single vs multi**: multiple statuses by default; single-status mode can store as list (`status: [draft]`) or string (`status: draft`) for compatibility.
_Alt: Quick actions panel with organized sections for views, current note operations, clipboard actions, and tools_
## Screenshots
## 💻 Technology Stack
- Nav bar with current status
Built with modern web technologies for performance and reliability:
- ![navbar-unnasigned](images/navbar-unnasigned.png)
- ![navbar-multiple](images/navbar-multiple.png)
- **TypeScript** - Type-safe development
- **React** - Modern UI framework
- **esbuild** - Fast bundling and development
- **Obsidian API** - Deep vault integration
- Status assignment:
## 📦 Installation
- ![change-not-status-modal](images/change-not-status-modal.png)
### Community Plugin Store (Recommended)
- File explorer with status icons and hover overview:
- ![file-explorer-and-popup](images/file-explorer-and-popup.png)
- Multiple settings:
- ![settings-collapsed](images/settings-collapsed.png)
- Custom templates editor:
- ![settings-collapsed](images/status-templates-1.png)
- ![settings-collapsed](images/custom-templates.png)
- Frontmatter tags mapping
- ![frontmatter-mappings](images/frontmatter-mappings.png)
- Grouped status view:
- ![status-groups-full-picture-2](images/status-groups-full-picture-2.png)
- Status dashboard:
- ![status-dashboard-1](images/status-dashboard-1.png)
- ![status-dashboard-2](images/status-dashboard-2.png)
- ![status-dashboard-3](images/status-dashboard-3.png)
1. Open Obsidian → Settings → Community plugins
2. Disable Safe mode → Browse → Search "Note Status"
3. Install and Enable
## Template Marketplace
### Manual Installation
You can contribute your own status templates to the plugin!
1. Download latest release from [GitHub Releases](https://github.com/devonthesofa/obsidian-note-status/releases)
2. Extract `main.js`, `manifest.json`, and `styles.css` to `.obsidian/plugins/note-status/`
3. Enable in Community plugins settings
### How to contribute (The Easy Way)
## 🚀 How to Use
1. Create a custom template in the plugin settings.
2. Click the **Share** (📤) icon on your custom template.
3. Follow the instructions in the modal to copy the JSON and submit a Pull Request to the [official repository](https://github.com/devonthesofa/obsidian-note-status).
4. Maintainers will review your submission and accept it if it passes the revision!
### Quick Start
### How to contribute (Manual)
1. **Assign Status**:
1. Fork the repository.
...
2. Create a new JSON file in the `templates/` folder (e.g., `templates/my-awesome-workflow.json`).
3. Follow this format:
```json
{
"id": "my-awesome-workflow",
"name": "My Awesome Workflow",
"description": "A workflow for doing awesome things",
"authorGithub": "your-username",
"statuses": [
{ "name": "todo", "icon": "📝", "color": "#ff0000", "templateId": "my-awesome-workflow" },
{ "name": "done", "icon": "✅", "color": "#00ff00", "templateId": "my-awesome-workflow" }
]
}
```
4. Submit a Pull Request with your JSON file!
1. Click the "No status" in toolbar
Once accepted and merged, your template will be automatically included in the next build and available in the marketplace for all users.
![Status toolbar](images/no-status-toolbar.png)
## Installation
_Alt: Editor toolbar showing "No status" button for status assignment_
### Community Plugin Store (recommended)
2. → Select a status
1. Obsidian → Settings → Community plugins.
2. Disable Safe mode → Browse → search “Note Status”.
3. Install and enable.
2. **View in Explorer**: Status icons appear next to note names
### Manual
![File explorer item](images/file-explorer-item.png)
1. Download the latest release from [GitHub Releases](https://github.com/devonthesofa/obsidian-note-status/releases).
2. Extract `main.js`, `manifest.json`, and `styles.css` to `.obsidian/plugins/note-status/`.
3. Enable in Community plugins settings.
_Alt: Single file explorer item showing a note with status icon positioned next to the filename_
## Example workflows
3. **Browse by Status or open the Dashboard**: Open from left sidebar
**Personal knowledge**
![Open status view](images/open-view-buttons.png)
_Alt: Left sidebar showing ribbon buttons for opening grouped status view and status dashboard_
4. **Batch Update**: Select multiple files → Right-click → Change status
![Multiple status context](images/change-multiple-statuses.png)
_Alt: Context menu appearing when right-clicking multiple selected files, showing "Change note status" option_
### Example Workflows
**Personal Knowledge Management**
```
💡 idea → 📝 draft → ✏️ editing → ✅ completed → 📦 archived
```
**Project delivery**
**Project Management**
```
📋 backlog → 🚦 ready → 👨‍💻 inDevelopment → 🧪 testing → 🚀 live
```
**Academic**
**Academic Research**
```
🔍 research → 📑 outline → ✏️ draft → 🔬 review → 📚 final
```
## Data format
![Grouped status view with filtering](images/grouped-status-filtering.png)
Statuses in YAML(as string or array, it depends of your settings):
_Alt: Grouped status view showing notes organized by status with template filtering and search functionality_
### Custom Statuses
Create your own workflow:
1. **Settings** → Note Status → Custom Statuses
2. **Add Status** → Configure name, icon, color, description
3. **Reorder** using arrow controls
4. **Use** in your notes
Example custom status:
```
Name: urgent
Icon: 🔥
Color: #FF0000
Description: Requires immediate attention
```
![Custom statuses](images/custom-status-change.png)
_Alt: Status selection modal showing custom and template statuses with icons, names, and selection interface_
## 📋 Data Format
Statuses are stored in YAML frontmatter:
```yaml
---
@ -152,13 +153,49 @@ obsidian-note-status:
---
```
Works with Dataview, Templater, QuickAdd, and search. Non-Markdown uses the JSON store noted above.
Works with:
## Support the project
- **Dataview queries**
- **Templater scripts**
- **QuickAdd macros**
- **Global search**
## 🛠️ API Reference
For developers building integrations or contributing:
📚 **[Developer Documentation](https://github.com/devonthesofa/obsidian-note-status/wiki)**
Includes:
- Architecture overview
- Development setup
- API reference
- Contributing guide
## 🤝 Contributing
We welcome contributions!
1. **Check** [GitHub Issues](https://github.com/devonthesofa/obsidian-note-status/issues) for bugs/features
2. **Fork** the repository
3. **Follow** [Development Setup](https://github.com/devonthesofa/obsidian-note-status/wiki/🔧-Development-Setup)
4. **Submit** pull request
## 🐛 Support
- **📖 Documentation**: [Complete Wiki](https://github.com/devonthesofa/obsidian-note-status/wiki)
- **🐞 Bug Reports**: [GitHub Issues](https://github.com/devonthesofa/obsidian-note-status/issues)
- **💡 Feature Requests**: [GitHub Discussions](https://github.com/devonthesofa/obsidian-note-status/discussions)
## ❤️ Support Development
If you find this plugin useful:
[![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://paypal.me/aleixsoler)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/aleixsoler)
**PayPal**: https://paypal.me/aleixsoler
---
**License**: MIT · **Author**: [Aleix Soler](https://aleixsoler.com)
**License**: MIT | **Author**: [Aleix Soler](https://aleixsoler.com)

View file

@ -4,16 +4,11 @@ import {
StatusSelectorGroup,
Props as StatusSelectorGroupProps,
} from "./StatusSelectorGroup";
import { StatusTemplate } from "@/types/pluginSettings";
export interface Props {
currentStatuses: GroupedStatuses;
filesQuantity: number;
availableStatuses: NoteStatus[];
templates: StatusTemplate[];
iconFrameMode?: "always" | "never";
iconColorMode?: "status" | "theme";
activeStatus?: NoteStatus;
onRemoveStatus: (
frontmatterTagName: string,
status: NoteStatus,
@ -28,10 +23,6 @@ export const ChangeStatusModal: React.FC<Props> = ({
currentStatuses: initialStatuses,
filesQuantity,
availableStatuses,
templates,
iconFrameMode = "never",
iconColorMode = "status",
activeStatus,
onRemoveStatus,
onSelectStatus,
}) => {
@ -62,10 +53,6 @@ export const ChangeStatusModal: React.FC<Props> = ({
frontmatterTagName={frontmatterTagName}
availableStatuses={availableStatuses}
currentStatuses={statusList}
templates={templates}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
activeStatus={activeStatus}
onSelectedState={handleSelectedState}
/>
))}

View file

@ -14,7 +14,16 @@ export const CurrentStatusChips: React.FC<Props> = ({
}) => {
return (
<SettingItem name="Current statuses" vertical>
<div className="note-status-chips">
<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}`}

View file

@ -1,20 +1,15 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback } from "react";
import { NoteStatus } from "@/types/noteStatus";
import { SearchFilter } from "../atoms/SearchFilter";
import { StatusSelector } from "../atoms/StatusSelector";
import { SettingItem } from "../SettingsUI/SettingItem";
import { CurrentStatusChips } from "./CurrentStatusChips";
import { useKeyboardNavigation } from "./useKeyboardNavigation";
import { StatusTemplate } from "@/types/pluginSettings";
export interface Props {
frontmatterTagName: string;
currentStatuses: NoteStatus[];
availableStatuses: NoteStatus[];
templates: StatusTemplate[];
iconFrameMode: "always" | "never";
iconColorMode: "status" | "theme";
activeStatus?: NoteStatus;
onSelectedState: (
frontmatterTagName: string,
status: NoteStatus,
@ -25,16 +20,9 @@ export interface Props {
export const StatusSelectorGroup: React.FC<Props> = ({
currentStatuses,
availableStatuses,
templates,
frontmatterTagName,
iconFrameMode,
iconColorMode,
activeStatus,
onSelectedState,
}) => {
const TEMPLATE_ALL_VIEW = "__all__";
const TEMPLATE_CUSTOM_VIEW = "__custom__";
const handleRemoveStatus = useCallback(
(status: NoteStatus) => {
onSelectedState(frontmatterTagName, status, "unselected");
@ -49,105 +37,6 @@ export const StatusSelectorGroup: React.FC<Props> = ({
[onSelectedState, frontmatterTagName],
);
type TemplateViewOption = {
id: string;
label: string;
statuses: NoteStatus[];
};
const templateViewOptions = useMemo<TemplateViewOption[]>(() => {
const statusesByTemplate = new Map<string, NoteStatus[]>();
const customStatuses: NoteStatus[] = [];
availableStatuses.forEach((status) => {
if (status.templateId) {
const bucket = statusesByTemplate.get(status.templateId) ?? [];
bucket.push(status);
statusesByTemplate.set(status.templateId, bucket);
} else {
customStatuses.push(status);
}
});
const templateOrder = templates.map((template) => template.id);
const templateNameMap = new Map(
templates.map((template) => [template.id, template.name]),
);
const views: TemplateViewOption[] = [
{
id: TEMPLATE_ALL_VIEW,
label: "All",
statuses: availableStatuses,
},
];
templateOrder.forEach((templateId) => {
const statuses = statusesByTemplate.get(templateId);
if (statuses?.length) {
views.push({
id: templateId,
label: templateNameMap.get(templateId) ?? templateId,
statuses,
});
statusesByTemplate.delete(templateId);
}
});
// Include any template-defined statuses that aren't present in the settings order
statusesByTemplate.forEach((statuses, templateId) => {
if (statuses.length) {
views.push({
id: templateId,
label: templateNameMap.get(templateId) ?? templateId,
statuses,
});
}
});
if (customStatuses.length) {
views.push({
id: TEMPLATE_CUSTOM_VIEW,
label: "Custom",
statuses: customStatuses,
});
}
return views;
}, [availableStatuses, templates]);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(() => {
if (activeStatus && activeStatus.templateId) {
return activeStatus.templateId;
}
if (currentStatuses && currentStatuses.length > 0) {
const statusWithTemplate = currentStatuses.find(
(s) => s.templateId,
);
if (statusWithTemplate) {
return statusWithTemplate.templateId as string;
}
return TEMPLATE_CUSTOM_VIEW;
}
return TEMPLATE_ALL_VIEW;
});
useEffect(() => {
const exists = templateViewOptions.some(
(option) => option.id === selectedTemplateId,
);
if (!exists) {
setSelectedTemplateId(TEMPLATE_ALL_VIEW);
}
}, [templateViewOptions, selectedTemplateId]);
const activeTemplate =
templateViewOptions.find(
(option) => option.id === selectedTemplateId,
) ?? templateViewOptions[0];
const statusesForView = activeTemplate?.statuses ?? [];
const {
focusedIndex,
searchFilter,
@ -157,7 +46,7 @@ export const StatusSelectorGroup: React.FC<Props> = ({
handleKeyDown,
setSearchFilter,
} = useKeyboardNavigation({
availableStatuses: statusesForView,
availableStatuses,
currentStatuses,
onSelectStatus: handleSelectStatus,
onRemoveStatus: handleRemoveStatus,
@ -168,33 +57,8 @@ export const StatusSelectorGroup: React.FC<Props> = ({
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
className="status-selector-group"
style={{ outline: "none" }}
>
{templateViewOptions.length > 1 && (
<div
className="note-status-template-tabs"
role="tablist"
aria-label="Status templates"
>
{templateViewOptions.map((option) => (
<button
type="button"
role="tab"
key={option.id}
className={`note-status-template-tab${
option.id === selectedTemplateId
? " is-active"
: ""
}`}
onClick={() => setSelectedTemplateId(option.id)}
aria-selected={option.id === selectedTemplateId}
>
{option.label}
</button>
))}
</div>
)}
<SearchFilter
ref={searchRef}
value={searchFilter}
@ -203,7 +67,14 @@ export const StatusSelectorGroup: React.FC<Props> = ({
<SettingItem name="Current statuses" vertical>
{filteredStatuses.length === 0 ? (
<div className="status-selector-empty">
<div
style={{
padding: "16px",
textAlign: "center",
color: "var(--text-muted)",
fontStyle: "italic",
}}
>
{searchFilter
? `No statuses match "${searchFilter}"`
: "No statuses found"}
@ -213,8 +84,6 @@ export const StatusSelectorGroup: React.FC<Props> = ({
availableStatuses={filteredStatuses}
currentStatuses={currentStatuses}
focusedIndex={focusedIndex}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
onToggleStatus={(status, selected) =>
selected
? handleSelectStatus(status)

View file

@ -1,41 +1,24 @@
import { StatusIconPreview } from "@/components/atoms/StatusIconPreview";
import { GroupedStatuses } from "@/types/noteStatus";
import React, { FC, memo } from "react";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import {
getPrimaryStatus,
getUnknownStatusColor,
resolveStatusColor,
} from "@/utils/statusColor";
type Props = {
statuses: GroupedStatuses;
defaultTagName: string;
onMouseEnter: (payload: {
statuses: GroupedStatuses;
defaultTagName: string;
}) => void;
onMouseLeave: () => void;
onMouseEnter: (statuses: GroupedStatuses) => void;
onMouseLeave: (statuses: GroupedStatuses) => void;
hideUnknownStatus?: boolean;
unknownStatusConfig?: {
icon: string;
lucideIcon?: string;
color: string;
};
iconFrameMode?: "always" | "never";
iconColorMode?: "status" | "theme";
};
export const FileExplorerIcon: FC<Props> = memo(
({
statuses,
defaultTagName,
onMouseLeave,
onMouseEnter,
hideUnknownStatus,
unknownStatusConfig,
iconFrameMode = "never",
iconColorMode = "status",
}) => {
const statusEntries = Object.entries(statuses);
const totalStatuses = statusEntries.reduce(
@ -43,58 +26,54 @@ export const FileExplorerIcon: FC<Props> = memo(
0,
);
const useStatusColors = iconColorMode === "status";
if (totalStatuses === 0) {
// If hideUnknownStatus is enabled, don't show anything for files without status
if (hideUnknownStatus) return null;
// Use config passed from integration, with fallbacks
const icon = unknownStatusConfig?.icon || "❓";
const color = useStatusColors
? unknownStatusConfig?.color?.trim() || getUnknownStatusColor()
: undefined;
const color = unknownStatusConfig?.color || "#8b949e";
return (
<StatusIconPreview
icon={
<StatusIcon
icon={icon}
lucideIcon={unknownStatusConfig?.lucideIcon}
size={16}
/>
}
color={color}
iconColorMode={iconColorMode}
iconFrameMode={iconFrameMode}
onMouseEnter={() =>
onMouseEnter({ statuses: {}, defaultTagName })
}
onMouseLeave={onMouseLeave}
/>
<div className="status-wrapper">
<div
className="status-minimal status-minimal--no-status"
onMouseEnter={() => onMouseEnter({})}
onMouseLeave={() => onMouseLeave({})}
style={
{
"--primary-color": color,
} as React.CSSProperties
}
>
<span className="status-icon">{icon}</span>
</div>
</div>
);
}
const primaryStatus = getPrimaryStatus(statuses);
const primaryStatus = statusEntries[0]?.[1]?.[0];
if (!primaryStatus) return null;
const iconColor = useStatusColors
? resolveStatusColor(primaryStatus)
: undefined;
return (
<StatusIconPreview
icon={
<StatusIcon
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
size={16}
/>
}
color={iconColor}
count={totalStatuses}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
onMouseEnter={() => onMouseEnter({ statuses, defaultTagName })}
onMouseLeave={onMouseLeave}
/>
<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

@ -18,7 +18,6 @@ export type StatusItem = {
name: string;
color: string;
icon?: string;
lucideIcon?: string;
};
export type FilesByStatus = {
@ -70,7 +69,6 @@ const GroupedStatusViewContent = () => {
name: noteStatus.name,
color: noteStatus.color || "white",
icon: noteStatus.icon,
lucideIcon: noteStatus.lucideIcon,
};
const scopedIdentifier = noteStatus.templateId

View file

@ -1,5 +1,7 @@
import React, { useCallback } from "react";
import { FileItem } from "../GroupedStatusView";
import { SelectableListItem } from "../../atoms/SelectableListItem";
interface FileListProps {
files: FileItem[];
groupKey: string;
@ -46,23 +48,21 @@ export const FileList = ({
<div className="grouped-status-files">
<div className="grouped-status-files-list" onScroll={handleScroll}>
{visibleFiles.map((file) => (
<div
<SelectableListItem
key={file.id}
className="tree-item nav-file grouped-status-file-item"
onClick={() => handleFileClick(file)}
className="grouped-status-file-item"
title={file.path}
>
<div className="tree-item-self is-clickable nav-file-title">
<div className="tree-item-inner nav-file-title-content grouped-status-file-content">
<div className="grouped-status-file-name">
{file.name}
</div>
<div className="grouped-status-file-path">
{file.path}
</div>
</div>
<div>
<span className="grouped-status-file-name">
{file.name}
</span>
<span className="grouped-status-file-path">
{file.path}
</span>
</div>
</div>
</SelectableListItem>
))}
{hasMoreItems && (

View file

@ -46,19 +46,15 @@ export const StatusGroup = ({
}, [onToggle]);
return (
<div className="grouped-status-group tree-item">
<div className="grouped-status-group">
<div
className="grouped-status-group__header tree-item-self is-clickable"
className="grouped-status-group__header"
onClick={handleToggle}
>
<div className="tree-item-inner grouped-status-group__status">
<div className="grouped-status-group__status">
<StatusDisplay
status={
{
...status,
icon: status.icon || "",
lucideIcon: status.lucideIcon,
} as NoteStatus
{ ...status, icon: status.icon || "" } as NoteStatus
}
variant="badge"
/>
@ -78,16 +74,14 @@ export const StatusGroup = ({
</div>
{isExpanded && (
<div className="tree-item-children">
<FileList
files={files}
groupKey={groupKey}
loadedCount={loadedCount}
onFileClick={onFileClick}
onScroll={onScroll}
onLoadMore={onLoadMore}
/>
</div>
<FileList
files={files}
groupKey={groupKey}
loadedCount={loadedCount}
onFileClick={onFileClick}
onScroll={onScroll}
onLoadMore={onLoadMore}
/>
)}
</div>
);

View file

@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from "react";
import React, { useCallback } from "react";
import { GroupLabel } from "@/components/atoms/GroupLabel";
import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter";
import { FilesByStatus, FileItem, StatusItem } from "../GroupedStatusView";
@ -51,11 +51,6 @@ export const TagSection = ({
[onToggleFiles],
);
const statusOrder = useMemo(
() => Array.from(statusMap.keys()),
[statusMap],
);
return (
<div className="grouped-status-tag-section">
<div className="grouped-status-tag-header" onClick={handleToggle}>
@ -69,38 +64,32 @@ export const TagSection = ({
{isExpanded && (
<div className="grouped-status-tag-content">
{Object.entries(statusGroups)
.sort(
([statusA], [statusB]) =>
statusOrder.indexOf(statusA) -
statusOrder.indexOf(statusB),
)
.map(([statusName, files]) => {
if (files.length === 0) return null;
{Object.entries(statusGroups).map(([statusName, files]) => {
if (files.length === 0) return null;
const status = statusMap.get(statusName);
if (!status) return null;
const status = statusMap.get(statusName);
if (!status) return null;
const groupKey = `${tag}-${statusName}`;
const filesExpanded = expandedFiles.has(groupKey);
const loadedCount = getLoadedCount(groupKey);
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}
/>
);
})}
return (
<StatusGroup
key={statusName}
statusName={statusName}
status={status}
files={files}
tag={tag}
isExpanded={filesExpanded}
loadedCount={loadedCount}
onToggle={() => handleToggleFiles(groupKey)}
onFileClick={onFileClick}
onScroll={onScroll}
onLoadMore={onLoadMore}
/>
);
})}
</div>
)}
</div>

View file

@ -1,8 +1,6 @@
import { PluginSettings } from "@/types/pluginSettings";
import React, { useMemo } from "react";
import React from "react";
import { SettingItem } from "./SettingItem";
import { FrontmatterMappingsSettings } from "./FrontmatterMappingsSettings";
import { BaseNoteStatusService } from "@/core/noteStatusService";
export type Props = {
settings: PluginSettings;
@ -10,51 +8,10 @@ export type Props = {
};
export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
const availableStatuses = useMemo(() => {
return BaseNoteStatusService.getAllAvailableStatuses();
}, [
settings.templates,
settings.customStatuses,
settings.enabledTemplates,
]);
return (
<div>
<h3>Status tag</h3>
<SettingItem
name="Default status for new notes"
description="Automatically apply this status to newly created notes."
>
<select
value={settings.defaultStatusForNewNotes || ""}
onChange={(e) => {
const value = e.target.value;
onChange(
"defaultStatusForNewNotes",
value === "" ? null : value,
);
}}
>
<option value="">None</option>
{availableStatuses.map((status) => {
const identifier =
BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
});
const displayName = status.templateId
? `${status.name} (${status.templateId})`
: status.name;
return (
<option key={identifier} value={identifier}>
{displayName}
</option>
);
})}
</select>
</SettingItem>
<SettingItem
name="Enable multiple statuses"
description="Allow notes to have multiple statuses at the same time"
@ -68,44 +25,6 @@ export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
/>
</SettingItem>
<SettingItem
name="Single status format"
description="Choose how the status frontmatter value is stored when multiple statuses are disabled. String format improves compatibility with plugins like TaskNotes."
>
<select
disabled={settings.useMultipleStatuses}
value={settings.singleStatusStorageMode || "list"}
onChange={(e) =>
onChange(
"singleStatusStorageMode",
e.target
.value as PluginSettings["singleStatusStorageMode"],
)
}
>
<option value="list">List (status: [in-progress])</option>
<option value="string">String (status: in-progress)</option>
</select>
</SettingItem>
<SettingItem
name="Apply status recursively to subfolders"
description="Show a folder context menu option that also processes notes inside nested folders."
>
<input
type="checkbox"
checked={
settings.applyStatusRecursivelyToSubfolders || false
}
onChange={(e) =>
onChange(
"applyStatusRecursivelyToSubfolders",
e.target.checked,
)
}
/>
</SettingItem>
<SettingItem
name="Status tag prefix"
description="YAML frontmatter tag name for status (default: obsidian-note-status)"
@ -134,30 +53,6 @@ export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
/>
</SettingItem>
<SettingItem
name="Frontmatter mappings"
description="Map templates or individual statuses to specific YAML keys. These mappings only apply to Markdown files with frontmatter."
vertical
>
<FrontmatterMappingsSettings
settings={settings}
onChange={onChange}
/>
</SettingItem>
<SettingItem
name="Write mapped tags to default status tag"
description="When enabled, statuses mapped to custom frontmatter keys are also written to the default status tag."
>
<input
type="checkbox"
checked={settings.writeMappedTagsToDefault || false}
onChange={(e) =>
onChange("writeMappedTagsToDefault", e.target.checked)
}
/>
</SettingItem>
<SettingItem
name="Vault size limit"
description="Disable dashboard and grouped view for vaults with more notes than this limit to improve performance. Set to 0 to disable this feature."

View file

@ -1,15 +1,13 @@
import { NoteStatus } from "@/types/noteStatus";
import React from "react";
import { Input } from "@/components/atoms/Input";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import { IconSelectionField } from "./IconSelectionField";
export type Props = {
status: NoteStatus;
index: number;
onCustomStatusChange: (
index: number,
column: "name" | "icon" | "color" | "description" | "lucideIcon",
column: "name" | "icon" | "color" | "description",
value: string,
) => void;
onCustomStatusRemove: (index: number) => void;
@ -30,28 +28,26 @@ export const CustomStatusItem: React.FC<Props> = ({
canMoveDown = false,
}) => {
const isValid = status.name.trim().length > 0;
const displayIcon = status.icon.trim() || "📝";
return (
<div className="custom-status-item">
<IconSelectionField
className="custom-status-item__icon-fields"
emojiValue={status.icon}
onEmojiChange={(value) =>
onCustomStatusChange(index, "icon", value || "")
}
emojiLabel="Emoji icon"
emojiPlaceholder="Example: ✅ or 🚧"
emojiHint="Shown anywhere Lucide is not available."
lucideValue={status.lucideIcon || ""}
onLucideChange={(value) =>
onCustomStatusChange(index, "lucideIcon", value)
}
lucideLabel="Lucide icon (optional)"
lucidePlaceholder="Browse Lucide icons"
lucideHint="Matches Obsidian's toolbar icons so your status button blends in."
/>
{/* Simple horizontal layout with remaining inputs */}
{/* 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">
@ -143,13 +139,12 @@ export const CustomStatusItem: React.FC<Props> = ({
{/* Preview row - shows how the status will look */}
<div className="custom-status-item__preview">
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
<span
className="custom-status-item__preview-icon"
size={18}
style={{ color: status.color }}
/>
>
{displayIcon}
</span>
<span
className="custom-status-item__preview-text"
style={{ color: status.color }}

View file

@ -16,18 +16,17 @@ export const CustomStatusSettings: React.FC<Props> = ({
onChange,
}) => {
const addNewCustomStatus = () => {
const currentStatuses = [...(settings.customStatuses || [])];
const currentStatuses = [...settings.customStatuses];
currentStatuses.push({
name: "",
icon: "",
lucideIcon: "",
});
onChange("customStatuses", currentStatuses);
};
const removeCustomStatus: CustomStatusItemProps["onCustomStatusRemove"] = (
index,
) => {
const currentStatuses = [...(settings.customStatuses || [])];
const currentStatuses = [...settings.customStatuses];
currentStatuses.splice(index, 1);
onChange("customStatuses", currentStatuses);
};
@ -37,7 +36,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
column,
value,
) => {
const currentStatuses = [...(settings.customStatuses || [])];
const currentStatuses = [...settings.customStatuses];
const target = currentStatuses[index];
if (target) {
@ -48,7 +47,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
const moveCustomStatusUp = (index: number) => {
if (index <= 0) return;
const currentStatuses = [...(settings.customStatuses || [])];
const currentStatuses = [...settings.customStatuses];
[currentStatuses[index - 1], currentStatuses[index]] = [
currentStatuses[index],
currentStatuses[index - 1],
@ -57,8 +56,8 @@ export const CustomStatusSettings: React.FC<Props> = ({
};
const moveCustomStatusDown = (index: number) => {
const currentStatuses = [...(settings.customStatuses || [])];
if (index >= currentStatuses.length - 1) return;
if (index >= settings.customStatuses.length - 1) return;
const currentStatuses = [...settings.customStatuses];
[currentStatuses[index], currentStatuses[index + 1]] = [
currentStatuses[index + 1],
currentStatuses[index],
@ -85,11 +84,11 @@ export const CustomStatusSettings: React.FC<Props> = ({
<SettingItem
name="Custom statuses"
description="Create custom statuses with emoji icons and optional Lucide icon names plus colors. All statuses require a name."
description="Create custom statuses with icons, names, and colors. All statuses require a name."
vertical
>
<div className="custom-status-list">
{(settings.customStatuses || []).length === 0 ? (
{settings.customStatuses.length === 0 ? (
<div className="custom-status-list__empty">
<p>
No custom statuses yet. Click "Add Status" below
@ -97,7 +96,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
</p>
</div>
) : (
(settings.customStatuses || []).map((status, index) => (
settings.customStatuses.map((status, index) => (
<CustomStatusItem
key={index}
status={status}
@ -108,8 +107,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
onMoveDown={moveCustomStatusDown}
canMoveUp={index > 0}
canMoveDown={
index <
(settings.customStatuses || []).length - 1
index < settings.customStatuses.length - 1
}
/>
))

View file

@ -1,310 +0,0 @@
import React, { useMemo } from "react";
import {
PluginSettings,
StatusFrontmatterMapping,
} from "@/types/pluginSettings";
type Props = {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
};
type StatusOption = {
label: string;
value: string;
};
const isStatusMapping = (
mapping: StatusFrontmatterMapping,
): mapping is StatusFrontmatterMapping & { scope: "status" } =>
mapping.scope === "status";
const generateId = () =>
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const sanitizeKeys = (raw: string): string[] =>
raw
.split(",")
.map((key) => key.trim())
.filter((key) => Boolean(key));
const buildStatusIdentifier = (
templateId: string | undefined,
statusName: string,
): string => {
if (!templateId) {
return statusName;
}
return `${templateId}:${statusName}`;
};
export const FrontmatterMappingsSettings: React.FC<Props> = ({
settings,
onChange,
}) => {
const mappings = settings.statusFrontmatterMappings || [];
const templateOptions = useMemo(
() =>
(settings.templates || []).map((template) => ({
value: template.id,
label: template.name || template.id,
})),
[settings.templates],
);
const statusOptions = useMemo<StatusOption[]>(() => {
const templateStatuses =
settings.templates?.flatMap((template) =>
template.statuses.map((status) => ({
value: buildStatusIdentifier(template.id, status.name),
label: `${status.name} (${template.name || template.id})`,
})),
) || [];
const customStatuses =
settings.customStatuses?.map((status) => ({
value: status.name,
label: `${status.name} (Custom)`,
})) || [];
return [...templateStatuses, ...customStatuses];
}, [settings.templates, settings.customStatuses]);
const updateMappings = (nextMappings: StatusFrontmatterMapping[]) => {
onChange("statusFrontmatterMappings", nextMappings);
};
const handleMappingChange = (
id: string,
updater: (
mapping: StatusFrontmatterMapping,
) => StatusFrontmatterMapping,
) => {
updateMappings(
mappings.map((mapping) =>
mapping.id === id ? updater(mapping) : mapping,
),
);
};
const handleScopeChange = (
id: string,
scope: StatusFrontmatterMapping["scope"],
) => {
handleMappingChange(id, (mapping) => {
const frontmatterKeys = mapping.frontmatterKeys || [];
if (scope === "template") {
const templateId =
mapping.scope === "template"
? mapping.templateId
: templateOptions[0]?.value || "";
return {
id: mapping.id,
scope: "template",
templateId,
frontmatterKeys,
};
}
const defaultStatusOption = statusOptions[0];
const templateId = isStatusMapping(mapping)
? mapping.templateId
: defaultStatusOption?.value.includes(":")
? defaultStatusOption.value.split(":", 2)[0]
: undefined;
const statusName = isStatusMapping(mapping)
? mapping.statusName || ""
: defaultStatusOption?.value?.split(":")?.pop() || "";
return {
id: mapping.id,
scope: "status",
templateId,
statusName,
frontmatterKeys,
};
});
};
const handleTemplateChange = (id: string, templateId: string) => {
handleMappingChange(id, (mapping) => ({
...mapping,
scope: "template",
templateId,
}));
};
const handleStatusChange = (id: string, identifier: string) => {
const [maybeTemplateId, name] = identifier.includes(":")
? identifier.split(":", 2)
: [undefined, identifier];
handleMappingChange(id, (mapping) => ({
...mapping,
templateId: maybeTemplateId,
statusName: name,
scope: "status",
}));
};
const handleKeysChange = (id: string, raw: string) => {
const keys = sanitizeKeys(raw);
handleMappingChange(id, (mapping) => ({
...mapping,
frontmatterKeys: keys,
}));
};
const handleRemove = (id: string) => {
updateMappings(mappings.filter((mapping) => mapping.id !== id));
};
const handleAdd = () => {
const newMapping: StatusFrontmatterMapping = {
id: generateId(),
scope: "template",
templateId: templateOptions[0]?.value || "",
frontmatterKeys: [],
};
updateMappings([...mappings, newMapping]);
};
return (
<div className="status-mapping-list">
{mappings.length === 0 && (
<div className="status-mapping-empty">
No mappings configured yet.
</div>
)}
{mappings.map((mapping) => {
const currentKeys = mapping.frontmatterKeys || [];
const keysValue = currentKeys.join(", ");
const selectedStatus =
mapping.scope === "status"
? buildStatusIdentifier(
mapping.templateId,
mapping.statusName || "",
)
: undefined;
return (
<div className="status-mapping-row" key={mapping.id}>
<div className="status-mapping-row__fields">
<div className="status-mapping-field">
<label>Target type</label>
<select
value={mapping.scope}
onChange={(event) =>
handleScopeChange(
mapping.id,
event.target
.value as StatusFrontmatterMapping["scope"],
)
}
>
<option value="template">Template</option>
<option value="status">
Specific status
</option>
</select>
</div>
{mapping.scope === "template" ? (
<div className="status-mapping-field">
<label>Template</label>
<select
value={mapping.templateId || ""}
onChange={(event) =>
handleTemplateChange(
mapping.id,
event.target.value,
)
}
>
{templateOptions.length === 0 && (
<option value="">
No templates available
</option>
)}
{templateOptions.map((template) => (
<option
value={template.value}
key={template.value}
>
{template.label}
</option>
))}
</select>
</div>
) : (
<div className="status-mapping-field">
<label>Status</label>
<select
value={selectedStatus || ""}
onChange={(event) =>
handleStatusChange(
mapping.id,
event.target.value,
)
}
>
{statusOptions.length === 0 && (
<option value="">
No statuses available
</option>
)}
{statusOptions.map((option) => (
<option
value={option.value}
key={option.value}
>
{option.label}
</option>
))}
</select>
</div>
)}
<div className="status-mapping-field status-mapping-field--keys">
<label>Frontmatter keys</label>
<input
type="text"
placeholder="status, project-status"
value={keysValue}
onChange={(event) =>
handleKeysChange(
mapping.id,
event.target.value,
)
}
/>
</div>
</div>
<div className="status-mapping-row__actions">
<button
type="button"
className="status-mapping-remove-btn"
onClick={() => handleRemove(mapping.id)}
>
Remove
</button>
</div>
</div>
);
})}
<button
type="button"
className="status-mapping-add-btn"
onClick={handleAdd}
>
Add mapping
</button>
</div>
);
};

View file

@ -1,75 +0,0 @@
import React from "react";
import { Input } from "@/components/atoms/Input";
import { LucideIconPicker } from "./LucideIconPicker";
export interface IconSelectionFieldProps {
className?: string;
emojiValue: string;
onEmojiChange: (value: string) => void;
emojiLabel?: string;
emojiPlaceholder?: string;
emojiHint?: string;
lucideValue?: string;
onLucideChange?: (value: string) => void;
lucideLabel?: string;
lucidePlaceholder?: string;
lucideHint?: string;
lucideAllowClear?: boolean;
}
export const IconSelectionField: React.FC<IconSelectionFieldProps> = ({
className = "",
emojiValue,
onEmojiChange,
emojiLabel = "Emoji fallback",
emojiPlaceholder = "Example: ✅",
emojiHint,
lucideValue,
onLucideChange,
lucideLabel = "Lucide icon",
lucidePlaceholder = "Choose Lucide icon",
lucideHint,
lucideAllowClear = true,
}) => {
const containerClass = ["icon-selection-field", className]
.filter(Boolean)
.join(" ");
return (
<div className={containerClass}>
<div className="icon-selection-field__column">
<label className="icon-selection-field__label">
{emojiLabel}
</label>
<Input
variant="text"
value={emojiValue}
onChange={onEmojiChange}
placeholder={emojiPlaceholder}
className="icon-selection-field__emoji-input"
/>
{emojiHint && (
<p className="icon-selection-field__hint">{emojiHint}</p>
)}
</div>
{onLucideChange && (
<div className="icon-selection-field__column">
<label className="icon-selection-field__label">
{lucideLabel}
</label>
<LucideIconPicker
value={lucideValue}
onChange={onLucideChange}
placeholder={lucidePlaceholder}
allowClear={lucideAllowClear}
/>
{lucideHint && (
<p className="icon-selection-field__hint">
{lucideHint}
</p>
)}
</div>
)}
</div>
);
};

View file

@ -1,223 +0,0 @@
import React, {
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { App, Modal } from "obsidian";
import { IconName, getIconIds } from "obsidian";
import { Input } from "@/components/atoms/Input";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import { ObsidianIcon } from "@/components/atoms/ObsidianIcon";
import { createRoot, Root } from "react-dom/client";
const BATCH_SIZE = 80;
interface LucideIconModalProps {
initialValue?: string;
onSelect: (value: string) => void;
onCloseRequest: () => void;
}
const LucideIconModalContent: FC<LucideIconModalProps> = ({
initialValue = "",
onSelect,
onCloseRequest,
}) => {
const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(BATCH_SIZE);
const [showLabels, setShowLabels] = useState(true);
const listRef = useRef<HTMLDivElement>(null);
const iconIds = useMemo(() => {
try {
return getIconIds()
.slice()
.sort((a, b) => a.localeCompare(b));
} catch (error) {
console.error("Failed to fetch Lucide icon list", error);
return [] as IconName[];
}
}, []);
const computeFuzzyScore = useCallback(
(needle: string, haystack: string): number | null => {
let lastIndex = -1;
let score = 0;
for (let i = 0; i < needle.length; i += 1) {
const char = needle[i];
const idx = haystack.indexOf(char, lastIndex + 1);
if (idx === -1) {
return null;
}
score += idx - lastIndex;
lastIndex = idx;
}
return score + (haystack.length - lastIndex);
},
[],
);
const filteredIcons = useMemo(() => {
if (!iconIds.length) {
return [];
}
const normalized = query.trim().toLowerCase();
if (!normalized.length) {
return iconIds;
}
const matches = iconIds
.map((name) => {
const target = name.toLowerCase();
const score = computeFuzzyScore(normalized, target);
return score === null ? null : { name, score };
})
.filter(
(
entry,
): entry is {
name: string;
score: number;
} => entry !== null,
)
.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));
return matches.map((entry) => entry.name);
}, [iconIds, query, computeFuzzyScore]);
const visibleIcons = useMemo(
() => filteredIcons.slice(0, visibleCount),
[filteredIcons, visibleCount],
);
useEffect(() => {
setVisibleCount(BATCH_SIZE);
listRef.current?.scrollTo({ top: 0 });
}, [query, filteredIcons.length]);
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (
target.scrollTop + target.clientHeight >=
target.scrollHeight - 64
) {
setVisibleCount((prev) =>
Math.min(prev + BATCH_SIZE, filteredIcons.length),
);
}
},
[filteredIcons.length],
);
const handleSelect = (iconName: string) => {
onSelect(iconName);
onCloseRequest();
};
return (
<div className="lucide-icon-modal">
<div className="lucide-icon-modal__header">
<div>
<h3 className="modal-title">Select Lucide icon</h3>
<p className="lucide-icon-modal__count">
Showing {visibleIcons.length} of {filteredIcons.length}{" "}
( Total {iconIds.length})
</p>
</div>
<button
type="button"
className="modal-close-button lucide-icon-modal__close"
onClick={onCloseRequest}
aria-label="Close"
>
<ObsidianIcon name="x" size={14} />
</button>
</div>
<Input
variant="search"
value={query}
onChange={setQuery}
placeholder="Search icons…"
className="lucide-icon-modal__search"
/>
<label className="lucide-icon-modal__toggle">
<input
type="checkbox"
checked={showLabels}
onChange={(e) => setShowLabels(e.target.checked)}
/>
Show icon names
</label>
<div
ref={listRef}
className={`lucide-icon-modal__list${showLabels ? "" : " lucide-icon-modal__list--icons-only"}`}
onScroll={handleScroll}
>
{visibleIcons.map((iconName) => (
<button
key={iconName}
type="button"
className={`lucide-icon-modal__option${iconName === initialValue ? " lucide-icon-modal__option--selected" : ""}${showLabels ? "" : " lucide-icon-modal__option--compact"}`}
onClick={() => handleSelect(iconName)}
>
<StatusIcon
icon={iconName}
lucideIcon={iconName}
size={16}
/>
<span>{iconName}</span>
</button>
))}
{visibleIcons.length === 0 && (
<div className="lucide-icon-modal__empty">
No icons match {query.trim()}.
</div>
)}
</div>
</div>
);
};
type ModalOptions = {
initialValue?: string;
onSelect: (value: string) => void;
};
export class LucideIconModal extends Modal {
private root: Root | null = null;
private readonly options: ModalOptions;
constructor(app: App, options: ModalOptions) {
super(app);
this.options = options;
}
onOpen(): void {
this.contentEl.empty();
this.contentEl.addClass("lucide-icon-modal-wrapper");
this.root = createRoot(this.contentEl);
this.root.render(
<LucideIconModalContent
initialValue={this.options.initialValue}
onSelect={this.options.onSelect}
onCloseRequest={() => this.close()}
/>,
);
}
onClose(): void {
this.root?.unmount();
this.root = null;
this.contentEl.empty();
}
}

View file

@ -1,79 +0,0 @@
import React from "react";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import { LucideIconModal } from "./LucideIconModal";
import { App } from "obsidian";
import { Input } from "@/components/atoms/Input";
const getAppInstance = (): App | undefined => {
return (window as typeof window & { app?: App }).app;
};
export interface LucideIconPickerProps {
value?: string;
onChange: (value: string) => void;
placeholder?: string;
allowTextInput?: boolean;
allowClear?: boolean;
}
export const LucideIconPicker: React.FC<LucideIconPickerProps> = ({
value = "",
onChange,
placeholder = "Choose Lucide icon",
allowTextInput = false,
allowClear = true,
}) => {
const openModal = () => {
const app = getAppInstance();
if (!app) {
console.warn("Cannot open Lucide icon modal without Obsidian app");
return;
}
const modal = new LucideIconModal(app, {
initialValue: value,
onSelect: (icon) => onChange(icon),
});
modal.open();
};
const handleClear = () => {
onChange("");
};
return (
<div className="lucide-icon-picker">
{allowTextInput && (
<Input
variant="text"
value={value}
onChange={(val) => onChange(val)}
placeholder="Emoji or icon name"
className="lucide-icon-picker__text-input"
/>
)}
<div className="lucide-icon-picker__row">
<button
type="button"
className="lucide-icon-picker__modal-button"
onClick={openModal}
>
<span className="lucide-icon-picker__preview">
<StatusIcon icon={value} lucideIcon={value} size={18} />
</span>
<span className="lucide-icon-picker__label">
{value || placeholder}
</span>
</button>
{allowClear && value && (
<button
type="button"
className="lucide-icon-picker__clear-trigger"
onClick={handleClear}
>
Clear
</button>
)}
</div>
</div>
);
};

View file

@ -1,208 +0,0 @@
import React, { useState, useMemo } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import {
PREDEFINED_TEMPLATES,
COMMUNITY_TEMPLATES,
} from "@/constants/predefinedTemplates";
import { StatusDisplay } from "../atoms/StatusDisplay";
import { ObsidianIcon } from "../atoms/ObsidianIcon";
import { SearchFilter } from "../atoms/SearchFilter";
interface MarketplaceBrowseModalProps {
installedTemplates: StatusTemplate[];
onInstall: (template: StatusTemplate) => void;
onClose: () => void;
}
export const MarketplaceBrowseModal: React.FC<MarketplaceBrowseModalProps> = ({
installedTemplates,
onInstall,
onClose,
}) => {
const [searchQuery, setSearchQuery] = useState("");
const [languageFilter, setLanguageFilter] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const allTemplates = useMemo(() => {
return [...PREDEFINED_TEMPLATES, ...COMMUNITY_TEMPLATES];
}, []);
const availableLanguages = useMemo(() => {
const langs = new Set<string>();
allTemplates.forEach((t) => {
if (t.language) langs.add(t.language);
});
return Array.from(langs).sort();
}, [allTemplates]);
const availableTypes = useMemo(() => {
const types = new Set<string>();
allTemplates.forEach((t) => {
if (t.type) types.add(t.type);
});
return Array.from(types).sort();
}, [allTemplates]);
const filteredTemplates = useMemo(() => {
return allTemplates.filter((t) => {
const matchesSearch =
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesLanguage =
!languageFilter || t.language === languageFilter;
const matchesType = !typeFilter || t.type === typeFilter;
return matchesSearch && matchesLanguage && matchesType;
});
}, [allTemplates, searchQuery, languageFilter, typeFilter]);
return (
<div className="template-editor-modal marketplace-browse-modal">
<div className="template-editor-modal__header">
<div className="marketplace-header-content">
<h2>Template Marketplace</h2>
<div style={{ display: "flex", gap: "8px" }}>
<SearchFilter
value={searchQuery}
onFilterChange={setSearchQuery}
placeholder="Search templates..."
/>
{availableLanguages.length > 0 && (
<select
value={languageFilter}
onChange={(e) =>
setLanguageFilter(e.target.value)
}
className="dropdown"
>
<option value="">All Languages</option>
{availableLanguages.map((lang) => (
<option key={lang} value={lang}>
{lang}
</option>
))}
</select>
)}
{availableTypes.length > 0 && (
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="dropdown"
>
<option value="">All Types</option>
{availableTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
)}
</div>
</div>
</div>
<div className="template-editor-modal__content">
<div className="marketplace-grid">
{filteredTemplates.map((template) => {
const isInstalled = installedTemplates.some(
(t) =>
t.name.toLowerCase() ===
template.name.toLowerCase(),
);
return (
<div key={template.id} className="marketplace-card">
<div className="marketplace-card-header">
<div className="marketplace-card-title">
{template.name}
</div>
<div className="marketplace-card-meta">
<span
className={`template-badge ${template.isPredefined ? "badge-accent" : "badge-info"}`}
>
{template.isPredefined
? "Built-in"
: "Community"}
</span>
{template.type && (
<span className="template-badge badge-neutral">
{template.type}
</span>
)}
{template.language && (
<span className="template-badge badge-neutral">
{template.language}
</span>
)}
{template.authorGithub && (
<div className="marketplace-card-author">
by{" "}
<a
href={`https://github.com/${template.authorGithub}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) =>
e.stopPropagation()
}
>
@{template.authorGithub}
</a>
</div>
)}
</div>
</div>
<div className="marketplace-card-description">
{template.description}
</div>
<div className="marketplace-card-statuses">
{template.statuses.map((status, idx) => (
<StatusDisplay
key={idx}
status={status}
variant="template"
/>
))}
</div>
<div className="marketplace-card-actions">
<button
className={`marketplace-install-btn ${
isInstalled
? "installed"
: "mod-cta"
}`}
onClick={() =>
!isInstalled && onInstall(template)
}
disabled={isInstalled}
>
<ObsidianIcon
name={
isInstalled
? "check"
: "download"
}
size={16}
/>
{isInstalled
? "Installed"
: "Install Template"}
</button>
</div>
</div>
);
})}
</div>
{filteredTemplates.length === 0 && (
<div className="marketplace-empty">
<ObsidianIcon name="search-slash" size={48} />
<p>No templates found matching your search.</p>
</div>
)}
</div>
<div className="template-editor-modal__actions">
<button onClick={onClose}>Close</button>
</div>
</div>
);
};

View file

@ -1,107 +0,0 @@
import React, { useState, useCallback } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { ObsidianIcon } from "../atoms/ObsidianIcon";
interface MarketplaceShareModalProps {
template: StatusTemplate;
onClose: () => void;
}
export const MarketplaceShareModal: React.FC<MarketplaceShareModalProps> = ({
template,
onClose,
}) => {
const [copied, setCopied] = useState(false);
const marketplaceTemplate = {
...template,
id: template.id.replace(/^custom-/, ""),
statuses: template.statuses.map((s) => ({
...s,
templateId: template.id.replace(/^custom-/, ""),
})),
};
const jsonString = JSON.stringify(marketplaceTemplate, null, 2);
const filename = `${marketplaceTemplate.id}.json`;
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [jsonString]);
return (
<div className="template-editor-modal marketplace-share-modal">
<div className="template-editor-modal__header">
<h2>Submit to Marketplace</h2>
</div>
<div className="template-editor-modal__content">
<p>
Share your template with the community by following these
steps:
</p>
<ol className="marketplace-steps">
<li>
<strong>Copy</strong> the template JSON data below.
</li>
<li>
Go to the <strong>templates folder</strong> in the
official repository:
<br />
<a
href="https://github.com/devonthesofa/obsidian-note-status/tree/master/templates"
target="_blank"
rel="noopener noreferrer"
>
github.com/devonthesofa/obsidian-note-status/templates
</a>
</li>
<li>
Click <strong>Add file</strong> &gt;{" "}
<strong>Create new file</strong>.
</li>
<li>
Name it <code>{filename}</code> and{" "}
<strong>paste</strong> the content.
</li>
<li>
Click <strong>Propose new file</strong> and submit the
Pull Request.
</li>
</ol>
<p className="marketplace-note">
<em>
Note: Submissions are reviewed by maintainers. Once
verified, your template will be included in the next
plugin update for all users!
</em>
</p>
<div className="marketplace-json-preview">
<div className="json-preview-header">
<span>{filename}</span>
<button
className={`copy-btn ${copied ? "success" : ""}`}
onClick={handleCopy}
>
<ObsidianIcon
name={copied ? "check" : "copy"}
size={14}
/>
{copied ? "Copied!" : "Copy JSON"}
</button>
</div>
<pre>{jsonString}</pre>
</div>
</div>
<div className="template-editor-modal__actions">
<button onClick={onClose}>Close</button>
</div>
</div>
);
};

View file

@ -41,12 +41,7 @@ export const QuickCommandsSettings: React.FC<Props> = ({
}
}
return { customStatuses, templateGroups };
}, [
settings.useCustomStatusesOnly,
settings.customStatuses,
settings.enabledTemplates,
settings.templates,
]);
}, [settings]);
const currentQuickCommands = settings.quickStatusCommands || [];

View file

@ -1,6 +1,5 @@
import React from "react";
import { PluginSettings } from "@/types/pluginSettings";
import { StatusIcon } from "@/components/atoms/StatusIcon";
export type StatusGroupProps = {
statuses: PluginSettings["customStatuses"];
@ -40,12 +39,9 @@ export const StatusGroup: React.FC<StatusGroupProps> = ({
>
<div className="status-selector__content">
<div className="status-selector__status">
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
className="status-selector__icon"
size={16}
/>
<span className="status-selector__icon">
{status.icon}
</span>
<span className="status-selector__name">
{status.name}
</span>

View file

@ -1,271 +0,0 @@
import { PluginSettings, SyncGroup } from "@/types/pluginSettings";
import React from "react";
import { SettingItem } from "./SettingItem";
import settingsService from "@/core/settingsService";
import statusStoreManager from "@/core/statusStoreManager";
export type Props = {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
};
export const SynchronizationSettings: React.FC<Props> = ({
settings,
onChange,
}) => {
const handleExport = async () => {
await settingsService.syncToExternalFile(true);
alert("Plugin settings exported successfully.");
};
const handleImport = async () => {
await settingsService.loadFromExternalFile(true);
alert("Plugin settings imported successfully.");
};
const handleDataExport = async () => {
try {
await statusStoreManager.getNonMarkdownStore().exportToVault();
alert("Non-Markdown status data exported to vault.");
} catch (e) {
alert(`Export failed: ${e.message}`);
}
};
const handleDataImport = async () => {
try {
await statusStoreManager.getNonMarkdownStore().importFromVault();
alert("Non-Markdown status data imported from vault.");
} catch (e) {
alert(`Import failed: ${e.message}`);
}
};
const toggleGroup = (group: SyncGroup) => {
const currentGroups = [...(settings.syncGroups || [])];
const index = currentGroups.indexOf(group);
if (index === -1) {
currentGroups.push(group);
} else {
currentGroups.splice(index, 1);
}
onChange("syncGroups", currentGroups);
};
const syncGroups: { id: SyncGroup; label: string; description: string }[] =
[
{
id: "templates",
label: "Templates",
description: "Status templates and their enabled status.",
},
{
id: "customStatuses",
label: "Custom Statuses",
description: "Standalone custom statuses.",
},
{
id: "statusColors",
label: "Status Colors",
description: "Custom colors for all statuses.",
},
{
id: "uiAppearance",
label: "UI & Appearance",
description: "Icons, frames, status bar, and toolbar styles.",
},
{
id: "workflow",
label: "Workflow Rules",
description: "Multi-status, strict mode, and overview popups.",
},
{
id: "storage",
label: "Storage & Tagging",
description: "Frontmatter keys, mappings, and tag prefix.",
},
{
id: "features",
label: "Features & Limits",
description: "Quick commands, experiments, and vault limits.",
},
];
return (
<div>
<h3>Multi-device Synchronization</h3>
<p>
Keep your plugin settings and data in sync across devices using
files in your vault.
</p>
<div className="sync-section" style={{ marginBottom: "30px" }}>
<h4>Settings Synchronization</h4>
<p className="setting-item-description">
Save plugin configuration to a JSON file.
</p>
<SettingItem
name="Enable settings synchronization"
description="Automatically save and load selected plugin settings from a file in your vault."
>
<input
type="checkbox"
checked={settings.enableExternalStatusSync || false}
onChange={(e) =>
onChange(
"enableExternalStatusSync",
e.target.checked,
)
}
/>
</SettingItem>
<SettingItem
name="Settings sync file path"
description="Path to the JSON file where settings will be stored (relative to vault root)."
>
<input
type="text"
value={
settings.externalStatusSyncPath ||
"_note-status-sync.json"
}
onChange={(e) =>
onChange("externalStatusSyncPath", e.target.value)
}
placeholder="_note-status-sync.json"
/>
</SettingItem>
<div
className="sync-groups-container"
style={{ margin: "20px 0" }}
>
<h5>Selective Synchronization</h5>
<p className="setting-item-description">
Choose which groups of settings should be included in
the synchronization.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "10px",
marginTop: "10px",
}}
>
{syncGroups.map((group) => (
<div
key={group.id}
className={`selectable-group-item ${
(settings.syncGroups || []).includes(
group.id,
)
? "is-selected"
: ""
}`}
style={{
padding: "10px",
border: "1px solid var(--background-modifier-border)",
borderRadius: "4px",
cursor: "pointer",
backgroundColor: (
settings.syncGroups || []
).includes(group.id)
? "var(--background-modifier-hover)"
: "transparent",
}}
onClick={() => toggleGroup(group.id)}
>
<div style={{ fontWeight: "bold" }}>
<input
type="checkbox"
checked={(
settings.syncGroups || []
).includes(group.id)}
onChange={() => {}} // Handled by parent div onClick
style={{ marginRight: "8px" }}
/>
{group.label}
</div>
<div
className="setting-item-description"
style={{
marginTop: "4px",
fontSize: "0.85em",
}}
>
{group.description}
</div>
</div>
))}
</div>
</div>
<div
className="note-status-settings__actions"
style={{ marginTop: "1em", display: "flex", gap: "10px" }}
>
<button onClick={handleExport}>
📤 Export selected now
</button>
<button onClick={handleImport}>
📥 Import selected now
</button>
</div>
</div>
<div className="sync-section">
<h4>Non-Markdown Data Synchronization</h4>
<p className="setting-item-description">
Statuses for non-Markdown files (PDFs, images, etc.) are
stored in a special data file. Enable this to keep them in
sync across devices.
</p>
<SettingItem
name="Sync non-Markdown statuses"
description="Store non-Markdown status data in a vault file instead of the internal plugin folder."
>
<input
type="checkbox"
checked={settings.enableNonMarkdownSync || false}
onChange={(e) =>
onChange("enableNonMarkdownSync", e.target.checked)
}
/>
</SettingItem>
<SettingItem
name="Data sync file path"
description="Path to the JSON file where non-Markdown statuses will be stored."
>
<input
type="text"
value={
settings.nonMarkdownSyncPath ||
"_note-status-data.json"
}
onChange={(e) =>
onChange("nonMarkdownSyncPath", e.target.value)
}
placeholder="_note-status-data.json"
/>
</SettingItem>
<div
className="note-status-settings__actions"
style={{ marginTop: "1em", display: "flex", gap: "10px" }}
>
<button onClick={handleDataExport}>
📤 Export internal data to vault
</button>
<button onClick={handleDataImport}>
📥 Import data from vault
</button>
</div>
</div>
</div>
);
};

View file

@ -1,11 +1,9 @@
import React, { useState, useCallback, useMemo } from "react";
import React, { useState, useCallback } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { NoteStatus } from "@/types/noteStatus";
import { Input } from "@/components/atoms/Input";
import { SettingItem } from "./SettingItem";
import { CustomStatusItem } from "./CustomStatusItem";
import { isCustomTemplate } from "@/utils/templateUtils";
import settingsService from "@/core/settingsService";
interface TemplateEditorModalProps {
template?: StatusTemplate;
@ -18,27 +16,13 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
onSave,
onCancel,
}) => {
const isCustom = useMemo(
() => (template ? isCustomTemplate(template) : true),
[template],
);
const [name, setName] = useState(template?.name || "");
const [id, setId] = useState(template?.id || "");
const [isIdTouched, setIsIdTouched] = useState(!!template?.id);
const [isIdUnlocked, setIsIdUnlocked] = useState(false);
const [isAuthorUnlocked, setIsAuthorUnlocked] = useState(false);
const [description, setDescription] = useState(template?.description || "");
const [language, setLanguage] = useState(template?.language || "");
const [type, setType] = useState(template?.type || "");
const [authorGithub, setAuthorGithub] = useState(
template?.authorGithub || "",
);
const [statuses, setStatuses] = useState<NoteStatus[]>(
template?.statuses || [
{
name: "",
icon: "",
lucideIcon: "",
color: "#888888",
templateId: template?.id || "",
},
@ -46,24 +30,15 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
);
const isEditing = !!template;
const isIdUnique =
isEditing && id === template?.id
? true
: !settingsService.settings.templates.some(
(t) => t.id === id.trim(),
);
const isValid =
name.trim().length > 0 &&
id.trim().length > 0 &&
isIdUnique &&
description.trim().length > 0 &&
statuses.some((s) => s.name.trim().length > 0);
const handleSave = useCallback(() => {
if (!isValid) return;
const templateId = id.trim();
const templateId = template?.id || `custom-${Date.now()}`;
const validStatuses = statuses.filter((s) => s.name.trim().length > 0);
// Update templateId for all statuses
@ -76,39 +51,11 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
id: templateId,
name: name.trim(),
description: description.trim(),
language: language.trim() || undefined,
type: type.trim() || undefined,
authorGithub: authorGithub.trim() || undefined,
statuses: statusesWithTemplateId,
};
onSave(savedTemplate);
}, [
id,
name,
description,
language,
type,
authorGithub,
statuses,
isValid,
onSave,
]);
const handleNameChange = useCallback(
(val: string) => {
setName(val);
if (!isIdTouched && !isEditing) {
setId(val.toLowerCase().replace(/[^a-z0-9]/g, "-"));
}
},
[isIdTouched, isEditing],
);
const handleIdChange = useCallback((val: string) => {
setId(val.toLowerCase().replace(/[^a-z0-9-]/g, ""));
setIsIdTouched(true);
}, []);
}, [name, description, statuses, template, isValid, onSave]);
const handleAddStatus = useCallback(() => {
setStatuses((prev) => [
@ -116,7 +63,6 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
{
name: "",
icon: "",
lucideIcon: "",
color: "#888888",
templateId: template?.id || "",
},
@ -126,7 +72,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
const handleStatusChange = useCallback(
(
index: number,
column: "name" | "icon" | "color" | "description" | "lucideIcon",
column: "name" | "icon" | "color" | "description",
value: string,
) => {
setStatuses((prev) =>
@ -180,46 +126,11 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
<Input
variant="text"
value={name}
onChange={handleNameChange}
onChange={setName}
placeholder="e.g. Project Workflow"
/>
</SettingItem>
<SettingItem
name="Template ID"
description={
"A unique identifier for your template (used in the frontmatter settings). Cannot contain spaces." +
(!isIdUnique && id.trim().length > 0
? " (This ID is already taken!)"
: "") +
(isEditing && !isIdUnlocked
? " Warning: Changing this ID will detach it from existing notes using it."
: "")
}
>
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
width: "100%",
}}
>
<Input
variant="text"
value={id}
onChange={handleIdChange}
placeholder="e.g. project-workflow"
disabled={isEditing && !isIdUnlocked}
/>
{isEditing && !isIdUnlocked && (
<button onClick={() => setIsIdUnlocked(true)}>
Unlock
</button>
)}
</div>
</SettingItem>
<SettingItem
name="Description"
description="Brief description of the template's purpose"
@ -232,64 +143,9 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
/>
</SettingItem>
<SettingItem
name="Template Type (Optional)"
description="Categorize your template (e.g. Academic, Project Management, Writing, General)"
>
<Input
variant="text"
value={type}
onChange={setType}
placeholder="e.g. Project Management"
/>
</SettingItem>
<SettingItem
name="Language (Optional)"
description="Primary language of the statuses in this template"
>
<Input
variant="text"
value={language}
onChange={setLanguage}
placeholder="e.g. English"
/>
</SettingItem>
<SettingItem
name="Author GitHub Username (Optional)"
description={
isCustom
? "Your GitHub username for attribution"
: "Original author of the marketplace template"
}
>
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
width: "100%",
}}
>
<Input
variant="text"
value={authorGithub}
onChange={setAuthorGithub}
placeholder="e.g. janedoe"
disabled={!isCustom && !isAuthorUnlocked}
/>
{!isCustom && !isAuthorUnlocked && (
<button onClick={() => setIsAuthorUnlocked(true)}>
Unlock
</button>
)}
</div>
</SettingItem>
<SettingItem
name="Statuses"
description="Define the statuses included in this template, including emojis and optional Lucide icons"
description="Define the statuses included in this template"
vertical
>
<div className="template-editor-statuses">

View file

@ -1,8 +1,8 @@
import React, { useMemo } from "react";
import React from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { StatusDisplay } from "../atoms/StatusDisplay";
import { SelectableListItem } from "../atoms/SelectableListItem";
import { isCustomTemplate, isTemplateModified } from "@/utils/templateUtils";
import { isCustomTemplate } from "@/utils/templateUtils";
import { ObsidianIcon } from "../atoms/ObsidianIcon";
interface TemplateItemProps {
@ -11,7 +11,6 @@ interface TemplateItemProps {
onToggle: (templateId: string, enabled: boolean) => void;
onEdit?: (template: StatusTemplate) => void;
onDelete?: (templateId: string) => void;
onShare?: (template: StatusTemplate) => void;
}
export const TemplateItem: React.FC<TemplateItemProps> = ({
@ -20,14 +19,7 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
onToggle,
onEdit,
onDelete,
onShare,
}) => {
const isCustom = useMemo(() => isCustomTemplate(template), [template]);
const isModified = useMemo(
() => !isCustom && isTemplateModified(template),
[isCustom, template],
);
const handleClick = (e: React.MouseEvent) => {
// Don't toggle if clicking action buttons
if ((e.target as HTMLElement).closest(".template-item-actions")) {
@ -37,7 +29,7 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
};
return (
<div className={`template-item ${isEnabled ? "enabled" : "disabled"}`}>
<div className={`template-item ${isEnabled ? "enabled" : ""}`}>
<SelectableListItem
selected={isEnabled}
onClick={handleClick}
@ -56,47 +48,14 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
<span className="setting-item-name">
{template.name}
</span>
<div className="template-badges">
{isEnabled ? (
<span className="template-badge badge-success">
Active
</span>
) : (
<span className="template-badge badge-muted">
Inactive
</span>
)}
{isCustom ? (
<span className="template-badge badge-accent">
Custom
</span>
) : (
<span className="template-badge badge-info">
Marketplace
</span>
)}
{isModified && (
<span className="template-badge badge-warning">
Modified
</span>
)}
</div>
{isCustomTemplate(template.id) && (
<span className="template-custom-badge">
Custom
</span>
)}
</div>
<div className="setting-item-description">
{template.description}
{template.authorGithub && (
<div className="template-author-info">
By{" "}
<a
href={`https://github.com/${template.authorGithub}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
@{template.authorGithub}
</a>
</div>
)}
{template.description}:
</div>
<div className="template-statuses">
{template.statuses.map((status, index) => (
@ -110,18 +69,6 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
</div>
</SelectableListItem>
<div className="template-item-actions">
{isCustom && onShare && (
<button
onClick={(e) => {
e.stopPropagation();
onShare(template);
}}
title="Submit to Marketplace"
className="template-marketplace-btn"
>
<ObsidianIcon name="share" size={16} />
</button>
)}
{onEdit && (
<button
onClick={(e) => {

View file

@ -2,13 +2,14 @@ import React, { useCallback, useState } from "react";
import { PluginSettings, StatusTemplate } from "@/types/pluginSettings";
import { TemplateItem } from "./TemplateItem";
import { TemplateEditorModal } from "./TemplateEditorModal";
import { MarketplaceShareModal } from "./MarketplaceShareModal";
import { MarketplaceBrowseModal } from "./MarketplaceBrowseModal";
import { ObsidianIcon } from "../atoms/ObsidianIcon";
import {
generateTemplateId,
isTemplateNameUnique,
} from "@/utils/templateUtils";
import {
DEFAULT_ENABLED_TEMPLATES,
PREDEFINED_TEMPLATES,
} from "@/constants/predefinedTemplates";
interface TemplateSettingsProps {
settings: PluginSettings;
@ -20,18 +21,13 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
onChange,
}) => {
const [showEditor, setShowEditor] = useState(false);
const [showShare, setShowShare] = useState(false);
const [showMarketplace, setShowMarketplace] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<
StatusTemplate | undefined
>();
const [sharingTemplate, setSharingTemplate] = useState<
StatusTemplate | undefined
>();
const handleTemplateToggle = useCallback(
(templateId: string, enabled: boolean) => {
let newEnabledTemplates = [...(settings.enabledTemplates || [])];
let newEnabledTemplates = [...settings.enabledTemplates];
if (enabled) {
if (!newEnabledTemplates.includes(templateId)) {
newEnabledTemplates.push(templateId);
@ -56,43 +52,6 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
setShowEditor(true);
}, []);
const handleShareTemplate = useCallback((template: StatusTemplate) => {
setSharingTemplate(template);
setShowShare(true);
}, []);
const handleInstallTemplate = useCallback(
(template: StatusTemplate) => {
if (!isTemplateNameUnique(template.name, undefined)) {
alert(
`A template with the name "${template.name}" is already installed.`,
);
return;
}
// Try to use the original ID if it's unique, otherwise generate a new one
const isIdUnique = !settings.templates.some(
(t) => t.id === template.id,
);
const newId = isIdUnique
? template.id
: generateTemplateId(template.name, settings.templates);
const installedTemplate: StatusTemplate = {
...template,
id: newId,
statuses: template.statuses.map((s) => ({
...s,
templateId: newId,
})),
};
onChange("templates", [...settings.templates, installedTemplate]);
handleTemplateToggle(newId, true);
},
[settings.templates, onChange, handleTemplateToggle],
);
const handleSaveTemplate = useCallback(
(template: StatusTemplate) => {
// Validate name uniqueness
@ -113,16 +72,8 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
);
onChange("templates", updatedTemplates);
} else {
// Add new template
// Try to use the original ID if it's unique, otherwise generate a new one
const isIdUnique =
template.id &&
!settings.templates.some((t) => t.id === template.id);
const uniqueId = isIdUnique
? template.id
: generateTemplateId(template.name, settings.templates);
// Add new template - generate unique ID
const uniqueId = generateTemplateId(template.name);
finalTemplate = { ...template, id: uniqueId };
// Update statuses with the final template ID
@ -167,24 +118,24 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
[settings.templates, settings.enabledTemplates, onChange],
);
const handleResetToDefaults = useCallback(() => {
const confirmed = confirm(
"Reset to default templates? This will:\n" +
"• Remove all custom templates\n" +
"• Reset enabled templates to defaults\n" +
"This action cannot be undone.",
);
if (!confirmed) return;
onChange("templates", [...PREDEFINED_TEMPLATES]);
onChange("enabledTemplates", [...DEFAULT_ENABLED_TEMPLATES]);
}, [onChange]);
const handleCancelEditor = useCallback(() => {
setShowEditor(false);
setEditingTemplate(undefined);
}, []);
const handleCancelShare = useCallback(() => {
setShowShare(false);
setSharingTemplate(undefined);
}, []);
const handleOpenMarketplace = useCallback(() => {
setShowMarketplace(true);
}, []);
const handleCloseMarketplace = useCallback(() => {
setShowMarketplace(false);
}, []);
if (showEditor) {
return (
<div>
@ -198,68 +149,43 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
);
}
if (showShare && sharingTemplate) {
return (
<div>
<h3>Status templates</h3>
<MarketplaceShareModal
template={sharingTemplate}
onClose={handleCancelShare}
/>
</div>
);
}
if (showMarketplace) {
return (
<div>
<h3>Status templates</h3>
<MarketplaceBrowseModal
installedTemplates={settings.templates}
onInstall={handleInstallTemplate}
onClose={handleCloseMarketplace}
/>
</div>
);
}
return (
<div>
<h3>Status templates</h3>
<p>
Browse the marketplace to find common status workflows or create
your own custom templates.
Enable predefined templates to quickly add common status
workflows, or create your own custom templates.
</p>
{/* Custom Templates Section */}
<div className="template-section">
<h4 className="template-section-title">Custom Templates</h4>
<div className="template-settings-actions">
<button
className="mod-cta marketplace-browse-btn"
onClick={handleOpenMarketplace}
>
<ObsidianIcon name="globe" size={16} />
Browse Marketplace
</button>
<button
className="template-create-btn"
className="mod-cta template-create-btn"
onClick={handleCreateTemplate}
>
+ Create Template
</button>
<button
className="template-reset-btn"
onClick={handleResetToDefaults}
>
🔄 Reset to Defaults
</button>
</div>
<div className="template-list">
{(settings.templates || []).map((template) => (
{settings.templates.map((template) => (
<TemplateItem
key={template.id}
template={template}
isEnabled={(
settings.enabledTemplates || []
).includes(template.id)}
isEnabled={settings.enabledTemplates.includes(
template.id,
)}
onToggle={handleTemplateToggle}
onEdit={handleEditTemplate}
onDelete={handleDeleteTemplate}
onShare={handleShareTemplate}
/>
))}
</div>

View file

@ -1,61 +1,152 @@
import React from "react";
import { PluginSettings } from "@/types/pluginSettings";
import React from "react";
import { Input } from "../atoms/Input";
import { Select } from "../atoms/Select";
import { SettingItem } from "./SettingItem";
import { IconSelectionField } from "./IconSelectionField";
export type Props = {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
};
export const StatusBarSettings: React.FC<Props> = ({ settings, onChange }) => {
const handleCheckbox =
export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
const handleChange =
(key: keyof PluginSettings) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(key, e.target.checked);
};
return (
<div className="ui-settings__group">
<h3>Status Bar</h3>
<div className="ui-settings">
<h3>User interface</h3>
<SettingItem
name="Show status icon in status bar"
description="Display a status icon in the status bar with the current note status."
name="Show status bar"
description="Display the status bar"
>
<input
type="checkbox"
checked={settings.showStatusBar}
onChange={handleCheckbox("showStatusBar")}
onChange={handleChange("showStatusBar")}
/>
</SettingItem>
<SettingItem
name="Hide icon when note has no status"
description="Automatically hide the status icon whenever the current note does not have a status."
name="Auto-hide status bar"
description="Hide the status bar when status is unknown"
>
<input
type="checkbox"
checked={settings.autoHideStatusBar}
onChange={handleCheckbox("autoHideStatusBar")}
onChange={handleChange("autoHideStatusBar")}
/>
</SettingItem>
<SettingItem
name="Show status overview popup"
description="Show a popup listing statuses when hovering or interacting with the status icon."
name="Show status icons in file explorer"
description="Display status icons in the file explorer"
>
<input
type="checkbox"
checked={settings.enableStatusOverviewPopup ?? true}
onChange={handleCheckbox("enableStatusOverviewPopup")}
checked={settings.showStatusIconsInExplorer}
onChange={handleChange("showStatusIconsInExplorer")}
/>
</SettingItem>
<SettingItem
name="Show template names next to status"
description="Control how the template name is shown alongside the current status."
name="Status icon in file explorer position"
description="Choose icon position in file explorer"
>
<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"
>
<input
type="checkbox"
checked={settings.hideUnknownStatusInExplorer || false}
onChange={handleChange("hideUnknownStatusInExplorer")}
/>
</SettingItem>
<h4>Unknown Status Customization</h4>
<SettingItem
name="Unknown status icon"
description="Icon displayed for files with unknown status"
>
<Input
variant="text"
value={settings.unknownStatusIcon}
onChange={(value) => onChange("unknownStatusIcon", value)}
placeholder="❓"
style={{ maxWidth: "150px" }}
/>
</SettingItem>
<SettingItem
name="Unknown status color"
description="Hex color for unknown status (e.g., #8b949e)"
>
<Input
variant="color"
value={settings.unknownStatusColor || "#8b949e"}
onChange={(value) => onChange("unknownStatusColor", value)}
placeholder="No status"
/>
</SettingItem>
<SettingItem
name="Status bar 'no status' text"
description="Text displayed in status bar when there is no status"
>
<Input
variant="text"
value={settings.statusBarNoStatusText}
onChange={(value) =>
onChange("statusBarNoStatusText", value)
}
style={{ maxWidth: "150px" }}
placeholder="No status"
/>
</SettingItem>
<SettingItem
name="Show icon in status bar for 'no status'"
description="Show unknown status icon in status bar alongside text"
>
<input
type="checkbox"
checked={settings.statusBarShowNoStatusIcon || false}
onChange={handleChange("statusBarShowNoStatusIcon")}
/>
</SettingItem>
<SettingItem
name="Show text in status bar for 'no status'"
description="Display the custom text in the status bar when there is no status"
>
<input
type="checkbox"
checked={settings.statusBarShowNoStatusText ?? true}
onChange={handleChange("statusBarShowNoStatusText")}
/>
</SettingItem>
<SettingItem
name="Show template names in status bar"
description="Control how template names are displayed alongside status names"
>
<Select
options={[
@ -79,117 +170,29 @@ export const StatusBarSettings: React.FC<Props> = ({ settings, onChange }) => {
/>
</SettingItem>
<SettingItem
name="Status bar badge style"
description="Match the file explorer visuals: accent stripe, solid fill, or dot marker."
>
<Select
options={[
{ value: "accent", display: "Accent stripe (default)" },
{ value: "filled", display: "Solid background" },
{ value: "dot", display: "Dot marker" },
]}
defaultValue={settings.statusBarBadgeStyle || "accent"}
onChange={(value) => onChange("statusBarBadgeStyle", value)}
/>
</SettingItem>
<SettingItem
name="Status bar badge content"
description="Choose whether the badge shows icon, text, both, or an empty accent."
>
<Select
options={[
{
value: "icon-text",
display: "Icon + text (default)",
},
{ value: "icon", display: "Icon only" },
{ value: "text", display: "Text only" },
{ value: "none", display: "Empty badge (accent only)" },
]}
defaultValue={
settings.statusBarBadgeContentMode || "icon-text"
}
onChange={(value) =>
onChange("statusBarBadgeContentMode", value)
}
/>
</SettingItem>
<h4>No Status Display</h4>
<SettingItem
name="Custom text when note has no status"
description="Pick what text should be shown in the status bar when a note does not have a status."
>
<Input
variant="text"
value={settings.statusBarNoStatusText}
onChange={(value) =>
onChange("statusBarNoStatusText", value)
}
className="ui-settings__no-status-input"
placeholder="No status"
/>
</SettingItem>
<SettingItem
name="Show icon when note has no status"
description="Display the unknown status icon in the status bar next to the custom text."
>
<input
type="checkbox"
checked={settings.statusBarShowNoStatusIcon || false}
onChange={handleCheckbox("statusBarShowNoStatusIcon")}
/>
</SettingItem>
<SettingItem
name="Show text when note has no status"
description="Display the custom text in the status bar whenever no status is set."
>
<input
type="checkbox"
checked={settings.statusBarShowNoStatusText ?? true}
onChange={handleCheckbox("statusBarShowNoStatusText")}
/>
</SettingItem>
</div>
);
};
export const EditorToolbarSettings: React.FC<Props> = ({
settings,
onChange,
}) => {
const handleCheckbox =
(key: keyof PluginSettings) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(key, e.target.checked);
return (
<div className="ui-settings__group">
<h3>Editor Toolbar</h3>
<h4>Editor Toolbar Button</h4>
<SettingItem
name="Show editor toolbar button"
description="Add a button to the editor toolbar to change the note status."
description="Display status button in the editor toolbar"
>
<input
type="checkbox"
checked={settings.showEditorToolbarButton ?? true}
onChange={handleCheckbox("showEditorToolbarButton")}
onChange={handleChange("showEditorToolbarButton")}
/>
</SettingItem>
<SettingItem
name="Editor toolbar button position"
description="Choose where the status button should appear in the editor toolbar."
description="Choose where to position the status button in the editor toolbar"
>
<Select
options={[
{ value: "left", display: "Left side of toolbar" },
{
value: "left",
display: "Left side of toolbar",
},
{
value: "right",
display: "Right side (after all buttons)",
@ -209,13 +212,19 @@ export const EditorToolbarSettings: React.FC<Props> = ({
</SettingItem>
<SettingItem
name="Show toolbar button for"
description="Control which editors should display the toolbar button."
name="Editor toolbar button display"
description="Control which notes show the status button"
>
<Select
options={[
{ value: "all-notes", display: "All open notes" },
{ value: "active-only", display: "Active note only" },
{
value: "all-notes",
display: "All open notes",
},
{
value: "active-only",
display: "Active note only",
},
]}
defaultValue={
settings.editorToolbarButtonDisplay || "all-notes"
@ -228,243 +237,3 @@ export const EditorToolbarSettings: React.FC<Props> = ({
</div>
);
};
export const FileExplorerSettings: React.FC<Props> = ({
settings,
onChange,
}) => {
const handleCheckbox =
(key: keyof PluginSettings) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(key, e.target.checked);
return (
<div className="ui-settings__group">
<h3>File Explorer</h3>
<SettingItem
name="Show status icons in file explorer"
description="Display a status icon for each file inside the file explorer."
>
<input
type="checkbox"
checked={settings.showStatusIconsInExplorer}
onChange={handleCheckbox("showStatusIconsInExplorer")}
/>
</SettingItem>
<SettingItem
name="Status icon position in file explorer"
description="Choose where the icon should be rendered next to the file name."
>
<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 icon when file status is unknown"
description="Do not render a status icon in the explorer when a note status cannot be determined."
>
<input
type="checkbox"
checked={settings.hideUnknownStatusInExplorer || false}
onChange={handleCheckbox("hideUnknownStatusInExplorer")}
/>
</SettingItem>
<SettingItem
name="Color filename text using status color"
description="Apply the active status color to the filename text inside the file explorer."
>
<input
type="checkbox"
checked={settings.fileExplorerColorFileName || false}
onChange={handleCheckbox("fileExplorerColorFileName")}
/>
</SettingItem>
<SettingItem
name="Color the file explorer block based on status"
description="Tint the entire file explorer list item with the active status color."
>
<input
type="checkbox"
checked={settings.fileExplorerColorBlock || false}
onChange={handleCheckbox("fileExplorerColorBlock")}
/>
</SettingItem>
<h4>Additional status visualization in file explorer</h4>
<SettingItem
name="Show colored left border"
description="Render a subtle colored strip on the left edge of the file explorer item."
>
<input
type="checkbox"
checked={settings.fileExplorerLeftBorder || false}
onChange={handleCheckbox("fileExplorerLeftBorder")}
/>
</SettingItem>
<SettingItem
name="Show colored status dot"
description="Append a compact colored dot next to the filename as an extra status indicator."
>
<input
type="checkbox"
checked={settings.fileExplorerStatusDot || false}
onChange={handleCheckbox("fileExplorerStatusDot")}
/>
</SettingItem>
<SettingItem
name="Underline filename with status color"
description="Adds a colored underline below the filename without changing the text color."
>
<input
type="checkbox"
checked={settings.fileExplorerUnderlineFileName || false}
onChange={handleCheckbox("fileExplorerUnderlineFileName")}
/>
</SettingItem>
<SettingItem
name="Status icon frame"
description="Choose whether to display a frame around the status icon inside the file explorer."
>
<Select
options={[
{ value: "never", display: "Never show a frame" },
{ value: "always", display: "Always show a frame" },
]}
defaultValue={settings.fileExplorerIconFrame || "never"}
onChange={(value) =>
onChange("fileExplorerIconFrame", value)
}
/>
</SettingItem>
<SettingItem
name="Status icon color"
description="Decide whether icons use their configured status colors or inherit the theme default."
>
<Select
options={[
{
value: "status",
display: "Use custom status colors",
},
{ value: "theme", display: "Use theme default colors" },
]}
defaultValue={
settings.fileExplorerIconColorMode || "status"
}
onChange={(value) =>
onChange("fileExplorerIconColorMode", value)
}
/>
</SettingItem>
</div>
);
};
export const UnknownStatusAppearanceSettings: React.FC<Props> = ({
settings,
onChange,
}) => (
<div className="ui-settings__group">
<h3>Unknown / No-Status Appearance</h3>
<SettingItem
name="Icon for unknown status"
description="Emoji or Lucide icon name displayed whenever a note does not have a status."
vertical
>
<IconSelectionField
emojiValue={settings.unknownStatusIcon}
onEmojiChange={(value) => onChange("unknownStatusIcon", value)}
emojiPlaceholder="❓"
emojiHint="Type any emoji or text fallback."
lucideValue={settings.unknownStatusLucideIcon || ""}
onLucideChange={(value) =>
onChange("unknownStatusLucideIcon", value)
}
lucidePlaceholder="Choose Lucide icon"
lucideHint="Matches the Obsidian toolbar style."
/>
</SettingItem>
<SettingItem
name="Color for unknown status"
description="Hex color used for unknown statuses (e.g., #8b949e)."
>
<Input
variant="color"
value={settings.unknownStatusColor || "#8b949e"}
onChange={(value) => onChange("unknownStatusColor", value)}
placeholder="No status"
/>
</SettingItem>
</div>
);
export const ExperimentalSettings: React.FC<Props> = ({
settings,
onChange,
}) => {
const handleCheckbox =
(key: keyof PluginSettings) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(key, e.target.checked);
return (
<div className="ui-settings__group">
<h3>Experimental features</h3>
<SettingItem
name="Enable experimental features"
description="Unlock beta views that are still under development."
>
<input
type="checkbox"
checked={settings.enableExperimentalFeatures || false}
onChange={handleCheckbox("enableExperimentalFeatures")}
/>
</SettingItem>
<SettingItem
name="Enable status dashboard"
description="Show the ribbon shortcut to open the status dashboard view."
>
<input
type="checkbox"
disabled={!settings.enableExperimentalFeatures}
checked={settings.enableStatusDashboard || false}
onChange={handleCheckbox("enableStatusDashboard")}
/>
</SettingItem>
<SettingItem
name="Enable grouped status view"
description="Show the ribbon shortcut to open the grouped status view."
>
<input
type="checkbox"
disabled={!settings.enableExperimentalFeatures}
checked={settings.enableGroupedStatusView || false}
onChange={handleCheckbox("enableGroupedStatusView")}
/>
</SettingItem>
</div>
);
};

View file

@ -1,47 +1,18 @@
import React, { useEffect, useState } from "react";
import { Notice } from "obsidian";
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 { SynchronizationSettings } from "./SynchronizationSettings";
import {
EditorToolbarSettings,
ExperimentalSettings,
FileExplorerSettings,
StatusBarSettings,
UnknownStatusAppearanceSettings,
} from "./UISettings";
export type Props = {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
};
type SectionId =
| "templates-statuses"
| "quick-actions"
| "status-bar"
| "editor-toolbar"
| "file-explorer"
| "unknown-appearance"
| "behavior-storage"
| "synchronization"
| "experimental-features";
type SectionDefinition = {
id: SectionId;
label: string;
description: string;
content: React.ReactNode;
};
const SettingsUI: React.FC<Props> = ({ settings, onChange }) => {
const [localSettings, setLocalSettings] = useState(settings);
const [expandedSections, setExpandedSections] = useState<
Partial<Record<SectionId, boolean>>
>({});
useEffect(() => {
setLocalSettings(settings);
@ -52,239 +23,16 @@ const SettingsUI: React.FC<Props> = ({ settings, onChange }) => {
onChange(key, value);
};
const handleSupportClick = (url: string, label: string) => {
window.open(url);
navigator.clipboard.writeText(url).then(() => {
new Notice(`Copied ${label} URL to clipboard!`);
});
};
const sections: SectionDefinition[] = [
{
id: "templates-statuses",
label: "Templates & Statuses",
description:
"Enable predefined workflows or define your own statuses.",
content: (
<div className="note-status-settings__stack">
<TemplateSettings
settings={localSettings}
onChange={handleChange}
/>
<CustomStatusSettings
settings={localSettings}
onChange={handleChange}
/>
</div>
),
},
{
id: "quick-actions",
label: "Quick Actions",
description: "Choose statuses with command palette shortcuts.",
content: (
<QuickCommandsSettings
settings={localSettings}
onChange={handleChange}
/>
),
},
{
id: "status-bar",
label: "Status Bar",
description:
"Icon, popup, badge style/content, and no-status behavior.",
content: (
<StatusBarSettings
settings={localSettings}
onChange={handleChange}
/>
),
},
{
id: "editor-toolbar",
label: "Editor Toolbar",
description: "Button placement and visibility across panes.",
content: (
<EditorToolbarSettings
settings={localSettings}
onChange={handleChange}
/>
),
},
{
id: "file-explorer",
label: "File Explorer",
description:
"Icon placement, hiding unknown, and visual styling options.",
content: (
<FileExplorerSettings
settings={localSettings}
onChange={handleChange}
/>
),
},
{
id: "unknown-appearance",
label: "Unknown / No-Status Appearance",
description: "Fallback icon and color when a note lacks status.",
content: (
<UnknownStatusAppearanceSettings
settings={localSettings}
onChange={handleChange}
/>
),
},
{
id: "behavior-storage",
label: "Behavior & Storage",
description:
"Multi-status mode, tag key, mappings, and vault safeguards.",
content: (
<BehaviourSettings
settings={localSettings}
onChange={handleChange}
/>
),
},
{
id: "synchronization",
label: "Synchronization",
description:
"Keep custom statuses and templates in sync across multiple devices.",
content: (
<SynchronizationSettings
settings={localSettings}
onChange={handleChange}
/>
),
},
{
id: "experimental-features",
label: "Experimental Features",
description: "Opt into dashboard and grouped view previews.",
content: (
<ExperimentalSettings
settings={localSettings}
onChange={handleChange}
/>
),
},
];
return (
<div className="note-status-settings">
{sections.map((section) => {
const isOpen = expandedSections[section.id] ?? false;
return (
<div
key={section.id}
className={`note-status-section ${
isOpen ? "is-open" : ""
}`}
>
<button
type="button"
className="note-status-section__header"
aria-expanded={isOpen}
onClick={() =>
setExpandedSections((prev) => ({
...prev,
[section.id]: !prev[section.id],
}))
}
>
<div className="note-status-section__title-block">
<div className="setting-item-name">
{section.label}
</div>
<div className="setting-item-description">
{section.description}
</div>
</div>
<span
className="note-status-section__chevron"
aria-hidden="true"
>
{isOpen ? "v" : ">"}
</span>
</button>
{isOpen ? (
<div className="note-status-section__content">
{section.content}
</div>
) : null}
</div>
);
})}
<div className="note-status-settings__intro">
<p className="note-status-settings__description">
Note Status keeps your vault aligned with clear,
configurable workflows. Tune the sections below to match how
you review notes, surface statuses, and keep storage tidy.
</p>
<div className="note-status-settings__support">
<button
className="note-status-settings__support-link"
onClick={() =>
handleSupportClick(
"https://paypal.me/aleixsoler",
"PayPal",
)
}
>
<img
src="https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white"
alt="PayPal"
style={{ height: "20px" }}
/>
<span className="note-status-settings__support-link-label">
Support via PayPal
</span>
<span className="note-status-settings__support-link-url">
paypal.me/aleixsoler
</span>
</button>
<button
className="note-status-settings__support-link"
onClick={() =>
handleSupportClick(
"https://buymeacoffee.com/aleixsoler",
"Buy Me a Coffee",
)
}
>
<img
src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black"
alt="Buy me a coffee"
style={{ height: "20px" }}
/>
<span className="note-status-settings__support-link-label">
Buy me a coffee
</span>
<span className="note-status-settings__support-link-url">
buymeacoffee.com/aleixsoler
</span>
</button>
<button
className="note-status-settings__support-link"
onClick={() =>
handleSupportClick(
"https://github.com/devonthesofa/obsidian-note-status",
"GitHub",
)
}
>
<span className="note-status-settings__support-link-label">
Star on GitHub
</span>
<span className="note-status-settings__support-link-url">
github.com/devonthesofa/obsidian-note-status
</span>
</button>
</div>
</div>
<TemplateSettings settings={settings} onChange={handleChange} />
<UISettings settings={localSettings} onChange={handleChange} />
<BehaviourSettings settings={settings} onChange={handleChange} />
<CustomStatusSettings settings={settings} onChange={handleChange} />
<QuickCommandsSettings
settings={settings}
onChange={handleChange}
/>
</div>
);
};

View file

@ -1,11 +1,10 @@
import { GroupedStatuses } from "@/types/noteStatus";
import { CSSProperties, FC } from "react";
import { FC } from "react";
import {
StatusBarProvider,
Props as StatusBarContextProps,
} from "./StatusBarContext";
import { StatusBarGroup } from "./StatusBarGroup";
import { StatusIcon } from "@/components/atoms/StatusIcon";
export type Props = {
statuses: GroupedStatuses;
@ -13,14 +12,11 @@ export type Props = {
hideIfNotStatuses?: boolean;
onStatusClick: StatusBarContextProps["onStatusClick"];
templateNameMode?: "always" | "never" | "auto";
badgeStyle?: "accent" | "filled" | "dot";
badgeContentMode?: "icon-text" | "icon" | "text" | "none";
noStatusConfig?: {
text: string;
showIcon: boolean;
showText: boolean;
icon: string;
lucideIcon?: string;
color: string;
};
};
@ -30,8 +26,6 @@ export const StatusBar: FC<Props> = ({
hideIfNotStatuses,
onStatusClick,
templateNameMode = "auto",
badgeStyle = "accent",
badgeContentMode = "icon-text",
noStatusConfig,
}) => {
const statusEntries = Object.entries(statuses);
@ -49,29 +43,23 @@ export const StatusBar: FC<Props> = ({
return (
<span
className="status-bar-item status-bar-no-status mod-clickable"
className="status-bar-item mod-clickable"
onClick={() => onStatusClick({ name: "", icon: "" })}
style={
noStatusConfig.color
? ({
"--status-empty-color": noStatusConfig.color,
} as CSSProperties)
: undefined
}
style={{
cursor: "pointer",
color: noStatusConfig.color,
}}
>
{noStatusConfig.showIcon && (
<StatusIcon
icon={noStatusConfig.icon}
lucideIcon={noStatusConfig.lucideIcon}
size={14}
className="status-bar-no-status__icon"
/>
)}
{noStatusConfig.showText && (
<span className="status-bar-no-status__label">
{noStatusConfig.text}
<span
style={{
marginRight: noStatusConfig.showText ? "4px" : "0",
}}
>
{noStatusConfig.icon}
</span>
)}
{noStatusConfig.showText && noStatusConfig.text}
</span>
);
}
@ -84,8 +72,6 @@ export const StatusBar: FC<Props> = ({
key={frontmatterTagName}
statuses={statusList}
templateNameMode={templateNameMode}
badgeStyle={badgeStyle}
badgeContentMode={badgeContentMode}
template={{
description: "Note status",
name: "",

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

@ -1,4 +1,4 @@
import { CSSProperties, FC, useState } from "react";
import { FC, useState } from "react";
import { NoteStatus } from "@/types/noteStatus";
import { StatusTemplate } from "@/types/pluginSettings";
import { GroupLabel } from "../atoms/GroupLabel";
@ -11,8 +11,6 @@ export interface StatusBarGroupProps {
template: StatusTemplate;
maxVisible?: number;
templateNameMode?: "always" | "never" | "auto";
badgeStyle?: "accent" | "filled" | "dot";
badgeContentMode?: "icon-text" | "icon" | "text" | "none";
}
export const StatusBarGroup: FC<StatusBarGroupProps> = ({
@ -20,8 +18,6 @@ export const StatusBarGroup: FC<StatusBarGroupProps> = ({
template,
maxVisible = 3,
templateNameMode = "auto",
badgeStyle = "accent",
badgeContentMode = "icon-text",
}) => {
const [isUncollapsed, setIsUncollapsed] = useState(false);
const visibleStatuses = isUncollapsed
@ -40,24 +36,19 @@ export const StatusBarGroup: FC<StatusBarGroupProps> = ({
<GroupLabel name={template.name} isHighlighted={isUncollapsed} />
<div className="status-bar-group-container">
{visibleStatuses.map((status, i) => (
// Use a wrapper so we can animate statuses when expanding
<div
key={i}
className={`status-bar-status-item${isUncollapsed && i >= maxVisible ? " status-bar-status-item--animated" : ""}`}
style={
isUncollapsed && i >= maxVisible
? ({
"--status-anim-delay": `${i * 0.05}s`,
} as CSSProperties)
: undefined
}
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"
badgeStyle={badgeStyle}
badgeContentMode={badgeContentMode}
templateNameMode={templateNameMode}
hasNameConflicts={getHasConflicts(status)}
onClick={() => {

View file

@ -5,7 +5,6 @@ import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
interface CurrentNoteInfo {
file: TFile | null;
@ -31,18 +30,9 @@ export const useCurrentNote = () => {
const noteStatusService = new NoteStatusService(activeFile);
noteStatusService.populateStatuses();
const statusMetadataKeys = getAllFrontmatterKeys();
const statusesByKey: Record<string, NoteStatus[]> = {};
statusMetadataKeys.forEach((key) => {
const statuses = noteStatusService.getStatusesForKey(key);
if (statuses.length) {
statusesByKey[key] = statuses;
}
});
setCurrentNote({
file: activeFile,
statuses: statusesByKey,
statuses: noteStatusService.statuses,
lastModified: activeFile.stat.mtime,
});
}, []);

View file

@ -1,11 +1,8 @@
import { useState, useCallback } from "react";
import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus";
import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
import { BaseNoteStatusService } from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
export interface VaultStats {
totalNotes: number;
@ -30,10 +27,10 @@ export const useVaultStats = () => {
});
const calculateVaultStats = useCallback((): VaultStats => {
const files = BaseNoteStatusService.app.vault.getFiles();
const files = BaseNoteStatusService.app.vault.getMarkdownFiles();
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const statusMetadataKeys = getAllFrontmatterKeys();
const statusMetadataKeys = [settingsService.settings.tagPrefix];
let notesWithStatus = 0;
const statusDistribution: Record<string, number> = {};
@ -52,26 +49,50 @@ export const useVaultStats = () => {
});
files.forEach((file) => {
const noteStatusService = new NoteStatusService(file);
noteStatusService.populateStatuses();
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
let hasAnyStatus = false;
statusMetadataKeys.forEach((key) => {
const statuses = noteStatusService.getStatusesForKey(key);
if (!statuses.length) return;
const value = frontmatter[key];
if (value) {
hasAnyStatus = true;
tagDistribution[key]++;
hasAnyStatus = true;
tagDistribution[key]++;
const statusNames = Array.isArray(value) ? value : [value];
statusNames.forEach((statusName) => {
const statusStr = statusName.toString();
statuses.forEach((status) => {
const scopedIdentifier = status.templateId
? `${status.templateId}:${status.name}`
: status.name;
if (!statusDistribution.hasOwnProperty(scopedIdentifier)) {
statusDistribution[scopedIdentifier] = 0;
}
statusDistribution[scopedIdentifier]++;
});
// 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) {

View file

@ -4,17 +4,11 @@ import { StatusDisplay } from "../atoms/StatusDisplay";
export interface Props {
statuses: GroupedStatuses;
defaultTagName: string;
onClose?: () => void;
}
export const StatusFileInfoPopup: React.FC<Props> = ({
statuses,
defaultTagName,
}) => {
const statusEntries = Object.entries(statuses).filter(
([, statusList]) => statusList.length > 0,
);
export const StatusFileInfoPopup: React.FC<Props> = ({ statuses }) => {
const statusEntries = Object.entries(statuses);
if (statusEntries.length === 0) {
return (
@ -33,39 +27,40 @@ export const StatusFileInfoPopup: React.FC<Props> = ({
</div>
<div className="status-popup-content">
<div className="status-tag-grid">
{statusEntries.map(([groupName, statusList]) => {
const isDefault = groupName === defaultTagName;
return (
<div key={groupName} className="status-tag-card">
<div className="status-tag-card__header">
<div className="status-tag-card__title">
<span className="status-tag-card__label">
{groupName}
</span>
{isDefault && (
<span className="status-tag-card__badge">
Default tag
</span>
)}
</div>
<span className="status-tag-card__count">
{statusList.length}
</span>
{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 className="status-tag-card__statuses">
{statusList.map((status, index) => (
<StatusDisplay
key={`${groupName}-${index}`}
status={status}
variant="badge"
/>
))}
</div>
</div>
);
})}
</div>
))}
</div>
</div>
))}
</div>
</div>
);

View file

@ -1,21 +1,18 @@
import React, { FC, memo } from "react";
import { GroupedStatuses } from "@/types/noteStatus";
import { StatusesInfoPopup } from "@/integrations/popups/statusesInfoPopupIntegration";
import { StatusIcon } from "@/components/atoms/StatusIcon";
interface EditorToolbarButtonProps {
statuses: GroupedStatuses;
defaultTagName: string;
onClick: () => void;
unknownStatusConfig: {
icon: string;
lucideIcon?: string;
color: string;
};
}
export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
({ statuses, defaultTagName, onClick, unknownStatusConfig }) => {
({ statuses, onClick, unknownStatusConfig }) => {
const statusEntries = Object.entries(statuses);
const allStatuses = statusEntries.flatMap(
([_, statusList]) => statusList,
@ -23,7 +20,7 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
const totalStatuses = allStatuses.length;
const handleMouseEnter = () => {
StatusesInfoPopup.open({ statuses, defaultTagName });
StatusesInfoPopup.open(statuses);
};
const handleMouseLeave = () => {
@ -43,13 +40,12 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
onMouseLeave={handleMouseLeave}
aria-label="Add status to note"
>
<StatusIcon
icon={unknownStatusConfig.icon}
lucideIcon={unknownStatusConfig.lucideIcon}
size={16}
<span
className="editor-toolbar-button__icon"
style={{ color: unknownStatusConfig.color }}
/>
>
{unknownStatusConfig.icon}
</span>
</button>
);
}
@ -67,17 +63,16 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
onMouseLeave={handleMouseLeave}
aria-label={`Current status: ${primaryStatus.name}. Click to change.`}
>
<StatusIcon
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
size={16}
<span
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
style={{
color:
primaryStatus.color ||
"var(--interactive-accent)",
}}
/>
>
{primaryStatus.icon || "📝"}
</span>
</button>
);
}
@ -93,17 +88,16 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
aria-label={`${totalStatuses} statuses assigned. Click to change.`}
>
<div className="editor-toolbar-button__icon-container">
<StatusIcon
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
size={16}
<span
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
style={{
color:
primaryStatus.color ||
"var(--interactive-accent)",
}}
/>
>
{primaryStatus.icon || "📝"}
</span>
<span className="editor-toolbar-button__counter">
{totalStatuses}
</span>

View file

@ -12,16 +12,18 @@ export const GroupLabel: FC<GroupLabelProps> = ({
}) => {
if (!name) return null;
const className = [
"setting-item-name",
"group-label-container",
isHighlighted ? "group-label-container--highlighted" : "",
]
.filter(Boolean)
.join(" ");
return (
<span className={className}>
<span
className="setting-item-name group-label-container"
style={{
color: isHighlighted
? "var(--text-normal)"
: "var(--text-muted)",
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<ObsidianIcon name="folder" size={16} />
{name}
</span>

View file

@ -11,7 +11,6 @@ interface BaseInputProps {
style?: React.CSSProperties;
onFocus?: () => void;
onBlur?: () => void;
disabled?: boolean;
}
interface TextInputProps extends BaseInputProps {
@ -28,6 +27,37 @@ interface SearchInputProps extends BaseInputProps {
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>(
(
{
@ -39,37 +69,23 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
style,
onFocus,
onBlur,
disabled,
},
ref,
) => {
const inputType =
variant === "color"
? "color"
: variant === "search"
? "search"
: "text";
const classes = [
"note-status-input",
`note-status-input--${variant}`,
variant === "search" ? "search-input" : "",
className,
]
.filter(Boolean)
.join(" ");
const inputStyles = getInputStyles(variant);
const combinedStyles = { ...inputStyles, ...style };
return (
<input
ref={ref}
type={inputType}
type={variant === "color" ? "color" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={classes}
style={style}
className={className}
style={combinedStyles}
onFocus={onFocus}
onBlur={onBlur}
disabled={disabled}
/>
);
},

View file

@ -1,4 +1,4 @@
import React, { ReactNode } from "react";
import React, { ReactNode, useState } from "react";
import { ObsidianIcon } from "./ObsidianIcon";
interface SelectableListItemProps {
@ -20,6 +20,8 @@ export const SelectableListItem: React.FC<SelectableListItemProps> = ({
className = "",
title,
}) => {
const [isHovered, setIsHovered] = useState(false);
const handleClick = (e: React.MouseEvent) => {
if (onClick) {
setTimeout(() => {
@ -28,25 +30,60 @@ export const SelectableListItem: React.FC<SelectableListItemProps> = ({
}
};
const composedClassName = ["selectable-list-item", className]
.filter(Boolean)
.join(" ");
return (
<div
className={composedClassName}
data-selected={selected}
data-focused={focused}
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">{icon}</span>}
<span className="selectable-list-item-content">{children}</span>
{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 && (
<ObsidianIcon
name="check"
<div
className="selectable-list-item-check"
/>
style={{
color: "var(--interactive-accent)",
}}
>
<ObsidianIcon name="check" />
</div>
)}
</div>
);

View file

@ -1,28 +1,16 @@
import { NoteStatus } from "@/types/noteStatus";
import {
CSSProperties,
FC,
MouseEvent,
ReactNode,
memo,
useMemo,
useState,
} from "react";
import { FC, memo, useState } from "react";
import { getStatusTooltip } from "@/utils/statusUtils";
import { ObsidianIcon } from "./ObsidianIcon";
import { StatusIcon } from "./StatusIcon";
export type StatusDisplayVariant = "chip" | "badge" | "template";
interface StatusDisplayProps {
status: NoteStatus;
variant: StatusDisplayVariant;
badgeStyle?: "accent" | "filled" | "dot";
badgeContentMode?: "icon-text" | "icon" | "text" | "none";
removable?: boolean;
hasNameConflicts?: boolean;
templateNameMode?: "always" | "never" | "auto";
icon?: ReactNode;
onRemove?: () => void;
onClick?: () => void;
}
@ -32,11 +20,8 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
status,
variant,
removable = false,
badgeStyle = "accent",
badgeContentMode = "icon-text",
hasNameConflicts = false,
templateNameMode = "auto",
icon,
onRemove,
onClick,
}) => {
@ -61,7 +46,7 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
: status.name;
};
const handleRemove = (e: MouseEvent) => {
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
if (onRemove) {
setIsRemoving(true);
@ -77,102 +62,71 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
}
};
const defaultIcon = useMemo(() => {
const baseSize = variant === "chip" ? 16 : 14;
return (
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
size={baseSize}
/>
);
}, [status.icon, status.lucideIcon, variant]);
const iconNode = icon ?? defaultIcon;
if (variant === "chip") {
const chipClassName = [
"note-status-chip",
isRemoving ? "note-status-chip--removing" : "",
onClick ? "note-status-chip--clickable" : "",
]
.filter(Boolean)
.join(" ");
return (
<div
className={chipClassName}
className="note-status-chip"
title={getStatusTooltip(status)}
data-clickable={!!onClick}
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">{iconNode}</span>
<span className="note-status-chip__text">
<span className="note-status-chip-icon">
{status.icon ? status.icon : "📝"}
</span>
<span className="note-status-chip-text">
{getDisplayName()}
</span>
{removable && (
<button
type="button"
className="note-status-chip__remove"
<div
className="note-status-chip-remove"
onClick={handleRemove}
aria-label={`Remove status ${status.name}`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "16px",
height: "16px",
borderRadius: "50%",
background: "rgba(255, 255, 255, 0.2)",
cursor: "pointer",
}}
>
<ObsidianIcon name="trash" size={12} />
</button>
</div>
)}
</div>
);
}
if (variant === "badge") {
const badgeStyleVars = status.color
? ({
"--status-accent": status.color,
} as CSSProperties)
: undefined;
const badgeClassName = [
"status-badge-container",
`status-badge--${badgeStyle}`,
onClick ? "status-badge-container--clickable" : "",
badgeContentMode === "none" ? "status-badge--empty" : "",
]
.filter(Boolean)
.join(" ");
const showDot = badgeStyle === "dot";
const showIcon =
badgeContentMode === "icon-text" || badgeContentMode === "icon";
const showText =
badgeContentMode === "icon-text" || badgeContentMode === "text";
const isEmptyBadge = badgeContentMode === "none";
return (
<div
className={badgeClassName}
style={badgeStyleVars}
className="status-badge-container"
style={{
backgroundColor: `${status.color}15`,
border: `1px solid ${status.color}30`,
}}
onClick={handleClick}
data-clickable={!!onClick}
title={getStatusTooltip(status)}
>
{showDot && (
<span className="status-badge-dot" aria-hidden="true" />
)}
<div className="status-badge-item">
{showIcon && (
<span className="status-badge-icon">
{iconNode}
</span>
)}
{showText && (
<span className="status-badge-text">
{getDisplayName()}
</span>
)}
{isEmptyBadge && (
<span
className="status-badge-empty"
aria-hidden="true"
/>
)}
<span className="status-badge-icon">
{status.icon ? status.icon : "📝"}
</span>
<span className="status-badge-text">
{getDisplayName()}
</span>
</div>
</div>
);
@ -180,24 +134,17 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
if (variant === "template") {
return (
<div
className="template-status-chip"
onClick={handleClick}
data-clickable={!!onClick}
>
<div className="template-status-chip" onClick={handleClick}>
<span
className="template-status-color-dot"
style={
{
"--dot-color": status.color,
} as CSSProperties
} as React.CSSProperties
}
/>
<span className="template-status-chip__label">
<span className="template-status-chip__icon">
{iconNode}
</span>
{getDisplayName()}
<span>
{status.icon ? status.icon : "📝"} {getDisplayName()}
</span>
</div>
);

View file

@ -1,52 +0,0 @@
import React, { CSSProperties, FC } from "react";
import { ObsidianIcon } from "./ObsidianIcon";
import { resolveLucideIconName } from "@/utils/iconUtils";
interface StatusIconProps {
icon?: string;
lucideIcon?: string;
fallbackIcon?: string;
className?: string;
size?: number;
style?: CSSProperties;
}
export const StatusIcon: FC<StatusIconProps> = ({
icon,
lucideIcon,
fallbackIcon = "📝",
className = "",
size,
style = {},
}) => {
const lucideName = resolveLucideIconName(lucideIcon, icon);
const resolvedSize = size ?? 16;
const combinedClassName = ["status-icon", className]
.filter(Boolean)
.join(" ");
const wrapperStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 1,
...style,
};
if (size) {
wrapperStyle.fontSize = `${size}px`;
}
const fallbackValue =
icon && icon.trim().length ? icon : (fallbackIcon ?? "");
return (
<span className={combinedClassName} style={wrapperStyle}>
{lucideName ? (
<ObsidianIcon name={lucideName} size={resolvedSize} />
) : (
fallbackValue
)}
</span>
);
};

View file

@ -1,90 +0,0 @@
import React, { CSSProperties, FC, ReactNode, memo } from "react";
type IconFrameMode = "always" | "never";
type IconColorMode = "status" | "theme";
export interface StatusIconPreviewProps {
icon?: ReactNode;
color?: string;
count?: number;
iconFrameMode?: IconFrameMode;
iconColorMode?: IconColorMode;
withWrapper?: boolean;
compact?: boolean;
iconClassName?: string;
wrapperClassName?: string;
style?: CSSProperties;
onMouseEnter?: (event: React.MouseEvent<HTMLDivElement>) => void;
onMouseLeave?: (event: React.MouseEvent<HTMLDivElement>) => void;
}
export const StatusIconPreview: FC<StatusIconPreviewProps> = memo(
({
icon,
color,
count,
iconFrameMode = "never",
iconColorMode = "status",
withWrapper = true,
compact = false,
iconClassName = "",
wrapperClassName = "",
style,
onMouseEnter,
onMouseLeave,
}) => {
const useStatusColor = iconColorMode === "status";
const appliedColor = useStatusColor && color ? color.trim() : undefined;
const iconStyles: CSSProperties = {
...style,
};
if (appliedColor) {
iconStyles.color = appliedColor;
}
if (iconFrameMode === "always") {
const frameColor = appliedColor || "currentColor";
iconStyles.boxShadow = `0 0 0 1px ${frameColor}`;
iconStyles.borderRadius = "var(--radius-s)";
}
const iconContent = (
<div
className={`status-minimal${compact ? " status-minimal--compact" : ""}${iconClassName ? ` ${iconClassName}` : ""}`}
style={iconStyles}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<span className="status-minimal__icon">{icon ?? "📝"}</span>
{count && count > 1 && (
<span
className="status-minimal__count"
style={
appliedColor
? {
backgroundColor: appliedColor,
}
: undefined
}
>
{count}
</span>
)}
</div>
);
if (!withWrapper) {
return iconContent;
}
return (
<div
className={`status-wrapper${wrapperClassName ? ` ${wrapperClassName}` : ""}`}
>
{iconContent}
</div>
);
},
);

View file

@ -2,27 +2,16 @@ import React, { memo, useCallback } from "react";
import { NoteStatus } from "@/types/noteStatus";
import { SelectableListItem } from "./SelectableListItem";
import { getStatusTooltip, isStatusSelected } from "@/utils/statusUtils";
import { StatusIconPreview } from "./StatusIconPreview";
import { StatusIcon } from "./StatusIcon";
interface StatusOptionProps {
status: NoteStatus;
isSelected: boolean;
isFocused: boolean;
onSelect: () => void;
iconFrameMode: "always" | "never";
iconColorMode: "status" | "theme";
}
export const StatusModalOption: React.FC<StatusOptionProps> = memo(
({
status,
isSelected,
isFocused,
onSelect,
iconFrameMode,
iconColorMode,
}) => {
({ status, isSelected, isFocused, onSelect }) => {
const displayName = status.templateId
? `${status.name} (${status.templateId})`
: status.name;
@ -31,40 +20,14 @@ export const StatusModalOption: React.FC<StatusOptionProps> = memo(
<SelectableListItem
selected={isSelected}
focused={isFocused}
icon={
<div className="note-status-option__icon-wrapper">
<StatusIconPreview
icon={
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
size={13}
/>
}
color={status.color}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
withWrapper={false}
compact
/>
</div>
}
icon={status.icon}
onClick={onSelect}
className="note-status-option"
title={
status.description ? getStatusTooltip(status) : undefined
}
>
<div className="note-status-option__text">
<span className="note-status-option__name">
{displayName}
</span>
{status.description && (
<span className="note-status-option__description">
{status.description}
</span>
)}
</div>
{displayName}
</SelectableListItem>
);
},
@ -75,8 +38,6 @@ export interface Props {
availableStatuses: NoteStatus[];
focusedIndex?: number;
onToggleStatus: (status: NoteStatus, selected: boolean) => void;
iconFrameMode?: "always" | "never";
iconColorMode?: "status" | "theme";
}
export const StatusSelector: React.FC<Props> = ({
@ -84,8 +45,6 @@ export const StatusSelector: React.FC<Props> = ({
availableStatuses,
focusedIndex = -1,
onToggleStatus,
iconFrameMode = "never",
iconColorMode = "status",
}) => {
const handleSelectStatus = useCallback(
async (status: NoteStatus) => {
@ -96,16 +55,23 @@ export const StatusSelector: React.FC<Props> = ({
);
return (
<div className="note-status-options" role="listbox">
<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.lucideIcon ?? ""}`}
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}`}
status={status}
isSelected={isStatusSelected(status, currentStatuses)}
isFocused={index === focusedIndex}
onSelect={() => handleSelectStatus(status)}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
/>
))}
</div>

View file

@ -1,9 +1,11 @@
import { PluginSettings } from "types/pluginSettings";
import { PREDEFINED_TEMPLATES } from "./predefinedTemplates";
import {
DEFAULT_ENABLED_TEMPLATES,
PREDEFINED_TEMPLATES,
} from "./predefinedTemplates";
export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
fileExplorerIconPosition: "absolute-right",
fileExplorerIconFrame: "never",
fileExplorerIconColorMode: "status",
statusColors: {
active: "var(--text-success)",
onHold: "var(--text-warning)",
@ -13,56 +15,26 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
},
showStatusBar: true,
autoHideStatusBar: false,
enableStatusOverviewPopup: true,
templates: JSON.parse(JSON.stringify(PREDEFINED_TEMPLATES)),
templates: [...PREDEFINED_TEMPLATES],
customStatuses: [],
showStatusIconsInExplorer: true,
hideUnknownStatusInExplorer: true, // Default to hide unknown status
fileExplorerColorFileName: false,
fileExplorerColorBlock: false,
fileExplorerLeftBorder: false,
fileExplorerStatusDot: false,
fileExplorerUnderlineFileName: false,
enableExperimentalFeatures: true,
enableStatusDashboard: true,
enableGroupedStatusView: true,
enabledTemplates: PREDEFINED_TEMPLATES.map((t) => t.id),
enabledTemplates: DEFAULT_ENABLED_TEMPLATES,
useCustomStatusesOnly: false,
useMultipleStatuses: true,
singleStatusStorageMode: "list",
statusBarShowTemplateName: "auto", // Default to show template names only when needed
tagPrefix: "obsidian-note-status",
strictStatuses: false, // Default to show all statuses from frontmatter
quickStatusCommands: ["active", "completed"], // Add default quick commands
// Unknown status customization
unknownStatusIcon: "❓",
unknownStatusLucideIcon: "",
unknownStatusColor: "#8b949e",
statusBarNoStatusText: "No status",
statusBarShowNoStatusIcon: false,
statusBarShowNoStatusText: true,
statusBarBadgeStyle: "accent",
statusBarBadgeContentMode: "icon-text",
vaultSizeLimit: 15000, // Disable dashboard and grouped view for vaults with more notes than this limit
// Editor toolbar button settings
showEditorToolbarButton: true, // Default to show the toolbar button
editorToolbarButtonPosition: "right", // Default position on the right
editorToolbarButtonDisplay: "all-notes", // Default to show button in all notes
applyStatusRecursivelyToSubfolders: false,
statusFrontmatterMappings: [],
writeMappedTagsToDefault: false,
enableExternalStatusSync: false,
externalStatusSyncPath: "_note-status-sync.json",
syncGroups: [
"templates",
"customStatuses",
"statusColors",
"uiAppearance",
"workflow",
"storage",
"features",
],
enableNonMarkdownSync: false,
nonMarkdownSyncPath: "_note-status-data.json",
defaultStatusForNewNotes: null,
};

View file

@ -1,72 +1,14 @@
/**
* THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT MANUALLY.
* To add a new predefined template, create a JSON file in the /templates/predefined directory.
* To add a community template, create a JSON file in the /templates directory.
*/
import { StatusTemplate } from "types/pluginSettings";
/**
* Predefined status templates
*/
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
{
id: "academic",
name: "Academic research",
description: "Status workflow for academic research and writing",
language: "English",
type: "Academic",
authorGithub: "soler1212",
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",
},
],
isPredefined: true,
},
{
id: "colorful",
name: "Colorful workflow",
description:
"A colorful set of workflow statuses with descriptive icons",
language: "English",
type: "General",
authorGithub: "soler1212",
statuses: [
{
name: "idea",
@ -123,92 +65,11 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
templateId: "colorful",
},
],
isPredefined: true,
},
{
id: "creative-writing",
name: "Creative Writing",
description: "Workflow for novelists and creative writers",
language: "English",
type: "Writing",
authorGithub: "soler1212",
statuses: [
{
name: "idea",
icon: "🧠",
color: "#FFD700",
templateId: "creative-writing",
},
{
name: "outline",
icon: "🗺️",
color: "#87CEEB",
templateId: "creative-writing",
},
{
name: "first-draft",
icon: "✍️",
color: "#FFA07A",
templateId: "creative-writing",
},
{
name: "revision",
icon: "🔍",
color: "#DA70D6",
templateId: "creative-writing",
},
{
name: "final-polish",
icon: "✨",
color: "#32CD32",
templateId: "creative-writing",
},
],
isPredefined: true,
},
{
id: "default-starter",
name: "Starter Template",
description:
"A simplified set of essential workflow statuses to get you started.",
language: "English",
type: "General",
authorGithub: "soler1212",
statuses: [
{
name: "todo",
icon: "📌",
color: "#F44336",
templateId: "default-starter",
},
{
name: "inProgress",
icon: "⚙️",
color: "#2196F3",
templateId: "default-starter",
},
{
name: "review",
icon: "👀",
color: "#9C27B0",
templateId: "default-starter",
},
{
name: "done",
icon: "✓",
color: "#4CAF50",
templateId: "default-starter",
},
],
isPredefined: true,
},
{
id: "minimal",
name: "Minimal workflow",
description: "A simplified set of essential workflow statuses",
language: "English",
type: "General",
authorGithub: "soler1212",
statuses: [
{
name: "todo",
@ -235,15 +96,60 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
templateId: "minimal",
},
],
isPredefined: true,
},
{
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",
language: "English",
type: "Project Management",
authorGithub: "soler1212",
statuses: [
{
name: "planning",
@ -294,51 +200,10 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
templateId: "project",
},
],
isPredefined: true,
},
];
export const COMMUNITY_TEMPLATES: StatusTemplate[] = [
{
id: "research-note",
name: "Research-note",
description:
"Workflow for managing first iteration of note-taking, starting second iteration, reviewing, and completing",
authorGithub: "JuliaHusar",
statuses: [
{
name: "First Pass",
icon: "",
lucideIcon: "",
color: "#eb2424",
templateId: "research-note",
description: "Note creation and initial addition of content",
},
{
name: "Second Pass",
icon: "🕵️‍♀️",
lucideIcon: "",
color: "#f29718",
templateId: "research-note",
description: "Adding additional information to note",
},
{
name: "Review",
icon: "👩‍💻",
lucideIcon: "",
color: "#ede61d",
templateId: "research-note",
description:
"Reviewing note and adding anything that might be missed",
},
{
name: "Complete",
icon: "✅",
lucideIcon: "",
color: "#41c511",
templateId: "research-note",
description: "Note is complete",
},
],
isPredefined: false,
},
];
/**
* Default template IDs that should be enabled by default
*/
export const DEFAULT_ENABLED_TEMPLATES = ["colorful"];

View file

@ -3,9 +3,6 @@ import { NoteStatusService, BaseNoteStatusService } from "./noteStatusService";
import settingsService from "./settingsService";
import eventBus from "./eventBus";
import { VIEW_TYPE_EXAMPLE } from "../integrations/views/grouped-status-view";
import { isExperimentalFeatureEnabled } from "@/utils/experimentalFeatures";
import { GroupedStatuses, NoteStatus } from "@/types/noteStatus";
import { getAllFrontmatterKeys } from "./statusKeyHelpers";
export class CommandsService {
private plugin: Plugin;
@ -73,20 +70,23 @@ export class CommandsService {
// Get the current file data
const statusService = this.createStatusService(file);
statusService.populateStatuses();
const flattened = this.flattenStatuses(
statusService.statuses,
);
const currentStatus = flattened[0];
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.name &&
(s.templateId || null) ===
(currentStatus.templateId || null),
(s) => s.name === currentStatus,
);
if (currentIndex !== -1) {
nextIndex = (currentIndex + 1) % allStatuses.length;
nextIndex =
currentIndex === -1
? 0
: (currentIndex + 1) % allStatuses.length;
}
}
@ -213,15 +213,10 @@ export class CommandsService {
if (!statuses || statuses.length === 0) return false;
const keys = getAllFrontmatterKeys();
const queries = Array.from(
new Set(
statuses.flatMap((status) =>
keys.map((key) => `[${key}:"${status}"]`),
),
),
const tagPrefix = settingsService.settings.tagPrefix;
const queries = statuses.map(
(status) => `[${tagPrefix}:"${status}"]`,
);
if (!queries.length) return false;
const query = queries.join(" OR ");
// @ts-ignore
@ -234,17 +229,15 @@ export class CommandsService {
});
this.registeredCommands.add("search-by-status");
// Open Status Pane command (experimental)
if (this.shouldEnableGroupedView()) {
this.plugin.addCommand({
id: "open-status-pane",
name: "Open status pane",
callback: async () => {
await this.openStatusPane();
},
});
this.registeredCommands.add("open-status-pane");
}
// Open Status Pane command
this.plugin.addCommand({
id: "open-status-pane",
name: "Open status pane",
callback: async () => {
await this.openStatusPane();
},
});
this.registeredCommands.add("open-status-pane");
}
private async openStatusPane(): Promise<void> {
@ -344,38 +337,12 @@ export class CommandsService {
});
}
private flattenStatuses(
groupedStatuses: GroupedStatuses | null | undefined,
): NoteStatus[] {
if (!groupedStatuses) {
return [];
}
const seen = new Set<string>();
const flattened: NoteStatus[] = [];
Object.values(groupedStatuses).forEach((list) => {
list.forEach((status) => {
const identifier = BaseNoteStatusService.formatStatusIdentifier(
{
templateId: status.templateId,
name: status.name,
},
);
if (!seen.has(identifier)) {
seen.add(identifier);
flattened.push(status);
}
});
});
return flattened;
}
private getFileStatuses(file: TFile): string[] {
const statusService = this.createStatusService(file);
statusService.populateStatuses();
const flattened = this.flattenStatuses(statusService.statuses);
return flattened.length > 0
? flattened.map((s) => s.name)
: ["unknown"];
const tagPrefix = settingsService.settings.tagPrefix;
const statuses = statusService.statuses[tagPrefix] || [];
return statuses.length > 0 ? statuses.map((s) => s.name) : ["unknown"];
}
/**
@ -389,10 +356,6 @@ export class CommandsService {
this.registeredCommands.clear();
}
private shouldEnableGroupedView(): boolean {
return isExperimentalFeatureEnabled("groupedStatusView");
}
public destroy(): void {
// Remove all registered commands properly
this.removeRegisteredCommands();

View file

@ -4,7 +4,7 @@ class EventBus {
private events: { [K in EventName]: Map<string, EventBusEvents[K]> } = {
"active-file-change": new Map(),
"plugin-settings-changed": new Map(),
"status-changed": new Map(),
"frontmatter-manually-changed": new Map(),
"triggered-open-modal": new Map(),
};
@ -32,8 +32,8 @@ class EventBus {
...args: Parameters<EventBusEvents["plugin-settings-changed"]>
): void;
publish(
event: "status-changed",
...args: Parameters<EventBusEvents["status-changed"]>
event: "frontmatter-manually-changed",
...args: Parameters<EventBusEvents["frontmatter-manually-changed"]>
): void;
publish(
event: "triggered-open-modal",

View file

@ -23,16 +23,14 @@ export class LazyElementObserver {
}
/**
* Sets up observers for multiple container elements
* Sets up observers for a container element
*/
setupObservers(containers: Element[]): void {
setupObservers(container: Element): void {
this.cleanup();
this.initializeIntersectionObserver();
this.initializeMutationObserver(containers);
containers.forEach((container) =>
this.observeExistingElements(container),
);
this.initializeMutationObserver(container);
this.observeExistingElements(container);
}
/**
@ -87,16 +85,14 @@ export class LazyElementObserver {
/**
* Initializes mutation observer for DOM changes
*/
private initializeMutationObserver(containers: Element[]): void {
private initializeMutationObserver(container: Element): void {
this.mutationObserver = new MutationObserver((mutations) =>
this.handleMutations(mutations),
);
containers.forEach((container) => {
this.mutationObserver?.observe(container, {
childList: true,
subtree: true,
});
this.mutationObserver.observe(container, {
childList: true,
subtree: true,
});
}

View file

@ -8,10 +8,6 @@ import {
} from "@/types/noteStatus";
import settingsService from "@/core/settingsService";
import eventBus from "./eventBus";
import statusStoreManager from "./statusStoreManager";
import type { StatusStore } from "./statusStores/types";
import { resolveFrontmatterKeysForStatus } from "@/utils/frontmatterMappings";
import { getFrontmatterKeysForFile } from "./statusKeyHelpers";
export abstract class BaseNoteStatusService {
static app: App;
@ -106,44 +102,6 @@ export abstract class BaseNoteStatusService {
return BaseNoteStatusService.resolveStatusFromIdentifier(statusName);
}
protected toScopedStatusName(
status: StatusIdentifier | NoteStatus,
): ScopedStatusName {
if (typeof status === "string") {
return BaseNoteStatusService.parseStatusIdentifier(status);
}
return {
name: status.name,
templateId: status.templateId,
};
}
protected hasExplicitMappingForStatus(
status: StatusIdentifier | NoteStatus,
): boolean {
const mappings =
settingsService.settings.statusFrontmatterMappings ?? [];
if (!mappings.length) {
return false;
}
const scoped = this.toScopedStatusName(status);
return mappings.some((mapping) => {
if (mapping.scope === "status") {
return (
mapping.statusName === scoped.name &&
(mapping.templateId || null) === (scoped.templateId || null)
);
}
if (mapping.scope === "template") {
return (
Boolean(scoped.templateId) &&
mapping.templateId === scoped.templateId
);
}
return false;
});
}
protected getStatusMetadataKeys(): string[] {
return [settingsService.settings.tagPrefix];
}
@ -161,208 +119,58 @@ export abstract class BaseNoteStatusService {
export class NoteStatusService extends BaseNoteStatusService {
private file: TFile;
private statusStore: StatusStore;
constructor(file: TFile) {
super();
this.file = file;
this.statusStore = statusStoreManager.getStoreForFile(file);
}
private isMarkdownFile(): boolean {
return this.file.extension === "md";
}
private collectIdentifiersForFile(): string[] {
const keysToRead = getFrontmatterKeysForFile(this.file);
const seen = new Set<string>();
const identifiers: string[] = [];
keysToRead.forEach((key) => {
const values = this.statusStore.getStatuses(this.file, key);
values.forEach((value) => {
const normalized = value?.toString();
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
identifiers.push(normalized);
});
});
return identifiers;
}
private buildStatusesArray(identifiers: string[]): NoteStatusType[] {
if (!identifiers.length) {
return [];
}
let statuses = identifiers
.map((identifier) => this.statusNameToObject(identifier))
.filter((status): status is NoteStatusType => status !== undefined);
if (statuses.length && !settingsService.settings.useMultipleStatuses) {
statuses = statuses.slice(0, 1);
}
return statuses;
}
private shouldStoreStatusesAsArray(): boolean {
if (settingsService.settings.useMultipleStatuses) {
return true;
}
const mode = settingsService.settings.singleStatusStorageMode || "list";
return mode === "list";
}
private getStoreOptions() {
return { storeAsArray: this.shouldStoreStatusesAsArray() };
}
private resolveTargetKeys(
frontmatterTagName: string,
status: StatusIdentifier | NoteStatus,
options?: { includeSourceKey?: boolean },
): string[] {
if (!this.isMarkdownFile()) {
return [frontmatterTagName];
}
let resolved = resolveFrontmatterKeysForStatus(
status,
settingsService.settings,
{ isMarkdownFile: true },
);
const defaultKey = settingsService.settings.tagPrefix;
const mappingMatched = this.hasExplicitMappingForStatus(status);
if (
resolved.length === 1 &&
resolved[0] === defaultKey &&
frontmatterTagName !== defaultKey
) {
resolved = [frontmatterTagName];
}
if (
mappingMatched &&
settingsService.settings.writeMappedTagsToDefault &&
defaultKey &&
this.isMarkdownFile()
) {
resolved = [...resolved, defaultKey];
}
if (options?.includeSourceKey && frontmatterTagName) {
resolved = [...resolved, frontmatterTagName];
}
const uniqueKeys = Array.from(
new Set(resolved.filter((key) => key && key.trim().length)),
);
return uniqueKeys.length ? uniqueKeys : [frontmatterTagName];
}
private async runAcrossKeys(
keys: string[],
action: (key: string) => Promise<boolean>,
): Promise<boolean> {
let hasChanged = false;
for (const key of keys) {
const changed = await action(key);
if (changed) {
hasChanged = true;
}
}
return hasChanged;
}
private statusesAreEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
return a.every((value, index) => value === b[index]);
}
public populateStatuses(): void {
const defaultKey = settingsService.settings.tagPrefix;
this.statuses = { [defaultKey]: [] };
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
this.statuses = Object.fromEntries(
STATUS_METADATA_KEYS.map((key) => [key, []]),
);
if (!this.file) {
return;
}
const identifiers = this.collectIdentifiersForFile();
if (!identifiers.length) {
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(this.file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) {
return;
}
this.statuses[defaultKey] = this.buildStatusesArray(identifiers);
}
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())];
}
public getStatusesForKey(frontmatterTagName: string): NoteStatusType[] {
const isValidKey =
!this.isMarkdownFile() &&
frontmatterTagName !== settingsService.settings.tagPrefix
? false
: true;
if (!isValidKey) {
return [];
}
const identifiers = this.statusStore
.getStatuses(this.file, frontmatterTagName)
.map((identifier) => identifier?.toString())
.filter((identifier): identifier is string => Boolean(identifier));
return this.buildStatusesArray(identifiers);
}
public getStatusesByAllKeys(): GroupedStatuses {
const keys = getFrontmatterKeysForFile(this.file);
const grouped: GroupedStatuses = {};
let hasStatuses = false;
keys.forEach((key) => {
const statuses = this.getStatusesForKey(key);
if (!statuses.length) {
return;
statuses = statuses.filter((s) => s !== undefined);
if (
statuses.length &&
!settingsService.settings.useMultipleStatuses
) {
statuses = statuses.slice(0, 1);
}
this.statuses[key] = statuses as NoteStatusType[];
}
grouped[key] = statuses;
hasStatuses = true;
});
if (!hasStatuses) {
grouped[settingsService.settings.tagPrefix] =
this.statuses[settingsService.settings.tagPrefix] ?? [];
}
return grouped;
}
async removeStatus(
frontmatterTagName: string,
status: NoteStatus,
): Promise<boolean> {
const targetKeys = this.resolveTargetKeys(frontmatterTagName, status, {
includeSourceKey: true,
});
const removed = await this.runAcrossKeys(targetKeys, (key) =>
this.removeStatusFromKey(key, status),
);
if (removed) {
eventBus.publish("status-changed", {
file: this.file,
});
}
return removed;
}
private async removeStatusFromKey(
frontmatterTagName: string,
status: NoteStatus,
): Promise<boolean> {
let removed = false;
const targetIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
@ -370,173 +178,88 @@ export class NoteStatusService extends BaseNoteStatusService {
})
: status.name;
return this.statusStore.mutateStatuses(
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
frontmatterTagName,
(current) => {
if (!current.length) {
return { hasChanged: false };
}
(frontmatter) => {
const noteStatusFrontmatter =
(frontmatter?.[frontmatterTagName] as string[]) || [];
if (!noteStatusFrontmatter.length) return;
let i = current.findIndex(
(statusName: string) => statusName === targetIdentifier,
);
if (i === -1 && status.templateId) {
i = current.findIndex(
(statusName: string) => statusName === status.name,
if (Array.isArray(noteStatusFrontmatter)) {
// First try to find exact match (scoped or legacy)
let i = noteStatusFrontmatter.findIndex(
(statusName: string) => statusName === targetIdentifier,
);
}
if (i === -1) {
return { hasChanged: false };
}
// 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,
);
}
const nextStatuses = [...current];
nextStatuses.splice(i, 1);
return { hasChanged: true, nextStatuses };
if (i !== -1) {
noteStatusFrontmatter.splice(i, 1);
removed = true;
}
}
},
this.getStoreOptions(),
);
if (removed) {
eventBus.publish("frontmatter-manually-changed", {
file: this.file,
});
}
return removed;
}
async clearStatus(frontmatterTagName: string): Promise<boolean> {
const keys = new Set<string>([frontmatterTagName]);
if (this.isMarkdownFile()) {
getFrontmatterKeysForFile(this.file).forEach((key) =>
keys.add(key),
);
}
const cleared = await this.runAcrossKeys([...keys], (key) =>
this.clearStatusForKey(key),
);
if (cleared) {
eventBus.publish("status-changed", {
file: this.file,
});
}
return cleared;
}
private async clearStatusForKey(
frontmatterTagName: string,
): Promise<boolean> {
return this.statusStore.mutateStatuses(
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
frontmatterTagName,
(current) => {
if (!current.length) {
return { hasChanged: false };
(frontmatter) => {
if (frontmatterTagName in frontmatter) {
frontmatter[frontmatterTagName] = [];
}
return { hasChanged: true, nextStatuses: [] };
},
this.getStoreOptions(),
);
eventBus.publish("frontmatter-manually-changed", { file: this.file });
return true;
}
async overrideStatuses(
frontmatterTagName: string,
statusIdentifiers: StatusIdentifier[],
): Promise<boolean> {
const limitedIdentifiers = settingsService.settings.useMultipleStatuses
? statusIdentifiers
: statusIdentifiers.slice(0, 1);
if (!limitedIdentifiers.length) {
return this.clearStatus(frontmatterTagName);
}
const keyMap = new Map<string, StatusIdentifier[]>();
if (!this.isMarkdownFile()) {
keyMap.set(frontmatterTagName, limitedIdentifiers);
} else {
limitedIdentifiers.forEach((identifier) => {
const keys = this.resolveTargetKeys(
frontmatterTagName,
identifier,
);
keys.forEach((key) => {
const currentList = keyMap.get(key) ?? [];
currentList.push(identifier);
keyMap.set(key, currentList);
});
});
const knownKeys = new Set<string>([
frontmatterTagName,
...getFrontmatterKeysForFile(this.file),
]);
knownKeys.forEach((key) => {
if (!keyMap.has(key)) {
keyMap.set(key, []);
}
});
}
const overridden = await this.runAcrossKeys(
Array.from(keyMap.keys()),
(key) => this.overrideStatusesForKey(key, keyMap.get(key) ?? []),
);
if (overridden) {
eventBus.publish("status-changed", {
file: this.file,
});
}
return overridden;
}
private async overrideStatusesForKey(
frontmatterTagName: string,
statusIdentifiers: StatusIdentifier[],
): Promise<boolean> {
const formattedIdentifiers = statusIdentifiers.map((id) =>
typeof id === "string"
? id
: BaseNoteStatusService.formatStatusIdentifier(id),
);
return this.statusStore.mutateStatuses(
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
frontmatterTagName,
(current) => {
if (this.statusesAreEqual(current, formattedIdentifiers)) {
return { hasChanged: false };
(frontmatter) => {
if (
frontmatterTagName in frontmatter &&
Array.isArray(frontmatter[frontmatterTagName])
) {
frontmatter[frontmatterTagName].splice(0);
frontmatter[frontmatterTagName].push(
...formattedIdentifiers,
);
}
return {
hasChanged: true,
nextStatuses: formattedIdentifiers,
};
frontmatter[frontmatterTagName] = [...formattedIdentifiers];
},
this.getStoreOptions(),
);
eventBus.publish("frontmatter-manually-changed", { file: this.file });
return true;
}
async addStatus(
frontmatterTagName: string,
statusIdentifier: StatusIdentifier,
): Promise<boolean> {
const targetKeys = this.resolveTargetKeys(
frontmatterTagName,
statusIdentifier,
);
const added = await this.runAcrossKeys(targetKeys, (key) =>
this.addStatusToKey(key, statusIdentifier),
);
if (added) {
eventBus.publish("status-changed", {
file: this.file,
});
}
return added;
}
private async addStatusToKey(
frontmatterTagName: string,
statusIdentifier: StatusIdentifier,
): Promise<boolean> {
let added = false;
const formattedIdentifier =
typeof statusIdentifier === "string"
? statusIdentifier
@ -544,48 +267,64 @@ export class NoteStatusService extends BaseNoteStatusService {
statusIdentifier,
);
return this.statusStore.mutateStatuses(
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
frontmatterTagName,
(current) => {
(frontmatter) => {
const noteStatusFrontmatter =
(frontmatter?.[frontmatterTagName] as string[]) || [];
if (!settingsService.settings.useMultipleStatuses) {
const newValue = [formattedIdentifier];
if (this.statusesAreEqual(current, newValue)) {
return { hasChanged: false };
}
return { hasChanged: true, nextStatuses: newValue };
}
const exactMatch = current.findIndex(
(statusName: string) => statusName === formattedIdentifier,
);
if (exactMatch !== -1) {
return { hasChanged: false };
}
let legacyIndex = -1;
if (
typeof statusIdentifier !== "string" &&
statusIdentifier.templateId
) {
legacyIndex = current.findIndex(
(statusName: string) =>
statusName === statusIdentifier.name,
);
}
const nextStatuses = [...current];
if (legacyIndex !== -1) {
nextStatuses[legacyIndex] = formattedIdentifier;
frontmatter[frontmatterTagName] = [formattedIdentifier];
added = true;
} else {
nextStatuses.push(formattedIdentifier);
}
// Ensure frontmatter property exists as an array
if (
!frontmatter[frontmatterTagName] ||
!Array.isArray(frontmatter[frontmatterTagName])
) {
frontmatter[frontmatterTagName] = [];
}
return { hasChanged: true, nextStatuses };
// 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;
}
}
}
},
this.getStoreOptions(),
);
if (added) {
eventBus.publish("frontmatter-manually-changed", {
file: this.file,
});
}
return added;
}
}
@ -602,60 +341,109 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
}
public populateStatuses(): void {
const defaultKey = settingsService.settings.tagPrefix;
this.statuses = { [defaultKey]: [] };
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
const aggregated = new Set<string>();
this.statuses = Object.fromEntries(
STATUS_METADATA_KEYS.map((key) => [key, []]),
);
const allStatuses = new Map<string, Set<string>>();
this.files.forEach((file) => {
let store: StatusStore;
try {
store = statusStoreManager.getStoreForFile(file);
} catch {
return;
}
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
const keys = getFrontmatterKeysForFile(file);
keys.forEach((key) => {
const identifiers = store.getStatuses(file, key);
identifiers.forEach((identifier) => {
const normalized = identifier?.toString();
if (normalized && !aggregated.has(normalized)) {
aggregated.add(normalized);
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()),
);
}
});
});
if (!aggregated.size) {
return;
}
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[];
const statuses = Array.from(aggregated)
.map((identifier) => this.statusNameToObject(identifier))
.filter((status): status is NoteStatusType => status !== undefined);
this.statuses[defaultKey] = statuses;
this.statuses[key] = statuses;
}
});
}
async removeStatus(
frontmatterTagName: string,
status: NoteStatus,
): Promise<boolean> {
const removalResults = await Promise.all(
this.files.map(async (file) => {
const noteStatusService = new NoteStatusService(file);
return noteStatusService.removeStatus(
frontmatterTagName,
status,
);
}),
);
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);
const removedFromAny = removalResults.some((result) => result);
if (removedFromAny) {
this.populateStatuses();
this.files.forEach((file) => {
eventBus.publish("frontmatter-manually-changed", { file });
});
}
return removedFromAny;
}
@ -663,20 +451,55 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
frontmatterTagName: string,
statusIdentifier: StatusIdentifier,
): Promise<boolean> {
const results = await Promise.all(
this.files.map(async (file) => {
const noteStatusService = new NoteStatusService(file);
return noteStatusService.addStatus(
frontmatterTagName,
statusIdentifier,
);
}),
);
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);
const addedToAny = results.some((result) => result);
if (addedToAny) {
this.populateStatuses();
this.files.forEach((file) => {
eventBus.publish("frontmatter-manually-changed", { file });
});
}
return addedToAny;
}
@ -692,20 +515,25 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
: status.name;
return this.files.filter((file) => {
let store: StatusStore;
try {
store = statusStoreManager.getStoreForFile(file);
} catch {
return false;
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))
);
}
const identifiers = store.getStatuses(file, frontmatterTagName);
if (!identifiers.length) return false;
return identifiers.some(
(identifier) =>
identifier === targetIdentifier ||
(status.templateId && identifier === status.name),
// Check for exact match first, then legacy format if scoped
return (
value === targetIdentifier ||
(status.templateId && value === status.name)
);
});
}

View file

@ -1,78 +1,17 @@
import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings";
import { PluginSettings, SyncGroup } from "@/types/pluginSettings";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
import { Plugin, TFile, normalizePath } from "obsidian";
import { PluginSettings } from "@/types/pluginSettings";
import { Plugin } from "obsidian";
import eventBus from "./eventBus";
export const SETTINGS_GROUPS: Record<SyncGroup, (keyof PluginSettings)[]> = {
templates: ["templates", "enabledTemplates"],
customStatuses: ["customStatuses"],
statusColors: ["statusColors"],
uiAppearance: [
"fileExplorerIconPosition",
"fileExplorerIconFrame",
"fileExplorerIconColorMode",
"showStatusBar",
"autoHideStatusBar",
"statusBarShowTemplateName",
"statusBarBadgeStyle",
"statusBarBadgeContentMode",
"showStatusIconsInExplorer",
"hideUnknownStatusInExplorer",
"fileExplorerColorFileName",
"fileExplorerColorBlock",
"fileExplorerLeftBorder",
"fileExplorerStatusDot",
"fileExplorerUnderlineFileName",
"unknownStatusIcon",
"unknownStatusLucideIcon",
"unknownStatusColor",
"statusBarNoStatusText",
"statusBarShowNoStatusIcon",
"statusBarShowNoStatusText",
"showEditorToolbarButton",
"editorToolbarButtonPosition",
"editorToolbarButtonDisplay",
],
workflow: [
"useCustomStatusesOnly",
"useMultipleStatuses",
"singleStatusStorageMode",
"strictStatuses",
"enableStatusOverviewPopup",
"defaultStatusForNewNotes",
],
storage: [
"tagPrefix",
"statusFrontmatterMappings",
"writeMappedTagsToDefault",
"applyStatusRecursivelyToSubfolders",
],
features: [
"quickStatusCommands",
"enableExperimentalFeatures",
"enableStatusDashboard",
"enableGroupedStatusView",
"vaultSizeLimit",
],
};
class SettingsService {
private plugin: Plugin;
public settings: PluginSettings;
private isWatcherRegistered = false;
constructor() {}
async initialize(plugin: Plugin): Promise<void> {
this.plugin = plugin;
const loadedData = await this.loadSettings();
this.migrateLegacySettings(loadedData);
if (this.settings.enableExternalStatusSync) {
await this.loadFromExternalFile();
this.setupExternalFileWatcher();
}
await this.loadSettings();
}
setValue(key: keyof PluginSettings, value: unknown) {
@ -82,19 +21,6 @@ class SettingsService {
// const oldValue = this.settings[key]; // TODO: Send the old value
this.settings[key] = value;
this.saveSettings().catch(console.error);
// Handle external sync
if (this.settings.enableExternalStatusSync) {
this.syncToExternalFile().catch(console.error);
}
if (key === "enableExternalStatusSync") {
if (value) {
this.syncToExternalFile().catch(console.error);
this.setupExternalFileWatcher();
}
}
// INFO: Send the propgation event
eventBus.publish("plugin-settings-changed", {
key,
@ -104,215 +30,17 @@ class SettingsService {
}
private async loadSettings() {
const loadedData = await this.plugin.loadData();
this.settings = this.mergeSettings(DEFAULT_PLUGIN_SETTINGS, loadedData);
this.deduplicateTemplates();
return loadedData;
}
/**
* Migrates legacy settings and ensures new users have a starter template.
*/
private migrateLegacySettings(loadedData: Partial<PluginSettings> | null) {
const hasTemplates =
this.settings.templates && this.settings.templates.length > 0;
// 1. If it's a truly new user (no data at all), wait, we now provide all templates in DEFAULT_PLUGIN_SETTINGS
if (!loadedData) {
return;
}
// 2. If it's an existing user but has no templates, they were using the old defaults
if (!hasTemplates) {
const legacyIds = [
"colorful",
"minimal",
"academic",
"project",
"creative-writing",
];
const enabledLegacy = (this.settings.enabledTemplates || []).filter(
(id) => legacyIds.includes(id),
);
if (enabledLegacy.length > 0) {
// Restore the ones they had enabled from the marketplace list
const toRestore = PREDEFINED_TEMPLATES.filter((t) =>
enabledLegacy.includes(t.id),
).map((t) => ({
...t,
id: t.id, // Keep original ID for legacy compatibility
statuses: t.statuses.map((s) => ({
...s,
templateId: t.id,
})),
}));
this.settings.templates = toRestore;
// Update enabled list
this.settings.enabledTemplates = toRestore.map((t) => t.id);
this.saveSettings().catch(console.error);
}
}
}
/**
* Removes duplicate templates with the same name, keeping the first occurrence.
*/
private deduplicateTemplates() {
if (!this.settings.templates) return;
const seenNames = new Set<string>();
const uniqueTemplates = [];
let hasDuplicates = false;
for (const template of this.settings.templates) {
const lowerName = template.name.toLowerCase().trim();
if (!seenNames.has(lowerName)) {
seenNames.add(lowerName);
uniqueTemplates.push(template);
} else {
hasDuplicates = true;
}
}
if (hasDuplicates) {
this.settings.templates = uniqueTemplates;
this.saveSettings().catch(console.error);
}
}
/**
* Deep merges loaded settings with defaults to prevent data loss for nested objects like statusColors.
*/
private mergeSettings(
defaults: PluginSettings,
loaded: Partial<PluginSettings> | null,
): PluginSettings {
if (!loaded) return { ...defaults };
const result = { ...defaults, ...loaded };
// Deep merge statusColors
if (loaded.statusColors) {
result.statusColors = {
...defaults.statusColors,
...loaded.statusColors,
};
}
return result;
this.settings = Object.assign(
{},
DEFAULT_PLUGIN_SETTINGS,
await this.plugin.loadData(),
);
return this.settings;
}
private async saveSettings() {
await this.plugin.saveData(this.settings);
}
async syncToExternalFile(force = false) {
if (!this.settings.enableExternalStatusSync && !force) return;
const path = normalizePath(this.settings.externalStatusSyncPath);
// Filter settings based on selected groups
const keysToSync = new Set<keyof PluginSettings>();
(this.settings.syncGroups || []).forEach((group) => {
const groupKeys = SETTINGS_GROUPS[group];
if (groupKeys) {
groupKeys.forEach((key) => keysToSync.add(key));
}
});
const filteredSettings: Partial<PluginSettings> = {};
keysToSync.forEach((key) => {
(filteredSettings as Record<string, unknown>)[key] =
this.settings[key];
});
const data = {
...filteredSettings,
syncGroups: this.settings.syncGroups, // Always include meta-settings
updatedAt: new Date().toISOString(),
};
try {
await this.plugin.app.vault.adapter.write(
path,
JSON.stringify(data, null, 2),
);
} catch (error) {
console.error("Failed to sync settings to external file:", error);
}
}
async loadFromExternalFile(force = false) {
if (!this.settings.enableExternalStatusSync && !force) return;
const path = normalizePath(this.settings.externalStatusSyncPath);
if (!(await this.plugin.app.vault.adapter.exists(path))) return;
try {
const content = await this.plugin.app.vault.adapter.read(path);
const data = JSON.parse(content);
// Determine which keys we should actually apply based on CURRENT settings
const allowedKeys = new Set<keyof PluginSettings>();
(this.settings.syncGroups || []).forEach((group) => {
const groupKeys = SETTINGS_GROUPS[group];
if (groupKeys) {
groupKeys.forEach((key) => allowedKeys.add(key));
}
});
let hasChanged = false;
const keys = Object.keys(data).filter(
(k) => k !== "updatedAt" && k !== "syncGroups",
) as Array<keyof PluginSettings>;
for (const key of keys) {
if (!(key in this.settings) || !allowedKeys.has(key)) continue;
const newValue = data[key];
const currentValue = this.settings[key];
// Basic deep equality check to avoid unnecessary updates
if (JSON.stringify(newValue) !== JSON.stringify(currentValue)) {
(this.settings as Record<string, unknown>)[key] = newValue;
hasChanged = true;
// Notify UI and other services
eventBus.publish("plugin-settings-changed", {
key: key as keyof PluginSettings,
value: newValue,
currentSettings: this.settings,
});
}
}
if (hasChanged) {
await this.saveSettings();
}
} catch (error) {
console.error("Failed to load settings from external file:", error);
}
}
private setupExternalFileWatcher() {
if (this.isWatcherRegistered) return;
this.plugin.registerEvent(
this.plugin.app.vault.on("modify", (file) => {
if (!this.settings.enableExternalStatusSync) return;
if (
file instanceof TFile &&
file.path ===
normalizePath(this.settings.externalStatusSyncPath)
) {
this.loadFromExternalFile().catch(console.error);
}
}),
);
this.isWatcherRegistered = true;
}
}
export default new SettingsService();

View file

@ -1,31 +0,0 @@
import { TFile } from "obsidian";
import settingsService from "./settingsService";
import { getKnownFrontmatterKeys } from "@/utils/frontmatterMappings";
const ensureOrderedKeys = (keys: string[]): string[] => {
const defaultKey = settingsService.settings.tagPrefix;
const seen = new Set<string>();
const ordered: string[] = [];
[defaultKey, ...keys].forEach((key) => {
const trimmed = key?.trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
ordered.push(trimmed);
});
return ordered;
};
export const getAllFrontmatterKeys = (): string[] => {
const keys = getKnownFrontmatterKeys(settingsService.settings);
return ensureOrderedKeys(keys);
};
export const getFrontmatterKeysForFile = (file: TFile): string[] => {
if (file.extension !== "md") {
return [settingsService.settings.tagPrefix];
}
return getAllFrontmatterKeys();
};

View file

@ -1,45 +0,0 @@
import { Plugin, TFile } from "obsidian";
import { FrontmatterStatusStore } from "./statusStores/frontmatterStatusStore";
import { NonMarkdownStatusStore } from "./statusStores/nonMarkdownStatusStore";
import { StatusStore } from "./statusStores/types";
class StatusStoreManager {
private stores: StatusStore[] = [];
private nonMarkdownStore: NonMarkdownStatusStore | null = null;
async initialize(plugin: Plugin) {
const frontmatterStore = new FrontmatterStatusStore(plugin.app);
this.stores = [frontmatterStore];
this.nonMarkdownStore = new NonMarkdownStatusStore(plugin);
await this.nonMarkdownStore.initialize();
this.registerStore(this.nonMarkdownStore);
}
getNonMarkdownStore(): NonMarkdownStatusStore {
if (!this.nonMarkdownStore) {
throw new Error("NonMarkdownStatusStore is not initialized");
}
return this.nonMarkdownStore;
}
registerStore(store: StatusStore) {
this.stores.push(store);
}
getStoreForFile(file: TFile): StatusStore {
const store = this.stores.find((candidate) =>
candidate.canHandle(file),
);
if (!store) {
throw new Error(
`No status store registered for file type: ${file.extension}`,
);
}
return store;
}
}
export default new StatusStoreManager();

View file

@ -1,80 +0,0 @@
import { App, TFile } from "obsidian";
import { StatusMutation, StatusStore } from "./types";
export class FrontmatterStatusStore implements StatusStore {
constructor(private readonly app: App) {}
canHandle(file: TFile): boolean {
return file.extension === "md";
}
getStatuses(file: TFile, frontmatterTagName: string): string[] {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return [];
return this.normalizeValue(frontmatter[frontmatterTagName]);
}
async mutateStatuses(
file: TFile,
frontmatterTagName: string,
mutator: (current: string[]) => StatusMutation,
options: { storeAsArray: boolean },
): Promise<boolean> {
let changed = false;
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
const currentStatuses = this.normalizeValue(
frontmatter?.[frontmatterTagName],
);
const mutation = mutator([...currentStatuses]);
if (!mutation.hasChanged) return;
this.writeStatuses(
frontmatter,
frontmatterTagName,
mutation.nextStatuses,
options.storeAsArray,
);
changed = true;
});
return changed;
}
private normalizeValue(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) {
return value
.map((status) =>
status === undefined || status === null
? undefined
: String(status),
)
.filter((status): status is string => Boolean(status));
}
return [String(value)];
}
private writeStatuses(
frontmatter: Record<string, unknown>,
frontmatterTagName: string,
statuses: string[],
storeAsArray: boolean,
) {
if (!statuses.length) {
if (storeAsArray) {
frontmatter[frontmatterTagName] = [];
} else {
delete frontmatter[frontmatterTagName];
}
return;
}
if (storeAsArray) {
frontmatter[frontmatterTagName] = [...statuses];
} else {
frontmatter[frontmatterTagName] = statuses[0];
}
}
}

View file

@ -1,268 +0,0 @@
import { normalizePath, Plugin, TAbstractFile, TFile } from "obsidian";
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { StatusMutation, StatusStore } from "./types";
type FileStatusMap = Record<string, Record<string, string[]>>;
type PersistedData = {
version: number;
files: FileStatusMap;
};
export class NonMarkdownStatusStore implements StatusStore {
private data: FileStatusMap = {};
private isWatcherRegistered = false;
constructor(private readonly plugin: Plugin) {}
async initialize(): Promise<void> {
this.data = await this.loadFromDisk();
this.registerVaultEvents();
this.setupSyncWatcher();
// Reload if settings change
eventBus.subscribe(
"plugin-settings-changed",
({ key }) => {
if (
key === "enableNonMarkdownSync" ||
key === "nonMarkdownSyncPath"
) {
this.loadFromDisk()
.then((newData) => {
this.data = newData;
this.setupSyncWatcher();
// Notify UI that statuses might have changed
this.plugin.app.workspace.iterateAllLeaves(
(leaf) => {
const view = leaf.view as {
requestRefresh?: () => void;
};
if (view.requestRefresh)
view.requestRefresh();
},
);
})
.catch(console.error);
}
},
"non-markdown-status-store-sync-subscriptor",
);
}
private getDataPath(): string {
const settings = settingsService.settings;
if (
settings &&
settings.enableNonMarkdownSync &&
settings.nonMarkdownSyncPath
) {
return this.getVaultPath();
}
return this.getInternalPath();
}
private getInternalPath(): string {
const configDir = this.plugin.app.vault.configDir;
const pluginDir = `${configDir}/plugins/${this.plugin.manifest.id}`;
return normalizePath(`${pluginDir}/non-markdown-statuses.json`);
}
private getVaultPath(): string {
const settings = settingsService.settings;
return normalizePath(settings.nonMarkdownSyncPath);
}
async exportToVault(): Promise<void> {
const internalPath = this.getInternalPath();
const vaultPath = this.getVaultPath();
if (!(await this.plugin.app.vault.adapter.exists(internalPath))) {
throw new Error("No internal data found to export.");
}
const raw = await this.plugin.app.vault.adapter.read(internalPath);
await this.ensureDirectoryExists(vaultPath);
await this.plugin.app.vault.adapter.write(vaultPath, raw);
// Reload if vault sync is active
if (settingsService.settings.enableNonMarkdownSync) {
this.data = await this.loadFromDisk();
}
}
async importFromVault(): Promise<void> {
const internalPath = this.getInternalPath();
const vaultPath = this.getVaultPath();
if (!(await this.plugin.app.vault.adapter.exists(vaultPath))) {
throw new Error("No vault sync file found to import.");
}
const raw = await this.plugin.app.vault.adapter.read(vaultPath);
await this.ensureDirectoryExists(internalPath);
await this.plugin.app.vault.adapter.write(internalPath, raw);
// Reload if vault sync is inactive
if (!settingsService.settings.enableNonMarkdownSync) {
this.data = await this.loadFromDisk();
}
}
canHandle(file: TFile): boolean {
return file.extension !== "md";
}
getStatuses(file: TFile, frontmatterTagName: string): string[] {
const fileStatuses = this.data[file.path];
if (!fileStatuses) return [];
const statuses = fileStatuses[frontmatterTagName];
return statuses ? [...statuses] : [];
}
async mutateStatuses(
file: TFile,
frontmatterTagName: string,
mutator: (current: string[]) => StatusMutation,
_options: { storeAsArray: boolean },
): Promise<boolean> {
const fileStatuses = this.data[file.path] ?? {};
const current = fileStatuses[frontmatterTagName] ?? [];
const mutation = mutator([...current]);
if (!mutation.hasChanged) {
return false;
}
if (mutation.nextStatuses.length) {
if (!this.data[file.path]) {
this.data[file.path] = {};
}
this.data[file.path][frontmatterTagName] = [
...mutation.nextStatuses,
];
} else if (this.data[file.path]) {
delete this.data[file.path][frontmatterTagName];
if (Object.keys(this.data[file.path]).length === 0) {
delete this.data[file.path];
}
}
await this.persistData();
return true;
}
private async loadFromDisk(): Promise<FileStatusMap> {
const path = this.getDataPath();
try {
const exists = await this.plugin.app.vault.adapter.exists(path);
if (!exists) return {};
const raw = await this.plugin.app.vault.adapter.read(path);
const parsed = JSON.parse(raw) as PersistedData;
if (parsed && typeof parsed === "object" && parsed.files) {
return parsed.files;
}
} catch (e) {
console.error("Failed to load non-markdown statuses:", e);
}
return {};
}
private async persistData(): Promise<void> {
const payload: PersistedData = {
version: 1,
files: this.data,
};
const path = this.getDataPath();
await this.ensureDirectoryExists(path);
await this.plugin.app.vault.adapter.write(
path,
JSON.stringify(payload, null, 2),
);
}
private async ensureDirectoryExists(path: string): Promise<void> {
const dir = path.split("/").slice(0, -1).join("/");
if (!dir) return;
if (!(await this.plugin.app.vault.adapter.exists(dir))) {
await this.plugin.app.vault.adapter.mkdir(dir);
}
}
private setupSyncWatcher() {
if (this.isWatcherRegistered) return;
this.plugin.registerEvent(
this.plugin.app.vault.on("modify", (file) => {
const settings = settingsService.settings;
if (!settings.enableNonMarkdownSync) return;
if (
file instanceof TFile &&
file.path === normalizePath(settings.nonMarkdownSyncPath)
) {
this.loadFromDisk()
.then((newData) => {
this.data = newData;
// Emit events for all files that might have changed
// This is a bit heavy, but non-markdown files are fewer
Object.keys(this.data).forEach((filePath) => {
const f =
this.plugin.app.vault.getAbstractFileByPath(
filePath,
);
if (f instanceof TFile) {
eventBus.publish("status-changed", {
file: f,
});
}
});
})
.catch(console.error);
}
}),
);
this.isWatcherRegistered = true;
}
private registerVaultEvents() {
this.plugin.registerEvent(
this.plugin.app.vault.on("rename", (file, oldPath) => {
this.handleRename(file, oldPath);
}),
);
this.plugin.registerEvent(
this.plugin.app.vault.on("delete", (file) => {
this.handleDelete(file);
}),
);
}
private handleRename(file: TAbstractFile, oldPath: string) {
if (!(file instanceof TFile)) return;
const existing = this.data[oldPath];
if (!existing) return;
this.data[file.path] = existing;
delete this.data[oldPath];
this.persistData()
.then(() => {
eventBus.publish("status-changed", { file });
})
.catch(console.error);
}
private handleDelete(file: TAbstractFile) {
if (!(file instanceof TFile)) return;
if (!this.data[file.path]) return;
delete this.data[file.path];
this.persistData()
.then(() => {
eventBus.publish("status-changed", { file });
})
.catch(console.error);
}
}

View file

@ -1,18 +0,0 @@
import { TFile } from "obsidian";
export type StatusMutation =
| { hasChanged: false }
| { hasChanged: true; nextStatuses: string[] };
export type StatusMutator = (current: string[]) => StatusMutation;
export interface StatusStore {
canHandle(file: TFile): boolean;
getStatuses(file: TFile, frontmatterTagName: string): string[];
mutateStatuses(
file: TFile,
frontmatterTagName: string,
mutator: StatusMutator,
options: { storeAsArray: boolean },
): Promise<boolean>;
}

View file

@ -1,78 +0,0 @@
# Feature Guide
## Status editing & selection
- Open the picker from the status bar badge, editor toolbar button, file explorer context menu, or the `Change current note status` command. The same modal handles single files, multi-select batches, and folder runs.
- Browse by template tabs (All, per template, Custom), search, and use full keyboard navigation (arrows/Tab + Enter/Backspace/Escape). Current statuses appear as removable chips; single-status mode keeps only one value.
- Works on Markdown and non-Markdown; resolves every relevant frontmatter key (default tag and any mappings) before writing.
- Clipboard helpers: `Copy current note status` and `Paste status to current note` mirror what you see in the picker.
## Bulk changes & context menus
- Right-click one or many files → `Change note status` to batch-apply statuses.
- Right-click a folder → `Apply note status to folder`; enable recursion in settings if you want nested folders included.
- The modal shows how many files will change before you confirm.
## Status surfaces
**Status bar**
- Clickable badges; hide entirely or auto-hide when empty. Keep a “No status” badge with custom text/icon/color if you prefer.
- Template names can be hidden, auto-shown only on conflicts, or always shown.
- Badge visuals: accent stripe, solid fill, or dot; content can be icon+text, icon only, text only, or empty accent.
**Editor toolbar**
- Optional badge in the editor header. Place it left, right-before actions, or right, and choose whether it shows for all editors or only the active one.
- Uses the same unknown-status icon/color you configure elsewhere.
**File explorer**
- Status icons appear next to file names. Choose placement (filename left, filename right, absolute right), add a frame or keep it frameless, and pick whether icons use status colors or the theme.
- Extra cues per option: color filename, tint the row, add a left border, append a dot, underline the filename. Unknowns can be hidden.
- Hover an icon to open the Status Overview popup listing every frontmatter key with statuses, highlighting the default tag.
**Status overview popup**
- Lightweight popup used by explorer/status icons; lists statuses per key and labels the default tag. Respects the enable/disable toggle.
## Dashboards and views (experimental)
**Status Dashboard**
- Ribbon icon/command (gated by Experimental Features + Dashboard toggles and the vault size limit). Shows the active note (path, modified time, statuses per tag), vault totals, and a status distribution chart. Clicking a bar runs a search for that status.
- Quick Actions panel: open grouped view, find unassigned notes, change/cycle/clear status, copy/paste status, search by status, toggle multi-status mode, and run Quick Status Commands.
**Grouped Status View**
- Ribbon icon/command (same gating and vault limit). Groups notes by frontmatter key, then by status (template badges shown when relevant).
- Filters: quick search, filter by note name substring, and filter by template id. Groups are collapsible and support “load more” for large sets. Clicking a note opens it in a new leaf.
## Templates & custom statuses
- Built-in templates:
- Colorful (idea → draft → inProgress → editing → pending → onHold → needsUpdate → completed → archived)
- Minimal (todo → inProgress → review → done)
- Academic (research → outline → draft → review → revision → final → published)
- Project (planning → backlog → ready → inDevelopment → testing → review → approved → live).
- Build your own templates (name, description, ordered statuses with emoji/Lucide + color). New templates auto-enable; you can edit/delete or reset built-ins anytime.
- Custom statuses live outside templates; create, reorder, delete them, or run in “Custom only” mode to hide template statuses entirely.
- Unknown status handling is configurable (icon/Lucide icon, color, and whether unknowns show in explorer/status bar).
## Commands & keyboard shortcuts
- Change current note status (opens picker).
- Cycle through statuses (single-status mode only).
- Clear status.
- Copy/Paste status (clipboard).
- Toggle multiple statuses mode.
- Search notes by current status (opens Global Search with queries for every configured frontmatter key).
- Open grouped status view (requires toggle).
- Quick Status Commands: select statuses in settings to auto-register `Set status to ...` commands you can bind to hotkeys.
## Data & compatibility
- Frontmatter key prefix defaults to `obsidian-note-status`; change it per vault. Map templates/statuses to extra YAML keys, and optionally also write mapped tags to the default key.
- Single-status mode can store the value as a list (`status: [in-progress]`) or plain string (`status: in-progress`) for compatibility with plugins that expect strings.
- Non-Markdown files store statuses in `.obsidian/plugins/obsidian-note-status/non-markdown-statuses.json`. Rename/delete events stay in sync automatically.
- Strict status validation removes unknown statuses when you edit a files statuses—use with caution if you expect ad-hoc values.

View file

@ -1,126 +0,0 @@
## Settings Reference
All settings are grouped the same way you see them in the plugin. Each section lists what it controls and when to tweak it.
---
## Templates & Statuses
### Status templates
- Enable/disable built-in templates or build your own (name, description, ordered statuses with emoji/Lucide + color).
- Auto-enables new templates; you can reset built-ins anytime.
- Good for shared workflows: research pipelines, task boards, project phases.
### Use only custom statuses
- Hides all template statuses so only your standalone statuses appear.
- Handy for very small personal vocabularies (e.g., `idea`, `draft`, `done`).
### Custom statuses
- Create standalone statuses, reorder, delete.
- Useful for special-purpose tags like `needsReviewByJohn` or `instagramPostReady`.
---
## Quick Actions
### Quick Status Commands
- Registers `Set status to ...` commands you can bind to hotkeys.
- Best when you want fast keyboard-driven changes; shines in single-status mode.
---
## Status Bar
### Visibility and behavior
- Show status icon in status bar; optionally hide when no status is set.
- Show status overview popup on hover for a quick per-key breakdown.
- Show template names next to status: never / auto on conflicts (default) / always.
### Badge visuals
- Badge style: accent stripe / filled / dot.
- Badge content: icon + text / icon only / text only / empty accent.
### No-status display
- Custom text for empty state.
- Show icon when no status.
- Show text when no status.
---
## Editor Toolbar
### Toolbar button
- Show/hide the status button in the editor header.
- Position: left / right-before-actions / right.
- Show for: all notes / active note only.
---
## File Explorer
### Icons and placement
- Show status icons in file explorer.
- Icon position: filename left / filename right / absolute right.
- Hide icon when status is unknown.
### Visual treatments (per-option)
- Color filename text.
- Color entire row.
- Left border.
- Status dot.
- Underline filename.
### Icon styling
- Status icon frame: never / always.
- Status icon color: use status colors / inherit theme colors.
---
## Unknown / No-Status Appearance
### Fallback visuals
- Icon for unknown status (emoji or Lucide).
- Color for unknown status (used across status bar/explorer fallbacks).
---
## Behavior & Storage
### Status model
- Enable multiple statuses (default) or force single-status mode.
- Single-status format when multiple is off: list (`status: [draft]`) or string (`status: draft`).
### Write targets
- Apply status recursively to subfolders (folder context menu option).
- Status tag prefix (frontmatter key, default `obsidian-note-status`).
- Frontmatter mappings: map templates or statuses to additional YAML keys; optional “write mapped tags to default” to keep both in sync.
- Strict status validation: removes unknown statuses on edit (use in controlled vocabularies).
### Safeguards
- Vault size limit: disables dashboard and grouped view beyond the threshold.
---
## Experimental Features
### Toggles
- Enable experimental features (master gate).
- Enable status dashboard.
- Enable grouped status view.
- Dashboard/grouped view respect the vault size limit.

View file

@ -1,76 +1,15 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import fs from "fs";
import path from "path";
const banner =
`/*
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
/**
* Automatically generates the predefinedTemplates.ts file by scanning the templates directory.
*/
const generateTemplatesIndex = () => {
const templatesDir = "./templates";
const outputFile = "./constants/predefinedTemplates.ts";
const predefinedDir = path.join(templatesDir, "predefined");
if (!fs.existsSync(templatesDir)) return;
if (!fs.existsSync(predefinedDir)) {
fs.mkdirSync(predefinedDir, { recursive: true });
}
const predefinedTemplates = [];
const communityTemplates = [];
// Read predefined templates
const predefinedFiles = fs.readdirSync(predefinedDir);
predefinedFiles.forEach((file) => {
if (file.endsWith(".json")) {
const filePath = path.join(predefinedDir, file);
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
content.isPredefined = true;
predefinedTemplates.push(content);
}
});
// Read community templates
const rootFiles = fs.readdirSync(templatesDir);
rootFiles.forEach((file) => {
if (file.endsWith(".json")) {
const filePath = path.join(templatesDir, file);
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
content.isPredefined = false;
communityTemplates.push(content);
}
});
const content = `/**
* THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT MANUALLY.
* To add a new predefined template, create a JSON file in the /templates/predefined directory.
* To add a community template, create a JSON file in the /templates directory.
*/
import { StatusTemplate } from "types/pluginSettings";
export const PREDEFINED_TEMPLATES: StatusTemplate[] = ${JSON.stringify(predefinedTemplates, null, "\t")};
export const COMMUNITY_TEMPLATES: StatusTemplate[] = ${JSON.stringify(communityTemplates, null, "\t")};
`;
fs.writeFileSync(outputFile, content);
console.log(
`\x1b[36m[Templates]\x1b[0m Automatically registered ${predefinedTemplates.length} predefined and ${communityTemplates.length} community templates.`,
);
};
// Generate templates index before build
generateTemplatesIndex();
const prod = process.argv[2] === "production";
const prod = (process.argv[2] === "production");
// CSS bundling function
const buildStyles = async () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View file

@ -32,9 +32,7 @@ export class CommandsIntegration {
if (
key === "quickStatusCommands" ||
key === "useMultipleStatuses" ||
key === "templates" ||
key === "enableExperimentalFeatures" ||
key === "enableGroupedStatusView"
key === "templates"
) {
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

View file

@ -5,15 +5,12 @@ import {
Plugin,
TAbstractFile,
TFile,
TFolder,
WorkspaceLeaf,
} from "obsidian";
import {
BaseNoteStatusService,
MultipleNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
export class ContextMenuIntegration {
private static instance: ContextMenuIntegration | null = null;
@ -49,32 +46,33 @@ export class ContextMenuIntegration {
const tFiles = files.filter(
(f): f is TFile => f instanceof TFile,
);
if (!tFiles.length) {
if (tFiles.length) {
this.openMultipleFilesStatusesModal(tFiles);
} else {
new Notice(
"Select at least one file to change its status.",
"The selected files are not valid to add status, just .md files can have status in this plugin version",
);
return;
}
this.openMultipleFilesStatusesModal(tFiles);
});
});
},
),
);
this.plugin.registerEvent(
this.plugin.app.workspace.on("file-menu", (menu, file) => {
if (file instanceof TFile) {
menu.addItem((item) => {
item.setTitle("Change note state")
.setIcon("rotate-ccw")
.onClick(async () => {
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 if (file instanceof TFolder) {
this.addFolderStatusActions(menu, file);
}
} else {
new Notice(
"The selected file is not valid to add status, just .md files can have status in this plugin version",
);
}
});
});
}),
);
@ -86,17 +84,17 @@ export class ContextMenuIntegration {
item.setTitle("Change note status")
.setIcon("rotate-ccw") // Lucide icon
.onClick(async () => {
if (
!view.file ||
!(view.file instanceof TFile)
) {
new Notice(
"The selected item is not a valid file.",
);
return;
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",
);
}
}
this.openSingleFileStatusesModal(view.file);
});
});
},
@ -119,92 +117,6 @@ export class ContextMenuIntegration {
});
}
private addFolderStatusActions(menu: Menu, folder: TFolder) {
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
if (!availableStatuses.length) {
menu.addItem((item) => {
item.setTitle("Apply note status (no statuses available)")
.setIcon("alert-triangle")
.setDisabled(true);
});
return;
}
menu.addItem((item) => {
item.setTitle("Apply note status to folder")
.setIcon("folder-sync")
.onClick(() => {
this.openFolderStatusModal(folder, false);
});
});
if (settingsService.settings.applyStatusRecursivelyToSubfolders) {
menu.addItem((item) => {
item.setTitle("Apply note status to folder and subfolders")
.setIcon("git-merge")
.onClick(() => {
this.openFolderStatusModal(folder, true);
});
});
}
}
private openFolderStatusModal(
folder: TFolder,
includeSubfolders: boolean,
): void {
const files = this.getFilesFromFolder(folder, includeSubfolders);
if (!files.length) {
new Notice("This folder does not contain any files.");
return;
}
const warningThreshold = 50;
if (files.length >= warningThreshold) {
new Notice(
`This folder contains ${files.length} files. Applying status changes may take a while.`,
8000,
);
}
const multiStatusService = new MultipleNoteStatusService(files);
multiStatusService.populateStatuses();
eventBus.publish("triggered-open-modal", {
statusService: multiStatusService,
});
}
private getFilesFromFolder(
folder: TFolder,
includeSubfolders: boolean,
): TFile[] {
const files: TFile[] = [];
const queue: TFolder[] = [folder];
while (queue.length) {
const current = queue.shift();
if (!current) continue;
current.children.forEach((child) => {
if (child instanceof TFile) {
files.push(child);
} else if (includeSubfolders && child instanceof TFolder) {
queue.push(child);
}
});
if (!includeSubfolders) {
break;
}
}
return files;
}
destroy() {
this.currentLeaf = null;
this.noteStatusService = null;

View file

@ -7,11 +7,6 @@ import {
import { NoteStatusService } from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
import { GroupedStatuses } from "@/types/noteStatus";
import {
getPrimaryStatus,
getUnknownStatusColor,
resolveStatusColor,
} from "@/utils/statusColor";
import { Plugin, TFile, View } from "obsidian";
import { createRoot } from "react-dom/client";
import { StatusesInfoPopup } from "../popups/statusesInfoPopupIntegration";
@ -24,17 +19,7 @@ export class FileExplorerIntegration implements IElementProcessor {
private readonly EVENT_SUBSCRIPTION_ID =
"file-explorer-integration-subscription1";
private readonly FILE_EXPLORER_TYPE = "file-explorer";
private readonly NOTEBOOK_NAVIGATOR_TYPE = "notebook-navigator";
private readonly CONTAINER_SELECTOR = ".nav-files-container, .nn-list-pane";
private readonly FILE_NAME_COLORIZED_ATTR = "noteStatusColorized";
private readonly FILE_NAME_ORIGINAL_COLOR_ATTR = "noteStatusOriginalColor";
private readonly FILE_BLOCK_CLASS = "note-status-colored-block";
private readonly FILE_BLOCK_COLOR_VAR = "--note-status-block-color";
private readonly FILE_BORDER_CLASS = "note-status-border-left";
private readonly FILE_BORDER_COLOR_VAR = "--note-status-border-color";
private readonly FILE_UNDERLINE_CLASS = "note-status-underline";
private readonly FILE_UNDERLINE_COLOR_VAR = "--note-status-underline-color";
private readonly STATUS_DOT_CLASS = "note-status-dot-badge";
private readonly CONTAINER_SELECTOR = ".nav-files-container";
constructor(plugin: Plugin) {
this.plugin = plugin;
@ -48,7 +33,7 @@ export class FileExplorerIntegration implements IElementProcessor {
this.setupWorkspaceIntegration();
eventBus.subscribe(
"status-changed",
"frontmatter-manually-changed",
({ file }) => {
const element = this.findFileElement(file.path);
if (!element) return;
@ -69,18 +54,9 @@ export class FileExplorerIntegration implements IElementProcessor {
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "tagPrefix" ||
key === "statusFrontmatterMappings" ||
key === "strictStatuses" ||
key === "fileExplorerIconPosition" ||
key === "fileExplorerIconFrame" ||
key === "fileExplorerIconColorMode" ||
key === "fileExplorerColorFileName" ||
key === "fileExplorerColorBlock" ||
key === "fileExplorerLeftBorder" ||
key === "fileExplorerStatusDot" ||
key === "fileExplorerUnderlineFileName" ||
key === "unknownStatusIcon" ||
key === "unknownStatusLucideIcon" ||
key === "unknownStatusColor" ||
key === "templates"
) {
@ -95,13 +71,18 @@ export class FileExplorerIntegration implements IElementProcessor {
private getFileNoteStatusService(
dataPath: string,
): NoteStatusService | null {
const abstractFile =
this.plugin.app.vault.getAbstractFileByPath(dataPath);
if (!(abstractFile instanceof TFile)) {
if (!dataPath.endsWith(".md")) {
return null;
}
const noteStatusService = new NoteStatusService(abstractFile);
const file = this.plugin.app.vault.getAbstractFileByPath(
dataPath,
) as TFile;
if (!file) {
return null;
}
const noteStatusService = new NoteStatusService(file);
noteStatusService.populateStatuses();
return noteStatusService;
}
@ -115,62 +96,34 @@ export class FileExplorerIntegration implements IElementProcessor {
}
if (dataPath) {
const textEl = element.querySelector(
".nav-file-title-content, .tree-item-inner, .nav-folder-title-content, .nn-file-name",
".nav-file-title-content, .tree-item-inner, .nav-folder-title-content",
);
if (!textEl) {
return;
}
const noteStatusService = this.getFileNoteStatusService(dataPath);
const statuses = noteStatusService?.getStatusesByAllKeys() ?? {};
const primaryStatus = getPrimaryStatus(statuses);
const hasStatus = Boolean(primaryStatus);
const fallbackColor = getUnknownStatusColor();
const statusColor = primaryStatus
? resolveStatusColor(primaryStatus, fallbackColor)
: undefined;
const textElement = textEl as HTMLElement;
const navItem = this.getNavItemElement(textElement);
this.applyFileNameColor(textElement, statusColor, hasStatus);
this.applyFileNameUnderline(textElement, statusColor, hasStatus);
this.applyStatusDot(textElement, statusColor, hasStatus);
this.applyFileBlockColor(navItem, statusColor, hasStatus);
this.applyLeftBorder(navItem, statusColor, hasStatus);
// Only render icons for markdown files
if (noteStatusService) {
this.render(
textEl,
statuses,
settingsService.settings.tagPrefix,
);
this.render(textEl, noteStatusService.statuses);
}
}
}
private getUnknownStatusConfig() {
const settings = settingsService.settings;
return {
icon: settingsService.settings.unknownStatusIcon || "❓",
lucideIcon: settingsService.settings.unknownStatusLucideIcon || "",
color: getUnknownStatusColor(),
icon: settings.unknownStatusIcon || "❓",
color: settings.unknownStatusColor || "#8b949e",
};
}
render(
element: Element,
statuses: GroupedStatuses,
defaultTagName: string,
): void {
// Remove existing icon (it may be a sibling or a child from older renders)
const existingSiblingIcon = element.parentElement?.querySelector(
`.${this.ICON_CLASS}`,
);
if (existingSiblingIcon) {
existingSiblingIcon.remove();
}
const existingChildIcon = element.querySelector(`.${this.ICON_CLASS}`);
if (existingChildIcon) {
existingChildIcon.remove();
render(element: Element, statuses: GroupedStatuses): void {
// Remove existing icon
const existingIcon = element.querySelector(`.${this.ICON_CLASS}`);
if (existingIcon) {
existingIcon.remove();
}
if (!settingsService.settings.showStatusIconsInExplorer) {
@ -185,26 +138,16 @@ export class FileExplorerIntegration implements IElementProcessor {
positionClassName = "custom-icon__absolute-right";
}
const icon = createSpan({ cls: [this.ICON_CLASS, positionClassName] });
icon.setAttribute("contenteditable", "false");
const root = createRoot(icon);
root.render(
<FileExplorerIcon
statuses={statuses}
defaultTagName={defaultTagName}
onMouseEnter={this.openModalInfo}
onMouseEnter={(s) => this.openModalInfo(s)}
onMouseLeave={this.closeModalInfo}
hideUnknownStatus={
settingsService.settings.hideUnknownStatusInExplorer
}
unknownStatusConfig={this.getUnknownStatusConfig()}
iconFrameMode={
settingsService.settings.fileExplorerIconFrame || "never"
}
iconColorMode={
settingsService.settings.fileExplorerIconColorMode ||
"status"
}
/>,
);
@ -212,36 +155,33 @@ export class FileExplorerIntegration implements IElementProcessor {
settingsService.settings.fileExplorerIconPosition ===
"file-name-right"
) {
element.insertAdjacentElement("afterend", icon);
element.append(icon);
} else {
element.insertAdjacentElement("beforebegin", icon);
element.prepend(icon);
}
}
private openModalInfo = ({
statuses,
defaultTagName,
}: {
statuses: GroupedStatuses;
defaultTagName: string;
}) => {
private openModalInfo(statuses: GroupedStatuses) {
if (!this.plugin) {
return;
}
StatusesInfoPopup.open({ statuses, defaultTagName });
};
private closeModalInfo = () => {
StatusesInfoPopup.open(statuses);
}
private closeModalInfo() {
StatusesInfoPopup.close();
};
}
/**
* Cleanup integration and unsubscribe from events
*/
destroy(): void {
this.observerService.cleanup();
eventBus.unsubscribe("status-changed", this.EVENT_SUBSCRIPTION_ID);
eventBus.unsubscribe(
"status-changed",
"frontmatter-manually-changed",
this.EVENT_SUBSCRIPTION_ID,
);
eventBus.unsubscribe(
"frontmatter-manually-changed",
"fileExplorerIntegrationSubscription2",
);
}
@ -268,20 +208,15 @@ export class FileExplorerIntegration implements IElementProcessor {
}
/**
* Finds file element in the file explorer views
* Finds file element in the file explorer
*/
private findFileElement(filePath: string): HTMLElement | null {
const views = this.getFileExplorerViews();
const fileExplorerView = this.getFileExplorerView();
if (!fileExplorerView) return null;
for (const view of views) {
const element = view.containerEl.querySelector(
`[data-path="${filePath}"]`,
) as HTMLElement | null;
if (element) return element;
}
return null;
return fileExplorerView.containerEl.querySelector(
`[data-path="${filePath}"]`,
) as HTMLElement;
}
/**
@ -302,219 +237,34 @@ export class FileExplorerIntegration implements IElementProcessor {
}
/**
* Patches the file explorers with observers
* Patches the file explorer with observers
*/
private patchFileExplorer(): void {
const containers = this.getFileExplorerContainers();
if (containers.length === 0) return;
const container = this.getFileExplorerContainer();
if (!container) return;
this.observerService.setupObservers(containers);
this.observerService.setupObservers(container);
}
/**
* Gets the file explorer views
* Gets the file explorer view
*/
private getFileExplorerViews(): View[] {
const leaves = [
...this.plugin.app.workspace.getLeavesOfType(
this.FILE_EXPLORER_TYPE,
),
...this.plugin.app.workspace.getLeavesOfType(
this.NOTEBOOK_NAVIGATOR_TYPE,
),
];
return leaves.map((leaf) => leaf.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 elements
* Gets the file explorer container element
*/
private getFileExplorerContainers(): Element[] {
const views = this.getFileExplorerViews();
private getFileExplorerContainer(): Element | null {
const fileExplorerView = this.getFileExplorerView();
if (!fileExplorerView) return null;
return views
.map((view) =>
view.containerEl.querySelector(this.CONTAINER_SELECTOR),
)
.filter(Boolean) as Element[];
}
private applyFileNameColor(
element?: HTMLElement | null,
color?: string,
hasStatus?: boolean,
): void {
if (!element) {
return;
}
if (
!settingsService.settings.fileExplorerColorFileName ||
!hasStatus ||
!color
) {
this.clearFileNameColor(element);
return;
}
if (!element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR]) {
element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR] =
element.style.color || "";
}
element.dataset[this.FILE_NAME_COLORIZED_ATTR] = "true";
element.style.color = color;
}
private applyFileBlockColor(
navItem?: HTMLElement | null,
color?: string,
hasStatus?: boolean,
): void {
if (!navItem) {
return;
}
if (
!settingsService.settings.fileExplorerColorBlock ||
!hasStatus ||
!color
) {
this.clearFileBlockColor(navItem);
return;
}
navItem.classList.add(this.FILE_BLOCK_CLASS);
navItem.style.setProperty(this.FILE_BLOCK_COLOR_VAR, color);
}
private applyLeftBorder(
navItem?: HTMLElement | null,
color?: string,
hasStatus?: boolean,
): void {
if (!navItem) {
return;
}
if (
!settingsService.settings.fileExplorerLeftBorder ||
!hasStatus ||
!color
) {
this.clearFileLeftBorder(navItem);
return;
}
navItem.classList.add(this.FILE_BORDER_CLASS);
navItem.style.setProperty(this.FILE_BORDER_COLOR_VAR, color);
}
private applyFileNameUnderline(
element?: HTMLElement | null,
color?: string,
hasStatus?: boolean,
): void {
if (!element) {
return;
}
if (
!settingsService.settings.fileExplorerUnderlineFileName ||
!hasStatus ||
!color
) {
this.clearFileNameUnderline(element);
return;
}
element.classList.add(this.FILE_UNDERLINE_CLASS);
element.style.setProperty(this.FILE_UNDERLINE_COLOR_VAR, color);
}
private applyStatusDot(
element?: HTMLElement | null,
color?: string,
hasStatus?: boolean,
): void {
if (!element) {
return;
}
const existingDot = element.querySelector(
`.${this.STATUS_DOT_CLASS}`,
) as HTMLElement | null;
if (
!settingsService.settings.fileExplorerStatusDot ||
!hasStatus ||
!color
) {
if (existingDot) {
existingDot.remove();
}
return;
}
const dot =
existingDot ||
createSpan({
cls: this.STATUS_DOT_CLASS,
});
dot.setAttribute("aria-hidden", "true");
dot.style.backgroundColor = color;
if (!existingDot) {
element.appendChild(dot);
}
}
private getNavItemElement(
element?: HTMLElement | null,
): HTMLElement | null {
if (!element) {
return null;
}
return element.closest(
".nav-file, .nav-folder, .nn-file",
) as HTMLElement | null;
}
private clearFileNameColor(element: HTMLElement): void {
if (!element.dataset[this.FILE_NAME_COLORIZED_ATTR]) {
return;
}
const previousColor =
element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR] || "";
if (previousColor) {
element.style.color = previousColor;
} else {
element.style.removeProperty("color");
}
delete element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR];
delete element.dataset[this.FILE_NAME_COLORIZED_ATTR];
}
private clearFileBlockColor(navItem?: HTMLElement | null): void {
if (!navItem) {
return;
}
navItem.classList.remove(this.FILE_BLOCK_CLASS);
navItem.style.removeProperty(this.FILE_BLOCK_COLOR_VAR);
}
private clearFileLeftBorder(navItem?: HTMLElement | null): void {
if (!navItem) {
return;
}
navItem.classList.remove(this.FILE_BORDER_CLASS);
navItem.style.removeProperty(this.FILE_BORDER_COLOR_VAR);
}
private clearFileNameUnderline(element: HTMLElement): void {
element.classList.remove(this.FILE_UNDERLINE_CLASS);
element.style.removeProperty(this.FILE_UNDERLINE_COLOR_VAR);
return fileExplorerView.containerEl.querySelector(
this.CONTAINER_SELECTOR,
);
}
}

View file

@ -11,14 +11,11 @@ import {
BaseNoteStatusService,
} from "@/core/noteStatusService";
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus";
export class StatusModalIntegration extends Modal {
private root: Root | null = null;
private static instance: StatusModalIntegration | null = null;
private selectorService: SelectorService;
private activeStatus?: NoteStatus;
constructor(app: App) {
super(app);
@ -27,7 +24,6 @@ export class StatusModalIntegration extends Modal {
static open(
app: App,
noteStatusService: NoteStatusService | MultipleNoteStatusService,
activeStatus?: NoteStatus,
) {
if (StatusModalIntegration.instance) {
throw new Error("Status Modal is already open");
@ -38,7 +34,7 @@ export class StatusModalIntegration extends Modal {
);
eventBus.subscribe(
"status-changed",
"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
@ -48,7 +44,6 @@ export class StatusModalIntegration extends Modal {
"statusModalIntegrationSubscription1",
);
StatusModalIntegration.instance.activeStatus = activeStatus;
StatusModalIntegration.instance.open();
}
@ -121,15 +116,6 @@ export class StatusModalIntegration extends Modal {
this.selectorService.noteStatusService.statuses ?? {}
}
filesQuantity={filesQuantity}
templates={settingsService.settings.templates}
iconFrameMode={
settingsService.settings.fileExplorerIconFrame || "never"
}
iconColorMode={
settingsService.settings.fileExplorerIconColorMode ||
"status"
}
activeStatus={this.activeStatus}
onRemoveStatus={this.onRemoveStatus}
onSelectStatus={this.onSelectStatus}
/>,
@ -146,7 +132,7 @@ export class StatusModalIntegration extends Modal {
StatusModalIntegration.instance = null;
eventBus.unsubscribe(
"status-changed",
"frontmatter-manually-changed",
"statusModalIntegrationSubscription1",
);
}

View file

@ -1,35 +1,20 @@
import { createRoot, Root } from "react-dom/client";
import { GroupedStatuses } from "@/types/noteStatus";
import { StatusFileInfoPopup } from "@/components/StatusFileInfoPopup/StatusFileInfoPopup";
import settingsService from "@/core/settingsService";
export class StatusesInfoPopup {
private root: Root | null = null;
private static instance: StatusesInfoPopup | null = null;
private element: HTMLElement | null = null;
private statuses: GroupedStatuses;
private defaultTagName: string;
private constructor() {}
static open({
statuses,
defaultTagName,
}: {
statuses: GroupedStatuses;
defaultTagName: string;
}) {
// Always ensure previous instance is cleaned up before showing or when disabled
static open(statuses: GroupedStatuses) {
StatusesInfoPopup.close();
const isEnabled =
settingsService.settings?.enableStatusOverviewPopup ?? true;
if (!isEnabled) {
return;
}
StatusesInfoPopup.instance = new StatusesInfoPopup();
StatusesInfoPopup.instance.statuses = statuses;
StatusesInfoPopup.instance.defaultTagName = defaultTagName;
StatusesInfoPopup.instance.show();
}
@ -46,12 +31,7 @@ export class StatusesInfoPopup {
document.body.appendChild(this.element);
this.root = createRoot(this.element);
this.root.render(
<StatusFileInfoPopup
statuses={this.statuses}
defaultTagName={this.defaultTagName}
/>,
);
this.root.render(<StatusFileInfoPopup statuses={this.statuses} />);
}
private destroy() {

View file

@ -1,17 +1,18 @@
import { createRoot, Root } from "react-dom/client";
import eventBus from "core/eventBus";
import { Plugin } from "obsidian";
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";
import { NoteStatus } from "@/types/noteStatus";
export class StatusBarIntegration {
private static instance: StatusBarIntegration | null = null;
private root: Root | null = null;
private plugin: Plugin;
private statusBarContainer: HTMLElement | null = null;
private statusBarContainer: HTMLElement;
private noteStatusService: NoteStatusService | null = null;
private currentLeaf: WorkspaceLeaf | null = null;
constructor(plugin: Plugin) {
if (StatusBarIntegration.instance) {
@ -22,10 +23,16 @@ export class StatusBarIntegration {
}
async integrate() {
this.statusBarContainer = this.plugin.addStatusBarItem();
this.statusBarContainer.classList.add("mod-clickable");
eventBus.subscribe(
"active-file-change",
() => {
this.handleActiveFileChange();
({ leaf }) => {
if (this.isValidMarkdownLeaf(leaf)) {
this.currentLeaf = leaf;
this.handleActiveFileChange().catch(console.error);
}
},
"statusBarIntegrationSubscription1",
);
@ -39,12 +46,6 @@ export class StatusBarIntegration {
if (key === "statusBarShowTemplateName") {
this.render(); // INFO: Force a render to set disabled or enabled
}
if (
key === "statusBarBadgeStyle" ||
key === "statusBarBadgeContentMode"
) {
this.render();
}
if (key === "enabledTemplates" || key === "templates") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
}
@ -57,9 +58,6 @@ export class StatusBarIntegration {
if (key === "tagPrefix") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
}
if (key === "statusFrontmatterMappings") {
this.handleActiveFileChange().catch(console.error);
}
if (key === "useCustomStatusesOnly") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
}
@ -68,7 +66,6 @@ export class StatusBarIntegration {
}
if (
key === "unknownStatusIcon" ||
key === "unknownStatusLucideIcon" ||
key === "unknownStatusColor" ||
key === "statusBarNoStatusText" ||
key === "statusBarShowNoStatusIcon" ||
@ -81,7 +78,7 @@ export class StatusBarIntegration {
);
eventBus.subscribe(
"status-changed",
"frontmatter-manually-changed",
() => {
this.handleActiveFileChange().catch(console.error);
},
@ -89,20 +86,30 @@ export class StatusBarIntegration {
);
}
private async handleActiveFileChange(): Promise<void> {
const file = this.plugin.app.workspace.getActiveFile();
if (!file) {
this.noteStatusService = null;
this.render();
return;
}
this.noteStatusService = new NoteStatusService(file);
this.noteStatusService.populateStatuses();
private async handleActiveFileChange() {
this.extractStatusesFromLeaf(this.currentLeaf);
this.render();
}
private openStatusModal(activeStatus?: NoteStatus) {
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",
@ -110,7 +117,6 @@ export class StatusBarIntegration {
}
eventBus.publish("triggered-open-modal", {
statusService: this.noteStatusService,
activeStatus,
});
}
@ -121,55 +127,38 @@ export class StatusBarIntegration {
showIcon: settings.statusBarShowNoStatusIcon || false,
showText: settings.statusBarShowNoStatusText ?? true,
icon: settings.unknownStatusIcon || "❓",
lucideIcon: settings.unknownStatusLucideIcon || "",
color: settings.unknownStatusColor || "#8b949e",
};
}
private ensureStatusBarItem() {
if (this.statusBarContainer) {
return;
}
this.statusBarContainer = this.plugin.addStatusBarItem();
this.statusBarContainer.classList.add("mod-clickable");
this.statusBarContainer.style.padding = "unset";
this.root = createRoot(this.statusBarContainer);
}
private destroyStatusBarItem() {
if (this.root) {
this.root.unmount();
this.root = null;
}
if (this.statusBarContainer) {
this.statusBarContainer.remove();
this.statusBarContainer = null;
}
}
private render() {
if (!settingsService.settings.showStatusBar) {
this.destroyStatusBarItem();
return;
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
}
templateNameMode={
settingsService.settings.statusBarShowTemplateName
}
onStatusClick={() => this.openStatusModal()}
noStatusConfig={this.getNoStatusConfig()}
/>,
);
}
this.ensureStatusBarItem();
this.root!.render(
<StatusBar
statuses={this.noteStatusService?.statuses || {}}
hideIfNotStatuses={settingsService.settings.autoHideStatusBar}
templateNameMode={
settingsService.settings.statusBarShowTemplateName
}
badgeStyle={settingsService.settings.statusBarBadgeStyle}
badgeContentMode={
settingsService.settings.statusBarBadgeContentMode
}
onStatusClick={(status) => this.openStatusModal(status)}
noStatusConfig={this.getNoStatusConfig()}
/>,
);
}
destroy() {
@ -182,11 +171,15 @@ export class StatusBarIntegration {
"statusBarIntegrationSubscription2",
);
eventBus.unsubscribe(
"status-changed",
"plugin-settings-changed",
"statusBarIntegrationSubscription3",
);
this.destroyStatusBarItem();
if (this.root) {
this.root.unmount();
this.root = null;
}
this.currentLeaf = null;
this.noteStatusService = null;
StatusBarIntegration.instance = null;
}

View file

@ -1,6 +1,6 @@
import { createRoot, Root } from "react-dom/client";
import eventBus from "core/eventBus";
import { MarkdownView, Plugin, WorkspaceLeaf, TFile } from "obsidian";
import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian";
import settingsService from "@/core/settingsService";
import { EditorToolbarButton } from "@/components/Toolbar/EditorToolbarButton";
import { NoteStatusService } from "@/core/noteStatusService";
@ -43,11 +43,6 @@ export class EditorToolbarIntegration {
this.handleLayoutChange();
}),
);
this.plugin.registerEvent(
this.plugin.app.workspace.on("file-open", (file) => {
this.handleFileOpen(file);
}),
);
eventBus.subscribe(
"active-file-change",
@ -71,9 +66,6 @@ export class EditorToolbarIntegration {
if (key === "tagPrefix") {
this.refreshAllButtons();
}
if (key === "statusFrontmatterMappings") {
this.refreshAllButtons();
}
if (key === "useCustomStatusesOnly") {
this.refreshAllButtons();
}
@ -82,7 +74,6 @@ export class EditorToolbarIntegration {
}
if (
key === "unknownStatusIcon" ||
key === "unknownStatusLucideIcon" ||
key === "unknownStatusColor"
) {
this.renderAllButtons();
@ -101,7 +92,7 @@ export class EditorToolbarIntegration {
);
eventBus.subscribe(
"status-changed",
"frontmatter-manually-changed",
({ file }) => {
// Find the button for the specific file and refresh only that one
for (const [leaf, leafButton] of this.leafButtons.entries()) {
@ -129,7 +120,6 @@ export class EditorToolbarIntegration {
private handleActiveLeafChange() {
this.syncButtons();
this.refreshAllButtons();
}
private handleDisplayModeChange() {
@ -140,7 +130,7 @@ export class EditorToolbarIntegration {
const targetLeaves = new Set(this.getTargetLeaves());
// Remove buttons that shouldn't exist
for (const leaf of Array.from(this.leafButtons.keys())) {
for (const leaf of this.leafButtons.keys()) {
if (!targetLeaves.has(leaf)) {
this.removeButtonForLeaf(leaf);
}
@ -158,36 +148,6 @@ export class EditorToolbarIntegration {
this.syncButtons();
}
private handleFileOpen(file: TFile | null) {
if (!file) {
this.refreshAllButtons();
return;
}
let updatedAny = false;
for (const [leaf, leafButton] of this.leafButtons.entries()) {
const markdownView = leaf.view as MarkdownView;
if (markdownView.file?.path !== file.path) {
continue;
}
leafButton.noteStatusService =
this.buildNoteStatusServiceForFile(file);
this.renderButtonForLeaf(leafButton);
updatedAny = true;
}
if (!updatedAny) {
this.refreshAllButtons();
}
}
private buildNoteStatusServiceForFile(file: TFile): NoteStatusService {
const noteStatusService = new NoteStatusService(file);
noteStatusService.populateStatuses();
return noteStatusService;
}
private createButtonForLeaf(leaf: WorkspaceLeaf) {
if (!this.isValidMarkdownLeaf(leaf)) {
return;
@ -203,11 +163,15 @@ export class EditorToolbarIntegration {
return;
}
const noteStatusService = this.buildNoteStatusServiceForLeaf(leaf);
if (!noteStatusService) {
const markdownView = leaf.view as MarkdownView;
if (!markdownView.file) {
return;
}
// Create note status service for this leaf
const noteStatusService = new NoteStatusService(markdownView.file);
noteStatusService.populateStatuses();
// Create button element
const buttonElement = this.createButtonElement(leaf);
if (!buttonElement) {
@ -250,10 +214,6 @@ export class EditorToolbarIntegration {
}
private getTargetLeaves(): WorkspaceLeaf[] {
if (!settingsService.settings.showEditorToolbarButton) {
return [];
}
const leaves: WorkspaceLeaf[] = [];
if (
@ -338,13 +298,12 @@ export class EditorToolbarIntegration {
}
private renderButtonForLeaf(leafButton: LeafButton): void {
const statuses =
leafButton.noteStatusService?.getStatusesByAllKeys() || {};
leafButton.root.render(
<EditorToolbarButton
statuses={statuses}
defaultTagName={settingsService.settings.tagPrefix}
onClick={() => this.openStatusModal(leafButton.leaf)}
statuses={leafButton.noteStatusService?.statuses || {}}
onClick={() =>
this.openStatusModal(leafButton.noteStatusService)
}
unknownStatusConfig={this.getUnknownStatusConfig()}
/>,
);
@ -352,12 +311,14 @@ export class EditorToolbarIntegration {
private refreshAllButtons(): void {
for (const [leaf, leafButton] of this.leafButtons.entries()) {
const noteStatusService = this.buildNoteStatusServiceForLeaf(leaf);
if (!noteStatusService) {
continue;
const markdownView = leaf.view as MarkdownView;
if (markdownView.file) {
leafButton.noteStatusService = new NoteStatusService(
markdownView.file,
);
leafButton.noteStatusService.populateStatuses();
this.renderButtonForLeaf(leafButton);
}
leafButton.noteStatusService = noteStatusService;
this.renderButtonForLeaf(leafButton);
}
}
@ -379,37 +340,21 @@ export class EditorToolbarIntegration {
this.syncButtons();
}
private openStatusModal(leaf: WorkspaceLeaf) {
const noteStatusService = this.buildNoteStatusServiceForLeaf(leaf);
private openStatusModal(noteStatusService: NoteStatusService) {
if (!noteStatusService) {
console.error(
"open status modal failed because there is no markdown file in the provided leaf",
throw new Error(
"open status modal failed because there is no noteStatusService available",
);
return;
}
eventBus.publish("triggered-open-modal", {
statusService: noteStatusService,
});
}
private buildNoteStatusServiceForLeaf(
leaf: WorkspaceLeaf,
): NoteStatusService | null {
if (!this.isValidMarkdownLeaf(leaf)) {
return null;
}
const markdownView = leaf.view as MarkdownView;
if (!markdownView.file) {
return null;
}
return this.buildNoteStatusServiceForFile(markdownView.file);
}
private getUnknownStatusConfig() {
const settings = settingsService.settings;
return {
icon: settings.unknownStatusIcon || "❓",
lucideIcon: settings.unknownStatusLucideIcon || "",
color: settings.unknownStatusColor || "#8b949e",
};
}
@ -424,7 +369,7 @@ export class EditorToolbarIntegration {
"editorToolbarIntegrationSubscription2",
);
eventBus.unsubscribe(
"status-changed",
"frontmatter-manually-changed",
"editorToolbarIntegrationSubscription3",
);

View file

@ -1,13 +1,10 @@
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import { GroupedStatusView as GroupedStatusViewComponent } from "@/components/GroupedStatusView/GroupedStatusView";
import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import { BaseNoteStatusService } from "@/core/noteStatusService";
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus";
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
export const VIEW_TYPE_GROUPED_DASHBOARD = "grouped-dashboard-view";
@ -21,7 +18,6 @@ interface StatusItem {
name: string;
color: string;
icon?: string;
lucideIcon?: string;
}
interface FilesByStatus {
@ -61,19 +57,28 @@ export class GroupedDashboardView extends ItemView {
name: status.name,
color: status.color || "",
icon: status.icon,
lucideIcon: status.lucideIcon,
});
private getAllFiles = (): FileItem[] => {
const tFiles = BaseNoteStatusService.app.vault.getFiles();
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles();
return tFiles.map(this.convertTFileToFileItem);
};
private processFiles = (files: FileItem[]): GroupedByStatus => {
const result: GroupedByStatus = {};
const statusMetadataKeys = getAllFrontmatterKeys();
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] = {};
@ -86,27 +91,41 @@ export class GroupedDashboardView extends ItemView {
});
files.forEach((file) => {
// Find the TFile to get metadata
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
file.path,
) as TFile;
if (!tFile) return;
const noteStatusService = new NoteStatusService(tFile);
noteStatusService.populateStatuses();
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(tFile);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
statusMetadataKeys.forEach((key) => {
const statusesForKey = noteStatusService.getStatusesForKey(key);
if (!statusesForKey.length) return;
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);
}
statusesForKey.forEach((status) => {
const statusKey = status.templateId
? `${status.templateId}:${status.name}`
: status.name;
if (!result[key][statusKey]) {
result[key][statusKey] = [];
}
result[key][statusKey].push(file);
});
if (resolvedStatus) {
const statusKey = resolvedStatus.templateId
? `${resolvedStatus.templateId}:${resolvedStatus.name}`
: resolvedStatus.name;
if (!result[key][statusKey]) {
result[key][statusKey] = [];
}
result[key][statusKey].push(file);
}
});
}
});
});
@ -134,7 +153,7 @@ export class GroupedDashboardView extends ItemView {
private subscribeToEvents = (onDataChange: () => void) => {
eventBus.subscribe(
"status-changed",
"frontmatter-manually-changed",
onDataChange,
"grouped-dashboard-view-subscription",
);
@ -147,7 +166,6 @@ export class GroupedDashboardView extends ItemView {
key === "useCustomStatusesOnly" ||
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "statusFrontmatterMappings" ||
key === "strictStatuses"
) {
onDataChange();
@ -162,7 +180,7 @@ export class GroupedDashboardView extends ItemView {
return () => {
eventBus.unsubscribe(
"status-changed",
"frontmatter-manually-changed",
"grouped-dashboard-view-subscription",
);
eventBus.unsubscribe(

View file

@ -6,14 +6,10 @@ import {
GroupedStatusView as GroupedStatusViewComponent,
StatusItem,
} from "@/components/GroupedStatusView/GroupedStatusView";
import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import { BaseNoteStatusService } from "@/core/noteStatusService";
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus";
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
export const VIEW_TYPE_EXAMPLE = "grouped-status-view";
@ -46,19 +42,28 @@ export class GroupedStatusView extends ItemView {
name: status.name,
color: status.color || "white",
icon: status.icon,
lucideIcon: status.lucideIcon,
});
private getAllFiles = (): FileItem[] => {
const tFiles = BaseNoteStatusService.app.vault.getFiles();
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles();
return tFiles.map(this.convertTFileToFileItem);
};
private processFiles = (files: FileItem[]): GroupedByStatus => {
const result: GroupedByStatus = {};
const statusMetadataKeys = getAllFrontmatterKeys();
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] = {};
@ -71,27 +76,41 @@ export class GroupedStatusView extends ItemView {
});
files.forEach((file) => {
// Find the TFile to get metadata
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
file.path,
) as TFile;
if (!tFile) return;
const noteStatusService = new NoteStatusService(tFile);
noteStatusService.populateStatuses();
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(tFile);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
statusMetadataKeys.forEach((key) => {
const statusesForKey = noteStatusService.getStatusesForKey(key);
if (!statusesForKey.length) return;
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);
}
statusesForKey.forEach((status) => {
const statusKey = status.templateId
? `${status.templateId}:${status.name}`
: status.name;
if (!result[key][statusKey]) {
result[key][statusKey] = [];
}
result[key][statusKey].push(file);
});
if (resolvedStatus) {
const statusKey = resolvedStatus.templateId
? `${resolvedStatus.templateId}:${resolvedStatus.name}`
: resolvedStatus.name;
if (!result[key][statusKey]) {
result[key][statusKey] = [];
}
result[key][statusKey].push(file);
}
});
}
});
});
@ -119,7 +138,7 @@ export class GroupedStatusView extends ItemView {
private subscribeToEvents = (onDataChange: () => void) => {
eventBus.subscribe(
"status-changed",
"frontmatter-manually-changed",
onDataChange,
"grouped-status-view-subscription",
);
@ -132,7 +151,6 @@ export class GroupedStatusView extends ItemView {
key === "useCustomStatusesOnly" ||
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "statusFrontmatterMappings" ||
key === "strictStatuses" ||
key === "vaultSizeLimit"
) {
@ -148,7 +166,7 @@ export class GroupedStatusView extends ItemView {
return () => {
eventBus.unsubscribe(
"status-changed",
"frontmatter-manually-changed",
"grouped-status-view-subscription",
);
eventBus.unsubscribe(
@ -165,14 +183,14 @@ export class GroupedStatusView extends ItemView {
container.addClass("grouped-status-view-container");
// Check if vault exceeds the size limit
const files = BaseNoteStatusService.app.vault.getFiles();
const files = BaseNoteStatusService.app.vault.getMarkdownFiles();
const vaultSizeLimit = settingsService.settings.vaultSizeLimit || 15000;
if (vaultSizeLimit > 0 && files.length > vaultSizeLimit) {
// Show disabled message
container.createEl("div", {
cls: "grouped-status-view-disabled",
text: `Grouped Status View disabled: Vault has ${files.length} files (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`,
text: `Grouped Status View disabled: Vault has ${files.length} notes (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`,
});
return;
}

View file

@ -10,7 +10,6 @@ import settingsService from "@/core/settingsService";
import eventBus from "@/core/eventBus";
import { VaultStats } from "@/components/StatusDashboard/useVaultStats";
import { NoteStatus } from "@/types/noteStatus";
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
interface AppWithCommands extends App {
commands: {
@ -59,10 +58,10 @@ export class StatusDashboardView extends ItemView {
}
private calculateVaultStats = (): VaultStats => {
const files = this.app.vault.getFiles();
const files = this.app.vault.getMarkdownFiles();
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const statusMetadataKeys = getAllFrontmatterKeys();
const statusMetadataKeys = [settingsService.settings.tagPrefix];
let notesWithStatus = 0;
const statusDistribution: Record<string, number> = {};
@ -81,26 +80,49 @@ export class StatusDashboardView extends ItemView {
});
files.forEach((file) => {
const noteStatusService = new NoteStatusService(file);
noteStatusService.populateStatuses();
const cachedMetadata = this.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
let hasAnyStatus = false;
statusMetadataKeys.forEach((key) => {
const statuses = noteStatusService.getStatusesForKey(key);
if (!statuses.length) return;
const value = frontmatter[key];
if (value) {
hasAnyStatus = true;
tagDistribution[key]++;
hasAnyStatus = true;
tagDistribution[key]++;
const statusNames = Array.isArray(value) ? value : [value];
statusNames.forEach((statusName) => {
const statusStr = statusName.toString();
statuses.forEach((status) => {
const scopedIdentifier = status.templateId
? `${status.templateId}:${status.name}`
: status.name;
if (!statusDistribution.hasOwnProperty(scopedIdentifier)) {
statusDistribution[scopedIdentifier] = 0;
}
statusDistribution[scopedIdentifier]++;
});
// 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) {
@ -128,19 +150,10 @@ export class StatusDashboardView extends ItemView {
const noteStatusService = new NoteStatusService(activeFile);
noteStatusService.populateStatuses();
const statusMetadataKeys = getAllFrontmatterKeys();
const statusesByKey: Record<string, NoteStatus[]> = {};
statusMetadataKeys.forEach((key) => {
const statuses = noteStatusService.getStatusesForKey(key);
if (statuses.length) {
statusesByKey[key] = statuses;
}
});
this.currentNote = {
file: activeFile,
statuses: statusesByKey,
statuses: noteStatusService.statuses,
lastModified: activeFile.stat.mtime,
};
this.renderDashboard();
@ -234,19 +247,12 @@ export class StatusDashboardView extends ItemView {
private findUnassignedNotes() {
const files = this.app.vault.getMarkdownFiles();
const statusKeys = getAllFrontmatterKeys();
const filesWithoutStatus = files.filter((file) => {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return true;
return !statusKeys.some((key) => {
const value = frontmatter[key];
if (Array.isArray(value)) {
return value.length > 0;
}
return Boolean(value);
});
return (
!frontmatter || !frontmatter[settingsService.settings.tagPrefix]
);
});
if (filesWithoutStatus.length === 0) {
@ -259,7 +265,8 @@ export class StatusDashboardView extends ItemView {
);
// Create a search query to find files without the status tag
const query = statusKeys.map((key) => `-[${key}:]`).join(" ");
const tagPrefix = settingsService.settings.tagPrefix;
const query = `-[${tagPrefix}:]`;
// @ts-ignore
this.app.internalPlugins.plugins[
@ -268,10 +275,8 @@ export class StatusDashboardView extends ItemView {
}
private searchBySpecificStatus(statusName: string) {
const keys = getAllFrontmatterKeys();
const query = keys
.map((key) => `[${key}:"${statusName}"]`)
.join(" OR ");
const tagPrefix = settingsService.settings.tagPrefix;
const query = `[${tagPrefix}:"${statusName}"]`;
// @ts-ignore
this.app.internalPlugins.plugins[
@ -300,14 +305,14 @@ export class StatusDashboardView extends ItemView {
container.addClass("status-dashboard-view-container");
// Check if vault exceeds the size limit
const files = this.app.vault.getFiles();
const files = this.app.vault.getMarkdownFiles();
const vaultSizeLimit = settingsService.settings.vaultSizeLimit || 15000;
if (vaultSizeLimit > 0 && files.length > vaultSizeLimit) {
// Show disabled message
container.createEl("div", {
cls: "status-dashboard-disabled",
text: `Dashboard disabled: Vault has ${files.length} files (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`,
text: `Dashboard disabled: Vault has ${files.length} notes (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`,
});
return;
}
@ -324,7 +329,7 @@ export class StatusDashboardView extends ItemView {
};
eventBus.subscribe(
"status-changed",
"frontmatter-manually-changed",
handleVaultChange,
"status-dashboard-vault-subscription",
);
@ -343,7 +348,6 @@ export class StatusDashboardView extends ItemView {
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "strictStatuses" ||
key === "statusFrontmatterMappings" ||
key === "vaultSizeLimit"
) {
handleVaultChange();
@ -362,7 +366,7 @@ export class StatusDashboardView extends ItemView {
async onClose() {
// Clean up event listeners
eventBus.unsubscribe(
"status-changed",
"frontmatter-manually-changed",
"status-dashboard-vault-subscription",
);
eventBus.unsubscribe(

157
main.tsx
View file

@ -1,12 +1,9 @@
import { Plugin, WorkspaceLeaf, TFile } from "obsidian";
import { Plugin, WorkspaceLeaf } from "obsidian";
import StatusBarIntegration from "integrations/status-bar/status-bar";
import eventBus from "core/eventBus";
import { PluginSettingIntegration } from "./integrations/settings/pluginSettings";
import settingsService from "./core/settingsService";
import {
BaseNoteStatusService,
NoteStatusService,
} from "./core/noteStatusService";
import { BaseNoteStatusService } from "./core/noteStatusService";
import { StatusModalIntegration } from "./integrations/modals/statusModalIntegration";
import ContextMenuIntegration from "./integrations/context-menu/contextMenuIntegration";
import { FileExplorerIntegration } from "./integrations/file-explorer/file-explorer-integration";
@ -20,9 +17,6 @@ import {
StatusDashboardView,
VIEW_TYPE_STATUS_DASHBOARD,
} from "./integrations/views/status-dashboard-view";
import { StatusesInfoPopup } from "./integrations/popups/statusesInfoPopupIntegration";
import statusStoreManager from "./core/statusStoreManager";
import { isExperimentalFeatureEnabled } from "@/utils/experimentalFeatures";
export default class NoteStatusPlugin extends Plugin {
private statusBarIntegration: StatusBarIntegration;
@ -31,12 +25,10 @@ export default class NoteStatusPlugin extends Plugin {
private fileExplorerIntegration: FileExplorerIntegration;
private commandsIntegration: CommandsIntegration;
private editorToolbarIntegration: EditorToolbarIntegration;
private experimentalRibbonShortcuts: Map<string, HTMLElement> = new Map();
async onload() {
BaseNoteStatusService.initialize(this.app);
await this.loadPluginSettings();
await statusStoreManager.initialize(this);
// INFO: initialize all integrations
Promise.all([
@ -58,7 +50,13 @@ export default class NoteStatusPlugin extends Plugin {
(leaf) => new StatusDashboardView(leaf),
);
this.syncExperimentalFeatureShortcuts();
this.addRibbonIcon("list-tree", "Open grouped status view", () => {
this.activateView();
});
this.addRibbonIcon("activity", "Open status dashboard", () => {
this.activateDashboard();
});
}
async onunload() {
@ -69,18 +67,12 @@ export default class NoteStatusPlugin extends Plugin {
this.commandsIntegration?.destroy();
this.editorToolbarIntegration?.destroy();
this.pluginSettingsIntegration?.destroy();
this.experimentalRibbonShortcuts.forEach((el) => el.remove());
this.experimentalRibbonShortcuts.clear();
// Clean up event subscriptions
eventBus.unsubscribe(
"triggered-open-modal",
"main-triggered-open-modal-subscriptor",
);
eventBus.unsubscribe(
"plugin-settings-changed",
"main-status-popup-setting-subscriptor",
);
}
async activateView() {
@ -149,58 +141,6 @@ export default class NoteStatusPlugin extends Plugin {
}
}
private syncExperimentalFeatureShortcuts(): void {
const shortcuts = this.getExperimentalShortcuts();
Object.entries(shortcuts).forEach(([key, config]) => {
this.toggleExperimentalRibbon(key, config);
});
}
private getExperimentalShortcuts() {
return {
groupedStatusView: {
feature: "groupedStatusView" as const,
icon: "list-tree",
title: "Open grouped status view",
handler: () => this.activateView(),
},
statusDashboard: {
feature: "statusDashboard" as const,
icon: "activity",
title: "Open status dashboard",
handler: () => this.activateDashboard(),
},
};
}
private toggleExperimentalRibbon(
key: string,
config: {
feature: Parameters<typeof isExperimentalFeatureEnabled>[0];
icon: string;
title: string;
handler: () => void;
},
): void {
const isEnabled = isExperimentalFeatureEnabled(config.feature);
const existingIcon = this.experimentalRibbonShortcuts.get(key);
if (isEnabled && !existingIcon) {
const ribbonEl = this.addRibbonIcon(
config.icon,
config.title,
config.handler,
);
this.experimentalRibbonShortcuts.set(key, ribbonEl);
return;
}
if (!isEnabled && existingIcon) {
existingIcon.remove();
this.experimentalRibbonShortcuts.delete(key);
}
}
private async loadEventBus() {
// Propagate to custom event bus the new active file
this.app.workspace.on(
@ -213,91 +153,18 @@ export default class NoteStatusPlugin extends Plugin {
// Propagate to custom event bus the manually frontmatter data
this.registerEvent(
this.app.metadataCache.on("changed", (file) => {
eventBus.publish("status-changed", { file });
eventBus.publish("frontmatter-manually-changed", { file });
}),
);
// Register listeners
eventBus.subscribe(
"triggered-open-modal",
({ statusService, activeStatus }) => {
StatusModalIntegration.open(
this.app,
statusService,
activeStatus,
);
({ statusService }) => {
StatusModalIntegration.open(this.app, statusService);
},
"main-triggered-open-modal-subscriptor",
);
eventBus.subscribe(
"plugin-settings-changed",
({ key, value }) => {
if (key === "enableStatusOverviewPopup" && value === false) {
StatusesInfoPopup.close();
}
if (
key === "enableExperimentalFeatures" ||
key === "enableStatusDashboard" ||
key === "enableGroupedStatusView"
) {
this.syncExperimentalFeatureShortcuts();
}
},
"main-status-popup-setting-subscriptor",
);
this.registerEvent(
this.app.vault.on("create", async (file) => {
if (
file instanceof TFile &&
file.extension === "md" &&
settingsService.settings.defaultStatusForNewNotes
) {
// Wait a bit to ensure metadata is processed or templates are applied
setTimeout(async () => {
// Ensure file still exists
if (!this.app.vault.getAbstractFileByPath(file.path)) {
return;
}
const noteStatusService = new NoteStatusService(file);
// Populate statuses to see if it already has any
noteStatusService.populateStatuses();
const groupedStatuses =
noteStatusService.getStatusesByAllKeys();
const hasStatuses = Object.values(groupedStatuses).some(
(statuses) => statuses.length > 0,
);
if (!hasStatuses) {
await noteStatusService.addStatus(
settingsService.settings.tagPrefix,
settingsService.settings
.defaultStatusForNewNotes as string,
);
}
}, 500); // Increased delay to allow more time for templates
}
}),
);
this.registerEvent(
this.app.vault.on("rename", (file) => {
if (file instanceof TFile) {
eventBus.publish("status-changed", { file });
}
}),
);
this.registerEvent(
this.app.vault.on("delete", (file) => {
if (file instanceof TFile) {
eventBus.publish("status-changed", { file });
}
}),
);
}
async loadPluginSettings() {

View file

@ -1,19 +1,19 @@
{
"id": "note-status",
"name": "Note Status",
"version": "3.1.2",
"version": "2.2.0",
"minAppVersion": "0.15.0",
"description": "Track and change note statuses across your vault with templates, bulk folder actions, dashboards, and non-Markdown support.",
"description": "Enhance your note organization with a status management system. Assign and track statuses (active, on hold, completed, dropped) for your notes with a customizable interface including status pane, dropdown, and file explorer integration.",
"author": "Aleix Soler",
"authorUrl": "https://aleixsoler.com",
"fundingUrl": {
"PayPal": "https://paypal.me/aleixsoler",
"Buy Me a Coffee": "https://buymeacoffee.com/aleixsoler"
"PayPal": "https://paypal.me/aleixsoler"
},
"isDesktopOnly": false,
"license": "MIT",
"icon": "tag",
"repository": "https://github.com/devonthesofa/obsidian-note-status",
"issues": "https://github.com/devonthesofa/obsidian-note-status/issues",
"releaseNotes": "Initial release with status management, customizable UI, and file explorer integration.",
"css": "styles.css"
}

File diff suppressed because it is too large Load diff

1420
styles.css

File diff suppressed because one or more lines are too long

View file

@ -1,62 +0,0 @@
/* ==========================================================================
Change Status Modal
========================================================================== */
.status-selector-group {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
outline: none;
}
.status-selector-group:focus-visible {
outline: 2px solid var(--background-modifier-border-hover);
outline-offset: 4px;
}
.note-status-template-tabs {
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
border: 1px solid var(--background-modifier-border-hover);
border-radius: var(--radius-m);
background: var(--background-secondary-alt);
margin-bottom: var(--size-4-3);
overflow-x: auto;
}
.note-status-template-tab {
border: none;
background: transparent;
color: var(--text-muted);
padding: var(--size-2-2) var(--size-4-2);
border-radius: var(--radius-s);
cursor: pointer;
font-weight: var(--font-medium);
font-size: var(--font-ui-small);
transition: all var(--anim-duration-fast) ease;
white-space: nowrap;
flex: 1;
text-align: center;
}
.note-status-template-tab:not(.is-active):hover {
background: var(--background-modifier-hover);
color: var(--text-on-accent);
}
.note-status-template-tab.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.1),
0 0 0 1px var(--background-modifier-border);
}
.note-status-template-tab:focus-visible {
outline: none;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.1),
0 0 0 2px var(--interactive-accent) inset;
}

View file

@ -11,62 +11,6 @@
right: 0;
}
.note-status-colored-block {
position: relative;
border-radius: var(--radius-s);
z-index: 0;
}
.note-status-colored-block::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background-color: var(--note-status-block-color, transparent);
opacity: 0.18;
pointer-events: none;
z-index: -1;
transition:
background-color var(--anim-duration-fast) ease,
opacity var(--anim-duration-fast) ease;
}
.note-status-border-left {
position: relative;
}
.note-status-border-left::after {
content: "";
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 3px;
border-radius: var(--radius-s);
background-color: var(--note-status-border-color, transparent);
opacity: 0.35;
pointer-events: none;
}
.note-status-underline {
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--note-status-underline-color, currentColor);
text-underline-offset: 3px;
}
.note-status-dot-badge {
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: var(--size-2-1);
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 1px var(--background-modifier-border);
flex-shrink: 0;
}
.status-wrapper {
position: relative;
display: inline-block;
@ -90,17 +34,6 @@
transform: scale(1.05);
}
.status-minimal--compact {
margin-left: 0;
padding: 0;
background: transparent;
gap: var(--size-2-1);
}
.status-minimal--compact .status-minimal__icon {
font-size: var(--font-ui-medium);
}
/* Use data attribute for dynamic colors */
.status-minimal[data-status-color] .status-minimal__count {
background: var(--interactive-accent);
@ -109,9 +42,6 @@
.status-minimal__icon {
font-size: 11px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
filter: grayscale(0.2);
transition: filter var(--anim-duration-fast) ease;
}
@ -172,54 +102,6 @@
color: var(--text-muted);
font-style: italic;
}
.status-tag-grid {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.status-tag-card {
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-2);
background: var(--background-primary-alt);
box-shadow: var(--shadow-xs);
}
.status-tag-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-2-2);
}
.status-tag-card__title {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
}
.status-tag-card__label {
font-weight: var(--font-semibold);
text-transform: none;
}
.status-tag-card__badge {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
background: var(--background-modifier-border);
padding: 2px 6px;
border-radius: var(--radius-s);
width: fit-content;
}
.status-tag-card__count {
font-size: var(--font-ui-small);
color: var(--text-muted);
background: var(--background-modifier-border);
padding: 2px 10px;
border-radius: var(--radius-s);
font-weight: var(--font-medium);
}
.status-tag-card__statuses {
display: flex;
flex-wrap: wrap;
gap: var(--size-2-2);
}
/* Status Groups in Popup */
.status-group {

View file

@ -1,47 +0,0 @@
/* ==========================================================================
Shared Form Controls
========================================================================== */
.note-status-input {
width: 100%;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background: var(--background-primary);
color: var(--text-normal);
font-size: var(--font-ui-small);
padding: var(--size-2-2) var(--size-2-3);
transition:
border-color var(--anim-duration-fast) ease,
box-shadow var(--anim-duration-fast) ease;
}
.note-status-input:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--background-modifier-accent);
}
.note-status-input--search {
padding-left: var(--size-4-1);
}
.note-status-input--color {
width: 36px;
height: 32px;
padding: var(--size-2-1);
cursor: pointer;
}
.note-status-input--color::-webkit-color-swatch {
border: none;
border-radius: var(--radius-s);
}
.note-status-input--color::-webkit-color-swatch-wrapper {
padding: 0;
}
.note-status-input:disabled {
opacity: 0.65;
cursor: not-allowed;
}

View file

@ -1,84 +0,0 @@
.status-mapping-list {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
width: 100%;
}
.status-mapping-row {
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-3);
background: var(--background-primary-alt);
display: flex;
flex-direction: column;
gap: var(--size-2-2);
}
.status-mapping-empty {
padding: var(--size-4-3);
text-align: center;
color: var(--text-muted);
font-style: italic;
border: 1px dashed var(--background-modifier-border);
border-radius: var(--radius-m);
}
.status-mapping-row__fields {
display: flex;
flex-wrap: wrap;
gap: var(--size-4-2);
width: 100%;
}
.status-mapping-field {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
flex: 1 1 180px;
}
.status-mapping-field label {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.status-mapping-field small {
color: var(--text-faint);
}
.status-mapping-field select,
.status-mapping-field input {
width: 100%;
}
.status-mapping-row__actions {
display: flex;
justify-content: flex-end;
}
.status-mapping-remove-btn {
background: var(--background-modifier-error);
color: var(--text-on-accent);
border: none;
border-radius: var(--radius-s);
padding: 4px 10px;
cursor: pointer;
}
.status-mapping-remove-btn:hover {
opacity: 0.9;
}
.status-mapping-add-btn {
align-self: flex-start;
padding: var(--size-2-2) var(--size-4-2);
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
background: var(--interactive-normal);
cursor: pointer;
}
.status-mapping-add-btn:hover {
background: var(--interactive-hover);
}

View file

@ -78,18 +78,25 @@
/* Status Group */
.grouped-status-group {
margin-bottom: 2px;
margin-bottom: var(--size-4-2);
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow var(--anim-duration-fast) ease;
}
.grouped-status-group:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.grouped-status-group__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
padding: var(--size-4-2) var(--size-4-3);
cursor: pointer;
border-radius: var(--radius-s);
transition: background var(--anim-duration-fast) ease;
position: relative;
}
@ -98,8 +105,8 @@
background: var(--background-modifier-hover);
}
.grouped-status-group__header:focus-visible {
outline: 2px solid var(--interactive-accent);
.grouped-status-group__header:focus {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
@ -131,46 +138,40 @@
/* Files List */
.grouped-status-files {
margin-bottom: var(--size-2-2);
background: var(--background-secondary);
border-top: 1px solid var(--background-modifier-border);
}
.grouped-status-files-list {
max-height: 400px;
max-height: 300px;
overflow-y: auto;
}
.grouped-status-file-item {
padding: var(--size-2-3) var(--size-4-3);
cursor: pointer;
transition: background var(--anim-duration-fast) ease;
border-radius: var(--radius-s);
margin-top: 1px;
border-bottom: 1px solid var(--background-modifier-border-hover);
}
.grouped-status-file-item:hover .tree-item-self {
.grouped-status-file-item:hover {
background: var(--background-modifier-hover);
}
.grouped-status-file-content {
display: flex;
flex-direction: column;
width: 100%;
.grouped-status-file-item:last-child {
border-bottom: none;
}
.grouped-status-file-name {
font-weight: var(--font-medium);
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.grouped-status-file-path {
font-size: var(--font-ui-smaller);
font-size: var(--font-ui-small);
color: var(--text-muted);
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Load More */

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