mirror of
https://github.com/devonthesofa/obsidian-note-status.git
synced 2026-07-22 12:30:24 +00:00
Merge pull request #36 from devonthesofa/refactor/welcome-react
[Refactor] Migrate from Vanilla JS to React with UI improvements and new features
This commit is contained in:
commit
75ebd05d15
132 changed files with 9822 additions and 6210 deletions
1
.obsidian/app.json
vendored
Normal file
1
.obsidian/app.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
1
.obsidian/appearance.json
vendored
Normal file
1
.obsidian/appearance.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
31
.obsidian/core-plugins.json
vendored
Normal file
31
.obsidian/core-plugins.json
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"file-explorer": true,
|
||||
"global-search": true,
|
||||
"switcher": true,
|
||||
"graph": true,
|
||||
"backlink": true,
|
||||
"canvas": true,
|
||||
"outgoing-link": true,
|
||||
"tag-pane": true,
|
||||
"properties": false,
|
||||
"page-preview": true,
|
||||
"daily-notes": true,
|
||||
"templates": true,
|
||||
"note-composer": true,
|
||||
"command-palette": true,
|
||||
"slash-command": false,
|
||||
"editor-status": true,
|
||||
"bookmarks": true,
|
||||
"markdown-importer": false,
|
||||
"zk-prefixer": false,
|
||||
"random-note": false,
|
||||
"outline": true,
|
||||
"word-count": true,
|
||||
"slides": false,
|
||||
"audio-recorder": false,
|
||||
"workspaces": false,
|
||||
"file-recovery": true,
|
||||
"publish": false,
|
||||
"sync": true,
|
||||
"webviewer": false
|
||||
}
|
||||
191
.obsidian/workspace.json
vendored
Normal file
191
.obsidian/workspace.json
vendored
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
{
|
||||
"main": {
|
||||
"id": "886965717aacb6d0",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "9481425cee5d18ea",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "1eb2e9c2c530a169",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "README.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "README"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "87a5f93795884bb0",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "empty",
|
||||
"state": {},
|
||||
"icon": "lucide-file",
|
||||
"title": "New tab"
|
||||
}
|
||||
}
|
||||
],
|
||||
"currentTab": 1
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
},
|
||||
"left": {
|
||||
"id": "580f4711e49ff790",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "2b68b0e26714f9db",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "9a2de93776a5f52a",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical",
|
||||
"autoReveal": true
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
"title": "Files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c1f9ccb674013288",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical"
|
||||
},
|
||||
"icon": "lucide-search",
|
||||
"title": "Search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1d6b9ea734b0a656",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "bookmarks",
|
||||
"state": {},
|
||||
"icon": "lucide-bookmark",
|
||||
"title": "Bookmarks"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300
|
||||
},
|
||||
"right": {
|
||||
"id": "267e362ff570d883",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "beba4aa22886cb5b",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "1f3a1273479a0100",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
"showSearch": false,
|
||||
"searchQuery": "",
|
||||
"backlinkCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "Backlinks"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a50a231b68e4c2c8",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "Outgoing links"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "760ee1f1f13bf340",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "tag",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"useHierarchy": true,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-tags",
|
||||
"title": "Tags"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e8291e1c7436943b",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Outline"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300
|
||||
},
|
||||
"left-ribbon": {
|
||||
"hiddenItems": {
|
||||
"switcher:Open quick switcher": false,
|
||||
"graph:Open graph view": false,
|
||||
"canvas:Create new canvas": false,
|
||||
"daily-notes:Open today's daily note": false,
|
||||
"templates:Insert template": false,
|
||||
"command-palette:Open command palette": false
|
||||
}
|
||||
},
|
||||
"active": "87a5f93795884bb0",
|
||||
"lastOpenFiles": [
|
||||
"README.md",
|
||||
"wiki/User Manual.md",
|
||||
"wiki/Quick Start Guide.md",
|
||||
"wiki/Performance Tuning.md",
|
||||
"wiki/Home Page.md",
|
||||
"wiki/Frontmatter Format.md",
|
||||
"wiki/Development Setup.md",
|
||||
"wiki/Configuration Guide.md",
|
||||
"wiki/Architecture Overview.md",
|
||||
"wiki"
|
||||
]
|
||||
}
|
||||
58
components/ChangeStatusModal/ChangeStatusModal.tsx
Normal file
58
components/ChangeStatusModal/ChangeStatusModal.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from "react";
|
||||
import { GroupedStatuses, NoteStatus } from "@/types/noteStatus";
|
||||
import {
|
||||
StatusSelectorGroup,
|
||||
Props as StatusSelectorGroupProps,
|
||||
} from "./StatusSelectorGroup";
|
||||
|
||||
export interface Props {
|
||||
currentStatuses: GroupedStatuses;
|
||||
filesQuantity: number;
|
||||
availableStatuses: NoteStatus[];
|
||||
onRemoveStatus: (
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
) => Promise<void>;
|
||||
onSelectStatus: (
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ChangeStatusModal: React.FC<Props> = ({
|
||||
currentStatuses: initialStatuses,
|
||||
filesQuantity,
|
||||
availableStatuses,
|
||||
onRemoveStatus,
|
||||
onSelectStatus,
|
||||
}) => {
|
||||
const currentStatuses = Object.entries(initialStatuses);
|
||||
|
||||
const handleSelectedState: StatusSelectorGroupProps["onSelectedState"] = (
|
||||
frontmatterTagName,
|
||||
status,
|
||||
action,
|
||||
) => {
|
||||
if (action === "select") {
|
||||
onSelectStatus(frontmatterTagName, status);
|
||||
} else {
|
||||
onRemoveStatus(frontmatterTagName, status);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Change note status {filesQuantity}</h1>
|
||||
|
||||
{currentStatuses.map(([frontmatterTagName, statusList]) => (
|
||||
<StatusSelectorGroup
|
||||
key={frontmatterTagName}
|
||||
frontmatterTagName={frontmatterTagName}
|
||||
availableStatuses={availableStatuses}
|
||||
currentStatuses={statusList}
|
||||
onSelectedState={handleSelectedState}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
39
components/ChangeStatusModal/CurrentStatusChips.tsx
Normal file
39
components/ChangeStatusModal/CurrentStatusChips.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { StatusDisplay } from "../atoms/StatusDisplay";
|
||||
import { SettingItem } from "../SettingsUI.tsx/SettingItem";
|
||||
|
||||
interface Props {
|
||||
currentStatuses: NoteStatus[];
|
||||
onRemoveStatus: (status: NoteStatus) => void;
|
||||
}
|
||||
|
||||
export const CurrentStatusChips: React.FC<Props> = ({
|
||||
currentStatuses,
|
||||
onRemoveStatus,
|
||||
}) => {
|
||||
return (
|
||||
<SettingItem name="Available statuses" vertical>
|
||||
<div
|
||||
className="note-status-chips"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "6px",
|
||||
minHeight: "32px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{currentStatuses.map((status) => (
|
||||
<StatusDisplay
|
||||
key={`${status.templateId || "custom"}:${status.name}`}
|
||||
status={status}
|
||||
variant="chip"
|
||||
removable
|
||||
onRemove={() => onRemoveStatus(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
};
|
||||
102
components/ChangeStatusModal/StatusSelectorGroup.tsx
Normal file
102
components/ChangeStatusModal/StatusSelectorGroup.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { SearchFilter } from "../atoms/SearchFilter";
|
||||
import { StatusSelector } from "../atoms/StatusSelector";
|
||||
import { SettingItem } from "../SettingsUI.tsx/SettingItem";
|
||||
import { CurrentStatusChips } from "./CurrentStatusChips";
|
||||
import { useKeyboardNavigation } from "./useKeyboardNavigation";
|
||||
|
||||
export interface Props {
|
||||
frontmatterTagName: string;
|
||||
currentStatuses: NoteStatus[];
|
||||
availableStatuses: NoteStatus[];
|
||||
onSelectedState: (
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
action: "select" | "unselected",
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const StatusSelectorGroup: React.FC<Props> = ({
|
||||
currentStatuses,
|
||||
availableStatuses,
|
||||
frontmatterTagName,
|
||||
onSelectedState,
|
||||
}) => {
|
||||
const handleRemoveStatus = useCallback(
|
||||
(status: NoteStatus) => {
|
||||
onSelectedState(frontmatterTagName, status, "unselected");
|
||||
},
|
||||
[onSelectedState, frontmatterTagName],
|
||||
);
|
||||
|
||||
const handleSelectStatus = useCallback(
|
||||
(status: NoteStatus) => {
|
||||
onSelectedState(frontmatterTagName, status, "select");
|
||||
},
|
||||
[onSelectedState, frontmatterTagName],
|
||||
);
|
||||
|
||||
const {
|
||||
focusedIndex,
|
||||
searchFilter,
|
||||
filteredStatuses,
|
||||
containerRef,
|
||||
searchRef,
|
||||
handleKeyDown,
|
||||
setSearchFilter,
|
||||
} = useKeyboardNavigation({
|
||||
availableStatuses,
|
||||
currentStatuses,
|
||||
onSelectStatus: handleSelectStatus,
|
||||
onRemoveStatus: handleRemoveStatus,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ outline: "none" }}
|
||||
>
|
||||
<SearchFilter
|
||||
ref={searchRef}
|
||||
value={searchFilter}
|
||||
onFilterChange={setSearchFilter}
|
||||
/>
|
||||
|
||||
<SettingItem name="Current statuses" vertical>
|
||||
{filteredStatuses.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{searchFilter
|
||||
? `No statuses match "${searchFilter}"`
|
||||
: "No statuses found"}
|
||||
</div>
|
||||
) : (
|
||||
<StatusSelector
|
||||
availableStatuses={filteredStatuses}
|
||||
currentStatuses={currentStatuses}
|
||||
focusedIndex={focusedIndex}
|
||||
onToggleStatus={(status, selected) =>
|
||||
selected
|
||||
? handleSelectStatus(status)
|
||||
: handleRemoveStatus(status)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingItem>
|
||||
|
||||
<CurrentStatusChips
|
||||
currentStatuses={currentStatuses}
|
||||
onRemoveStatus={handleRemoveStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
154
components/ChangeStatusModal/useKeyboardNavigation.tsx
Normal file
154
components/ChangeStatusModal/useKeyboardNavigation.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
interface UseKeyboardNavigationProps {
|
||||
availableStatuses: NoteStatus[];
|
||||
currentStatuses: NoteStatus[];
|
||||
onSelectStatus: (status: NoteStatus) => void;
|
||||
onRemoveStatus: (status: NoteStatus) => void;
|
||||
}
|
||||
|
||||
export const useKeyboardNavigation = ({
|
||||
availableStatuses,
|
||||
currentStatuses,
|
||||
onSelectStatus,
|
||||
onRemoveStatus,
|
||||
}: UseKeyboardNavigationProps) => {
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredStatuses = useMemo(
|
||||
() =>
|
||||
searchFilter
|
||||
? availableStatuses.filter((status) => {
|
||||
const searchTerm = searchFilter.toLowerCase();
|
||||
return (
|
||||
status.name.toLowerCase().includes(searchTerm) ||
|
||||
(status.templateId &&
|
||||
status.templateId
|
||||
.toLowerCase()
|
||||
.includes(searchTerm))
|
||||
);
|
||||
})
|
||||
: availableStatuses,
|
||||
[searchFilter, availableStatuses],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (filteredStatuses.length > 0) {
|
||||
setFocusedIndex((prev) =>
|
||||
prev < filteredStatuses.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (filteredStatuses.length > 0) {
|
||||
setFocusedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredStatuses.length - 1,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (filteredStatuses.length > 0) {
|
||||
setFocusedIndex((prev) =>
|
||||
prev < filteredStatuses.length - 1
|
||||
? prev + 1
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
e.preventDefault();
|
||||
if (filteredStatuses.length > 0) {
|
||||
setFocusedIndex((prev) =>
|
||||
prev > 0
|
||||
? prev - 1
|
||||
: filteredStatuses.length - 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
if (
|
||||
focusedIndex >= 0 &&
|
||||
focusedIndex < filteredStatuses.length
|
||||
) {
|
||||
e.preventDefault();
|
||||
const status = filteredStatuses[focusedIndex];
|
||||
const isSelected = currentStatuses.some(
|
||||
(s) =>
|
||||
s.name === status.name &&
|
||||
s.templateId === status.templateId,
|
||||
);
|
||||
if (isSelected) {
|
||||
onRemoveStatus(status);
|
||||
} else {
|
||||
onSelectStatus(status);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Backspace":
|
||||
e.preventDefault();
|
||||
setSearchFilter((prev) => prev.slice(0, -1));
|
||||
if (searchRef.current) {
|
||||
searchRef.current.focus();
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setSearchFilter("");
|
||||
break;
|
||||
default:
|
||||
if (
|
||||
e.key.length === 1 &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
setSearchFilter((prev) => prev + e.key);
|
||||
if (searchRef.current) {
|
||||
searchRef.current.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
filteredStatuses,
|
||||
focusedIndex,
|
||||
currentStatuses,
|
||||
onRemoveStatus,
|
||||
onSelectStatus,
|
||||
searchRef,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedIndex(filteredStatuses.length > 0 ? 0 : -1);
|
||||
}, [searchFilter, filteredStatuses.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
focusedIndex,
|
||||
searchFilter,
|
||||
filteredStatuses,
|
||||
containerRef,
|
||||
searchRef,
|
||||
handleKeyDown,
|
||||
setSearchFilter,
|
||||
};
|
||||
};
|
||||
66
components/FileExplorer/FileExplorerIcon.tsx
Normal file
66
components/FileExplorer/FileExplorerIcon.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import React, { FC, memo } from "react";
|
||||
|
||||
type Props = {
|
||||
statuses: GroupedStatuses;
|
||||
onMouseEnter: (statuses: GroupedStatuses) => void;
|
||||
onMouseLeave: (statuses: GroupedStatuses) => void;
|
||||
hideUnknownStatus?: boolean;
|
||||
};
|
||||
|
||||
export const FileExplorerIcon: FC<Props> = memo(
|
||||
({ statuses, onMouseLeave, onMouseEnter, hideUnknownStatus }) => {
|
||||
const statusEntries = Object.entries(statuses);
|
||||
const totalStatuses = statusEntries.reduce(
|
||||
(acc, [, list]) => acc + list.length,
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalStatuses === 0) {
|
||||
// If hideUnknownStatus is enabled, don't show anything for files without status
|
||||
if (hideUnknownStatus) return null;
|
||||
|
||||
// Show "no status" icon
|
||||
return (
|
||||
<div className="status-wrapper">
|
||||
<div
|
||||
className="status-minimal status-minimal--no-status"
|
||||
onMouseEnter={() => onMouseEnter({})}
|
||||
onMouseLeave={() => onMouseLeave({})}
|
||||
style={
|
||||
{
|
||||
"--primary-color": "var(--text-muted)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="status-icon">❓</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryStatus = statusEntries[0]?.[1]?.[0];
|
||||
if (!primaryStatus) return null;
|
||||
|
||||
return (
|
||||
<div className="status-wrapper">
|
||||
<div
|
||||
className="status-minimal"
|
||||
onMouseEnter={() => onMouseEnter(statuses)}
|
||||
onMouseLeave={() => onMouseLeave(statuses)}
|
||||
style={
|
||||
{
|
||||
"--primary-color":
|
||||
primaryStatus.color || "var(--text-accent)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="status-icon">{primaryStatus.icon}</span>
|
||||
{totalStatuses > 1 && (
|
||||
<span className="status-count">{totalStatuses}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
169
components/GroupedStatusView/GroupedStatusView.tsx
Normal file
169
components/GroupedStatusView/GroupedStatusView.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { useState, useCallback, useMemo } from "react";
|
||||
import { FilterSection } from "./components/FilterSection";
|
||||
import { TagSection } from "./components/TagSection";
|
||||
import { LoadingSpinner } from "./components/LoadingSpinner";
|
||||
import {
|
||||
GroupedDataProvider,
|
||||
useGroupedDataContext,
|
||||
} from "./context/GroupedDataProvider";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
export type FileItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type StatusItem = {
|
||||
name: string;
|
||||
color: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export type FilesByStatus = {
|
||||
[statusName: string]: FileItem[];
|
||||
};
|
||||
|
||||
export type GroupedByStatus = {
|
||||
[frontmatterTag: string]: FilesByStatus;
|
||||
};
|
||||
|
||||
export type GroupedStatusViewProps = {
|
||||
getAllFiles: () => FileItem[];
|
||||
processFiles: (files: FileItem[]) => GroupedByStatus;
|
||||
onFileClick: (file: FileItem) => void;
|
||||
subscribeToEvents: (onDataChange: () => void) => () => void;
|
||||
getAvailableStatuses: () => StatusItem[];
|
||||
getAvailableStatusesWithTemplateInfo: () => NoteStatus[];
|
||||
};
|
||||
|
||||
const GroupedStatusViewContent = () => {
|
||||
const {
|
||||
filteredData,
|
||||
isLoading,
|
||||
expandedGroups,
|
||||
expandedFiles,
|
||||
toggleGroup,
|
||||
toggleFiles,
|
||||
onFileClick,
|
||||
getAvailableStatusesWithTemplateInfo,
|
||||
getLoadedCount,
|
||||
loadMoreItems,
|
||||
handleScroll,
|
||||
} = useGroupedDataContext();
|
||||
|
||||
const handleFileClickCallback = useCallback(
|
||||
(file: FileItem) => {
|
||||
onFileClick(file);
|
||||
},
|
||||
[onFileClick],
|
||||
);
|
||||
|
||||
const statusMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
const noteStatuses = getAvailableStatusesWithTemplateInfo();
|
||||
|
||||
// Build map using scoped identifiers as keys
|
||||
noteStatuses.forEach((noteStatus) => {
|
||||
const statusItem: StatusItem = {
|
||||
name: noteStatus.name,
|
||||
color: noteStatus.color || "white",
|
||||
icon: noteStatus.icon,
|
||||
};
|
||||
|
||||
const scopedIdentifier = noteStatus.templateId
|
||||
? `${noteStatus.templateId}:${noteStatus.name}`
|
||||
: noteStatus.name;
|
||||
map.set(scopedIdentifier, statusItem);
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [getAvailableStatusesWithTemplateInfo]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grouped-status-content">
|
||||
{Object.entries(filteredData).map(([tag, statusGroups]) => {
|
||||
const tagKey = `tag-${tag}`;
|
||||
const isTagExpanded = expandedGroups.has(tagKey);
|
||||
|
||||
return (
|
||||
<TagSection
|
||||
key={tag}
|
||||
tag={tag}
|
||||
statusGroups={statusGroups}
|
||||
isExpanded={isTagExpanded}
|
||||
statusMap={statusMap}
|
||||
expandedFiles={expandedFiles}
|
||||
getLoadedCount={getLoadedCount}
|
||||
onToggle={() => toggleGroup(tagKey)}
|
||||
onToggleFiles={toggleFiles}
|
||||
onFileClick={handleFileClickCallback}
|
||||
onScroll={handleScroll}
|
||||
onLoadMore={loadMoreItems}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{Object.keys(filteredData).length === 0 && (
|
||||
<div className="grouped-status-empty">No statuses found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupedStatusView = ({
|
||||
getAllFiles,
|
||||
processFiles,
|
||||
onFileClick,
|
||||
subscribeToEvents,
|
||||
getAvailableStatuses,
|
||||
getAvailableStatusesWithTemplateInfo,
|
||||
}: GroupedStatusViewProps) => {
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const [noteNameFilter, setNoteNameFilter] = useState("");
|
||||
const [templateFilter, setTemplateFilter] = useState("");
|
||||
|
||||
// Get available templates from the statuses
|
||||
const availableTemplates = useMemo(() => {
|
||||
const templates = new Set<string>();
|
||||
getAvailableStatusesWithTemplateInfo().forEach((status) => {
|
||||
if (status.templateId) {
|
||||
templates.add(status.templateId);
|
||||
}
|
||||
});
|
||||
return Array.from(templates).sort();
|
||||
}, [getAvailableStatusesWithTemplateInfo]);
|
||||
|
||||
return (
|
||||
<GroupedDataProvider
|
||||
getAllFiles={getAllFiles}
|
||||
processFiles={processFiles}
|
||||
onFileClick={onFileClick}
|
||||
subscribeToEvents={subscribeToEvents}
|
||||
getAvailableStatuses={getAvailableStatuses}
|
||||
getAvailableStatusesWithTemplateInfo={
|
||||
getAvailableStatusesWithTemplateInfo
|
||||
}
|
||||
searchFilter={searchFilter}
|
||||
noteNameFilter={noteNameFilter}
|
||||
templateFilter={templateFilter}
|
||||
>
|
||||
<div className="">
|
||||
<FilterSection
|
||||
searchFilter={searchFilter}
|
||||
noteNameFilter={noteNameFilter}
|
||||
templateFilter={templateFilter}
|
||||
availableTemplates={availableTemplates}
|
||||
onSearchFilterChange={setSearchFilter}
|
||||
onNoteNameFilterChange={setNoteNameFilter}
|
||||
onTemplateFilterChange={setTemplateFilter}
|
||||
/>
|
||||
<GroupedStatusViewContent />
|
||||
</div>
|
||||
</GroupedDataProvider>
|
||||
);
|
||||
};
|
||||
82
components/GroupedStatusView/components/FileList.tsx
Normal file
82
components/GroupedStatusView/components/FileList.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { FileItem } from "../GroupedStatusView";
|
||||
import { SelectableListItem } from "../../atoms/SelectableListItem";
|
||||
|
||||
interface FileListProps {
|
||||
files: FileItem[];
|
||||
groupKey: string;
|
||||
loadedCount: number;
|
||||
onFileClick: (file: FileItem) => void;
|
||||
onScroll: (
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
groupKey: string,
|
||||
totalItems: number,
|
||||
) => void;
|
||||
onLoadMore: (groupKey: string) => void;
|
||||
}
|
||||
|
||||
export const FileList = ({
|
||||
files,
|
||||
groupKey,
|
||||
loadedCount,
|
||||
onFileClick,
|
||||
onScroll,
|
||||
onLoadMore,
|
||||
}: FileListProps) => {
|
||||
const visibleFiles = files.slice(0, loadedCount);
|
||||
const hasMoreItems = files.length > loadedCount;
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(file: FileItem) => {
|
||||
onFileClick(file);
|
||||
},
|
||||
[onFileClick],
|
||||
);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
onLoadMore(groupKey);
|
||||
}, [onLoadMore, groupKey]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
onScroll(e, groupKey, files.length);
|
||||
},
|
||||
[onScroll, groupKey, files.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grouped-status-files">
|
||||
<div className="grouped-status-files-list" onScroll={handleScroll}>
|
||||
{visibleFiles.map((file) => (
|
||||
<SelectableListItem
|
||||
key={file.id}
|
||||
onClick={() => handleFileClick(file)}
|
||||
className="grouped-status-file-item"
|
||||
title={file.path}
|
||||
>
|
||||
<div>
|
||||
<span className="grouped-status-file-name">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="grouped-status-file-path">
|
||||
{file.path}
|
||||
</span>
|
||||
</div>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
|
||||
{hasMoreItems && (
|
||||
<div className="grouped-status-load-more">
|
||||
<button
|
||||
className="grouped-status-load-btn"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
Load more... ({files.length - loadedCount}{" "}
|
||||
remaining)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
components/GroupedStatusView/components/FilterSection.tsx
Normal file
58
components/GroupedStatusView/components/FilterSection.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from "react";
|
||||
import { SearchFilter } from "@/components/atoms/SearchFilter";
|
||||
import { Input } from "@/components/atoms/Input";
|
||||
|
||||
interface FilterSectionProps {
|
||||
searchFilter: string;
|
||||
noteNameFilter: string;
|
||||
templateFilter: string;
|
||||
availableTemplates: string[];
|
||||
onSearchFilterChange: (value: string) => void;
|
||||
onNoteNameFilterChange: (value: string) => void;
|
||||
onTemplateFilterChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const FilterSection = ({
|
||||
searchFilter,
|
||||
noteNameFilter,
|
||||
templateFilter,
|
||||
availableTemplates,
|
||||
onSearchFilterChange,
|
||||
onNoteNameFilterChange,
|
||||
onTemplateFilterChange,
|
||||
}: FilterSectionProps) => {
|
||||
return (
|
||||
<div className="grouped-status-header">
|
||||
<h3 className="grouped-status-title">Status Groups</h3>
|
||||
<div className="grouped-status-filters">
|
||||
<SearchFilter
|
||||
value={searchFilter}
|
||||
onFilterChange={onSearchFilterChange}
|
||||
/>
|
||||
<div className="grouped-status-filters__note">
|
||||
<Input
|
||||
variant="text"
|
||||
value={noteNameFilter}
|
||||
onChange={onNoteNameFilterChange}
|
||||
placeholder="Filter by note name..."
|
||||
className="grouped-status-filters__note-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grouped-status-filters__template">
|
||||
<select
|
||||
value={templateFilter}
|
||||
onChange={(e) => onTemplateFilterChange(e.target.value)}
|
||||
className="grouped-status-filters__template-select"
|
||||
>
|
||||
<option value="">All templates</option>
|
||||
{availableTemplates.map((template) => (
|
||||
<option key={template} value={template}>
|
||||
{template}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import React from "react";
|
||||
|
||||
export const LoadingSpinner = () => {
|
||||
return (
|
||||
<div className="">
|
||||
<div className="grouped-status-loading">Loading statuses...</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
85
components/GroupedStatusView/components/StatusGroup.tsx
Normal file
85
components/GroupedStatusView/components/StatusGroup.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { StatusDisplay } from "@/components/atoms/StatusDisplay";
|
||||
import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter";
|
||||
import { FileItem, StatusItem } from "../GroupedStatusView";
|
||||
import { FileList } from "./FileList";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
interface StatusGroupProps {
|
||||
statusName: string;
|
||||
status: StatusItem;
|
||||
files: FileItem[];
|
||||
tag: string;
|
||||
isExpanded: boolean;
|
||||
loadedCount: number;
|
||||
onToggle: () => void;
|
||||
onFileClick: (file: FileItem) => void;
|
||||
onScroll: (
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
groupKey: string,
|
||||
totalItems: number,
|
||||
) => void;
|
||||
onLoadMore: (groupKey: string) => void;
|
||||
}
|
||||
|
||||
export const StatusGroup = ({
|
||||
statusName,
|
||||
status,
|
||||
files,
|
||||
tag,
|
||||
isExpanded,
|
||||
loadedCount,
|
||||
onToggle,
|
||||
onFileClick,
|
||||
onScroll,
|
||||
onLoadMore,
|
||||
}: StatusGroupProps) => {
|
||||
const groupKey = `${tag}-${statusName}`;
|
||||
|
||||
// Extract template ID from scoped identifier
|
||||
const templateId = statusName.includes(":")
|
||||
? statusName.split(":", 2)[0]
|
||||
: null;
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onToggle();
|
||||
}, [onToggle]);
|
||||
|
||||
return (
|
||||
<div className="grouped-status-group">
|
||||
<div className="grouped-status-group-header" onClick={handleToggle}>
|
||||
<div className="grouped-status-group__status">
|
||||
<StatusDisplay
|
||||
status={
|
||||
{ ...status, icon: status.icon || "" } as NoteStatus
|
||||
}
|
||||
variant="badge"
|
||||
/>
|
||||
{templateId && (
|
||||
<span className="grouped-status-group__template-badge">
|
||||
{templateId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grouped-status-group-info">
|
||||
<CollapsibleCounter
|
||||
count={files.length}
|
||||
isCollapsed={!isExpanded}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<FileList
|
||||
files={files}
|
||||
groupKey={groupKey}
|
||||
loadedCount={loadedCount}
|
||||
onFileClick={onFileClick}
|
||||
onScroll={onScroll}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
components/GroupedStatusView/components/TagSection.tsx
Normal file
97
components/GroupedStatusView/components/TagSection.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { GroupLabel } from "@/components/atoms/GroupLabel";
|
||||
import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter";
|
||||
import { FilesByStatus, FileItem, StatusItem } from "../GroupedStatusView";
|
||||
import { StatusGroup } from "./StatusGroup";
|
||||
|
||||
interface TagSectionProps {
|
||||
tag: string;
|
||||
statusGroups: FilesByStatus;
|
||||
isExpanded: boolean;
|
||||
statusMap: Map<string, StatusItem>;
|
||||
expandedFiles: Set<string>;
|
||||
getLoadedCount: (groupKey: string) => number;
|
||||
onToggle: () => void;
|
||||
onToggleFiles: (groupKey: string) => void;
|
||||
onFileClick: (file: FileItem) => void;
|
||||
onScroll: (
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
groupKey: string,
|
||||
totalItems: number,
|
||||
) => void;
|
||||
onLoadMore: (groupKey: string) => void;
|
||||
}
|
||||
|
||||
export const TagSection = ({
|
||||
tag,
|
||||
statusGroups,
|
||||
isExpanded,
|
||||
statusMap,
|
||||
expandedFiles,
|
||||
getLoadedCount,
|
||||
onToggle,
|
||||
onToggleFiles,
|
||||
onFileClick,
|
||||
onScroll,
|
||||
onLoadMore,
|
||||
}: TagSectionProps) => {
|
||||
const totalFilesInTag = Object.values(statusGroups).reduce(
|
||||
(total, files) => total + files.length,
|
||||
0,
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onToggle();
|
||||
}, [onToggle]);
|
||||
|
||||
const handleToggleFiles = useCallback(
|
||||
(groupKey: string) => {
|
||||
onToggleFiles(groupKey);
|
||||
},
|
||||
[onToggleFiles],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grouped-status-tag-section">
|
||||
<div className="grouped-status-tag-header" onClick={handleToggle}>
|
||||
<GroupLabel name={tag} />
|
||||
<CollapsibleCounter
|
||||
count={totalFilesInTag}
|
||||
isCollapsed={!isExpanded}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="grouped-status-tag-content">
|
||||
{Object.entries(statusGroups).map(([statusName, files]) => {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
const status = statusMap.get(statusName);
|
||||
if (!status) return null;
|
||||
|
||||
const groupKey = `${tag}-${statusName}`;
|
||||
const filesExpanded = expandedFiles.has(groupKey);
|
||||
const loadedCount = getLoadedCount(groupKey);
|
||||
|
||||
return (
|
||||
<StatusGroup
|
||||
key={statusName}
|
||||
statusName={statusName}
|
||||
status={status}
|
||||
files={files}
|
||||
tag={tag}
|
||||
isExpanded={filesExpanded}
|
||||
loadedCount={loadedCount}
|
||||
onToggle={() => handleToggleFiles(groupKey)}
|
||||
onFileClick={onFileClick}
|
||||
onScroll={onScroll}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
components/GroupedStatusView/context/GroupedDataProvider.tsx
Normal file
111
components/GroupedStatusView/context/GroupedDataProvider.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import React, { createContext, useContext, ReactNode } from "react";
|
||||
import { FileItem, GroupedByStatus, StatusItem } from "../GroupedStatusView";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { useGroupedData } from "../hooks/useGroupedData";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import { useExpandedState } from "../hooks/useExpandedState";
|
||||
|
||||
type GroupedDataContextType = {
|
||||
// Data
|
||||
groupedData: GroupedByStatus;
|
||||
filteredData: GroupedByStatus;
|
||||
isLoading: boolean;
|
||||
loadData: () => void;
|
||||
|
||||
// Pagination
|
||||
getLoadedCount: (groupKey: string) => number;
|
||||
loadMoreItems: (groupKey: string) => void;
|
||||
handleScroll: (
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
groupKey: string,
|
||||
totalItems: number,
|
||||
) => void;
|
||||
|
||||
// Expanded state
|
||||
expandedGroups: Set<string>;
|
||||
expandedFiles: Set<string>;
|
||||
toggleGroup: (groupKey: string) => void;
|
||||
toggleFiles: (groupKey: string) => void;
|
||||
|
||||
// Props
|
||||
onFileClick: (file: FileItem) => void;
|
||||
getAvailableStatuses: () => StatusItem[];
|
||||
getAvailableStatusesWithTemplateInfo: () => NoteStatus[];
|
||||
};
|
||||
|
||||
const GroupedDataContext = createContext<GroupedDataContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useGroupedDataContext = () => {
|
||||
const context = useContext(GroupedDataContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useGroupedDataContext must be used within GroupedDataProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
type GroupedDataProviderProps = {
|
||||
children: ReactNode;
|
||||
getAllFiles: () => FileItem[];
|
||||
processFiles: (files: FileItem[]) => GroupedByStatus;
|
||||
onFileClick: (file: FileItem) => void;
|
||||
subscribeToEvents: (onDataChange: () => void) => () => void;
|
||||
getAvailableStatuses: () => StatusItem[];
|
||||
getAvailableStatusesWithTemplateInfo: () => NoteStatus[];
|
||||
searchFilter: string;
|
||||
noteNameFilter: string;
|
||||
templateFilter: string;
|
||||
};
|
||||
|
||||
export const GroupedDataProvider = ({
|
||||
children,
|
||||
getAllFiles,
|
||||
processFiles,
|
||||
onFileClick,
|
||||
subscribeToEvents,
|
||||
getAvailableStatuses,
|
||||
getAvailableStatusesWithTemplateInfo,
|
||||
searchFilter,
|
||||
noteNameFilter,
|
||||
templateFilter,
|
||||
}: GroupedDataProviderProps) => {
|
||||
const { groupedData, filteredData, isLoading, loadData } = useGroupedData({
|
||||
getAllFiles,
|
||||
processFiles,
|
||||
subscribeToEvents,
|
||||
searchFilter,
|
||||
noteNameFilter,
|
||||
templateFilter,
|
||||
});
|
||||
|
||||
const { getLoadedCount, loadMoreItems, handleScroll } = usePagination();
|
||||
|
||||
const { expandedGroups, expandedFiles, toggleGroup, toggleFiles } =
|
||||
useExpandedState(filteredData);
|
||||
|
||||
const value: GroupedDataContextType = {
|
||||
groupedData,
|
||||
filteredData,
|
||||
isLoading,
|
||||
loadData,
|
||||
getLoadedCount,
|
||||
loadMoreItems,
|
||||
handleScroll,
|
||||
expandedGroups,
|
||||
expandedFiles,
|
||||
toggleGroup,
|
||||
toggleFiles,
|
||||
onFileClick,
|
||||
getAvailableStatuses,
|
||||
getAvailableStatusesWithTemplateInfo,
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupedDataContext.Provider value={value}>
|
||||
{children}
|
||||
</GroupedDataContext.Provider>
|
||||
);
|
||||
};
|
||||
3
components/GroupedStatusView/hooks/index.ts
Normal file
3
components/GroupedStatusView/hooks/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { useGroupedData } from "./useGroupedData";
|
||||
export { usePagination } from "./usePagination";
|
||||
export { useExpandedState } from "./useExpandedState";
|
||||
66
components/GroupedStatusView/hooks/useExpandedState.ts
Normal file
66
components/GroupedStatusView/hooks/useExpandedState.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { GroupedByStatus } from "../GroupedStatusView";
|
||||
|
||||
export const useExpandedState = (filteredData: GroupedByStatus) => {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
// Auto-expand groups when there's only one group
|
||||
useEffect(() => {
|
||||
const dataToCheck = filteredData;
|
||||
const groupKeys = Object.keys(dataToCheck);
|
||||
|
||||
if (groupKeys.length === 1) {
|
||||
const singleGroupKey = `tag-${groupKeys[0]}`;
|
||||
setExpandedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(singleGroupKey);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Also expand all status groups within the single tag group
|
||||
const statusGroups = dataToCheck[groupKeys[0]];
|
||||
const statusKeys = Object.keys(statusGroups).map(
|
||||
(statusName) => `${groupKeys[0]}-${statusName}`,
|
||||
);
|
||||
setExpandedFiles((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
statusKeys.forEach((key) => newSet.add(key));
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}, [filteredData]);
|
||||
|
||||
const toggleGroup = useCallback((groupKey: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupKey)) {
|
||||
newSet.delete(groupKey);
|
||||
} else {
|
||||
newSet.add(groupKey);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleFiles = useCallback((groupKey: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupKey)) {
|
||||
newSet.delete(groupKey);
|
||||
} else {
|
||||
newSet.add(groupKey);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
expandedGroups,
|
||||
expandedFiles,
|
||||
toggleGroup,
|
||||
toggleFiles,
|
||||
};
|
||||
};
|
||||
109
components/GroupedStatusView/hooks/useGroupedData.ts
Normal file
109
components/GroupedStatusView/hooks/useGroupedData.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { FileItem, GroupedByStatus } from "../GroupedStatusView";
|
||||
|
||||
export type UseGroupedDataProps = {
|
||||
getAllFiles: () => FileItem[];
|
||||
processFiles: (files: FileItem[]) => GroupedByStatus;
|
||||
subscribeToEvents: (onDataChange: () => void) => () => void;
|
||||
searchFilter: string;
|
||||
noteNameFilter: string;
|
||||
templateFilter: string;
|
||||
};
|
||||
|
||||
export const useGroupedData = ({
|
||||
getAllFiles,
|
||||
processFiles,
|
||||
subscribeToEvents,
|
||||
searchFilter,
|
||||
noteNameFilter,
|
||||
templateFilter,
|
||||
}: UseGroupedDataProps) => {
|
||||
const [groupedData, setGroupedData] = useState<GroupedByStatus>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const files = getAllFiles();
|
||||
const processed = processFiles(files);
|
||||
setGroupedData(processed);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [getAllFiles, processFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
const handleFileChange = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeToEvents(handleFileChange);
|
||||
|
||||
return unsubscribe;
|
||||
}, [loadData, subscribeToEvents]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (
|
||||
!searchFilter.trim() &&
|
||||
!noteNameFilter.trim() &&
|
||||
!templateFilter.trim()
|
||||
)
|
||||
return groupedData;
|
||||
|
||||
const filtered: GroupedByStatus = {};
|
||||
const searchLower = searchFilter.toLowerCase();
|
||||
const noteNameLower = noteNameFilter.toLowerCase();
|
||||
|
||||
Object.entries(groupedData).forEach(([tag, statusGroups]) => {
|
||||
filtered[tag] = {};
|
||||
Object.entries(statusGroups).forEach(([statusName, files]) => {
|
||||
// Filter by template if templateFilter is provided
|
||||
if (templateFilter.trim()) {
|
||||
// Check if statusName contains the template (scoped: "template:status")
|
||||
const statusTemplate = statusName.includes(":")
|
||||
? statusName.split(":", 2)[0]
|
||||
: "";
|
||||
|
||||
if (statusTemplate !== templateFilter) {
|
||||
return; // Skip this status group if it doesn't match the template filter
|
||||
}
|
||||
}
|
||||
|
||||
let matchingFiles = files;
|
||||
|
||||
// Filter by status/tag if searchFilter is provided
|
||||
if (searchFilter.trim()) {
|
||||
matchingFiles = matchingFiles.filter(
|
||||
(file) =>
|
||||
statusName.toLowerCase().includes(searchLower) ||
|
||||
tag.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by note name if noteNameFilter is provided
|
||||
if (noteNameFilter.trim()) {
|
||||
matchingFiles = matchingFiles.filter(
|
||||
(file) =>
|
||||
file.name.toLowerCase().includes(noteNameLower) ||
|
||||
file.path.toLowerCase().includes(noteNameLower),
|
||||
);
|
||||
}
|
||||
|
||||
if (matchingFiles.length > 0) {
|
||||
filtered[tag][statusName] = matchingFiles;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [groupedData, searchFilter, noteNameFilter, templateFilter]);
|
||||
|
||||
return {
|
||||
groupedData,
|
||||
filteredData,
|
||||
isLoading,
|
||||
loadData,
|
||||
};
|
||||
};
|
||||
47
components/GroupedStatusView/hooks/usePagination.ts
Normal file
47
components/GroupedStatusView/hooks/usePagination.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useState, useCallback } from "react";
|
||||
|
||||
const ITEMS_PER_LOAD = 20;
|
||||
|
||||
export const usePagination = () => {
|
||||
const [loadedItems, setLoadedItems] = useState<Record<string, number>>({});
|
||||
|
||||
const getLoadedCount = useCallback(
|
||||
(groupKey: string) => {
|
||||
return loadedItems[groupKey] || ITEMS_PER_LOAD;
|
||||
},
|
||||
[loadedItems],
|
||||
);
|
||||
|
||||
const loadMoreItems = useCallback((groupKey: string) => {
|
||||
setLoadedItems((prev) => ({
|
||||
...prev,
|
||||
[groupKey]: (prev[groupKey] || ITEMS_PER_LOAD) + ITEMS_PER_LOAD,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
groupKey: string,
|
||||
totalItems: number,
|
||||
) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const loadedCount = getLoadedCount(groupKey);
|
||||
|
||||
if (
|
||||
scrollHeight - scrollTop <= clientHeight + 100 &&
|
||||
loadedCount < totalItems
|
||||
) {
|
||||
loadMoreItems(groupKey);
|
||||
}
|
||||
},
|
||||
[getLoadedCount, loadMoreItems],
|
||||
);
|
||||
|
||||
return {
|
||||
loadedItems,
|
||||
getLoadedCount,
|
||||
loadMoreItems,
|
||||
handleScroll,
|
||||
};
|
||||
};
|
||||
57
components/SettingsUI.tsx/BehaviourSettings.tsx
Normal file
57
components/SettingsUI.tsx/BehaviourSettings.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import React from "react";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<h3>Status tag</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Enable multiple statuses"
|
||||
description="Allow notes to have multiple statuses at the same time"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.useMultipleStatuses}
|
||||
onChange={(e) =>
|
||||
onChange("useMultipleStatuses", e.target.checked)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Status tag prefix"
|
||||
description="The YAML frontmatter tag name used for status (default: obsidian-note-status)"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.tagPrefix}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.trim()) {
|
||||
onChange("tagPrefix", e.target.value.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Strict status validation"
|
||||
description="Only show statuses that are defined in templates or custom statuses. ⚠️ WARNING: When enabled, any unknown statuses will be automatically removed when modifying file statuses."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.strictStatuses || false}
|
||||
onChange={(e) =>
|
||||
onChange("strictStatuses", e.target.checked)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
137
components/SettingsUI.tsx/CustomStatusItem.tsx
Normal file
137
components/SettingsUI.tsx/CustomStatusItem.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import React from "react";
|
||||
import { Input } from "@/components/atoms/Input";
|
||||
|
||||
export type Props = {
|
||||
status: NoteStatus;
|
||||
index: number;
|
||||
settings: PluginSettings;
|
||||
onCustomStatusChange: (
|
||||
index: number,
|
||||
column: "name" | "icon" | "color" | "description",
|
||||
value: string,
|
||||
) => void;
|
||||
onCustomStatusRemove: (index: number) => void;
|
||||
};
|
||||
|
||||
export const CustomStatusItem: React.FC<Props> = ({
|
||||
status,
|
||||
index,
|
||||
onCustomStatusChange,
|
||||
onCustomStatusRemove,
|
||||
}) => {
|
||||
const isValid = status.name.trim().length > 0;
|
||||
const displayIcon = status.icon.trim() || "📝";
|
||||
|
||||
return (
|
||||
<div className="custom-status-item">
|
||||
{/* Simple horizontal layout with all inputs in a row */}
|
||||
<div className="custom-status-item__row">
|
||||
{/* Icon field - simple text input */}
|
||||
<div className="custom-status-item__field">
|
||||
<label className="custom-status-item__label">Icon</label>
|
||||
<Input
|
||||
variant="text"
|
||||
value={status.icon}
|
||||
onChange={(value) =>
|
||||
onCustomStatusChange(index, "icon", value || "")
|
||||
}
|
||||
placeholder="📝"
|
||||
className="custom-status-item__input custom-status-item__input--icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name field */}
|
||||
<div className="custom-status-item__field custom-status-item__field--name">
|
||||
<label className="custom-status-item__label">
|
||||
Name{" "}
|
||||
<span className="custom-status-item__label--required">
|
||||
*
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
variant="text"
|
||||
value={status.name}
|
||||
onChange={(value) =>
|
||||
onCustomStatusChange(index, "name", value || "")
|
||||
}
|
||||
placeholder="e.g. In Progress"
|
||||
className={`custom-status-item__input custom-status-item__input--name ${
|
||||
!isValid ? "custom-status-item__input--invalid" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description field */}
|
||||
<div className="custom-status-item__field custom-status-item__field--description">
|
||||
<label className="custom-status-item__label">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
variant="text"
|
||||
value={status.description || ""}
|
||||
onChange={(value) =>
|
||||
onCustomStatusChange(index, "description", value)
|
||||
}
|
||||
placeholder="Optional description"
|
||||
className="custom-status-item__input custom-status-item__input--description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color picker */}
|
||||
<div className="custom-status-item__field custom-status-item__field--color">
|
||||
<label className="custom-status-item__label">Color</label>
|
||||
<Input
|
||||
variant="color"
|
||||
value={status.color || "#888888"}
|
||||
onChange={(value) =>
|
||||
onCustomStatusChange(index, "color", value)
|
||||
}
|
||||
className="custom-status-item__input custom-status-item__input--color"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
<div className="custom-status-item__field custom-status-item__field--actions">
|
||||
<button
|
||||
onClick={() => onCustomStatusRemove(index)}
|
||||
aria-label="Remove status"
|
||||
className="custom-status-item__remove-btn"
|
||||
title="Remove this status"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview row - shows how the status will look */}
|
||||
<div className="custom-status-item__preview">
|
||||
<span
|
||||
className="custom-status-item__preview-icon"
|
||||
style={{ color: status.color }}
|
||||
>
|
||||
{displayIcon}
|
||||
</span>
|
||||
<span
|
||||
className="custom-status-item__preview-text"
|
||||
style={{ color: status.color }}
|
||||
>
|
||||
{status.name || "Status name"}
|
||||
</span>
|
||||
{status.description && (
|
||||
<span className="custom-status-item__preview-desc">
|
||||
{status.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation message */}
|
||||
{!isValid && (
|
||||
<div className="custom-status-item__error">
|
||||
Please enter a status name
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
103
components/SettingsUI.tsx/CustomStatusSettings.tsx
Normal file
103
components/SettingsUI.tsx/CustomStatusSettings.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import React from "react";
|
||||
import {
|
||||
CustomStatusItem,
|
||||
Props as CustomStatusItemProps,
|
||||
} from "./CustomStatusItem";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const CustomStatusSettings: React.FC<Props> = ({
|
||||
settings,
|
||||
onChange,
|
||||
}) => {
|
||||
const addNewCustomStatus = () => {
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
currentStatuses.push({
|
||||
name: "",
|
||||
icon: "",
|
||||
});
|
||||
onChange("customStatuses", currentStatuses);
|
||||
};
|
||||
const removeCustomStatus: CustomStatusItemProps["onCustomStatusRemove"] = (
|
||||
index,
|
||||
) => {
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
currentStatuses.splice(index, 1);
|
||||
onChange("customStatuses", currentStatuses);
|
||||
};
|
||||
|
||||
const updateCustomStatus: CustomStatusItemProps["onCustomStatusChange"] = (
|
||||
index,
|
||||
column,
|
||||
value,
|
||||
) => {
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
|
||||
const target = currentStatuses[index];
|
||||
if (target) {
|
||||
target[column] = value;
|
||||
onChange("customStatuses", currentStatuses);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Custom Statuses</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Use only custom statuses"
|
||||
description="Hide default template statuses and use only your custom statuses"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.useCustomStatusesOnly || false}
|
||||
onChange={(e) =>
|
||||
onChange("useCustomStatusesOnly", e.target.checked)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Your custom statuses"
|
||||
description="Create custom statuses with icons (emoji/text), names, and colors. Each status needs a name to be valid."
|
||||
vertical
|
||||
>
|
||||
<div className="custom-status-list">
|
||||
{settings.customStatuses.length === 0 ? (
|
||||
<div className="custom-status-list__empty">
|
||||
<p>
|
||||
No custom statuses yet. Click "Add Status" below
|
||||
to create your first one.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
settings.customStatuses.map((status, index) => (
|
||||
<CustomStatusItem
|
||||
key={index}
|
||||
status={status}
|
||||
index={index}
|
||||
settings={settings}
|
||||
onCustomStatusChange={updateCustomStatus}
|
||||
onCustomStatusRemove={removeCustomStatus}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Add new status"
|
||||
description="Create a new custom status"
|
||||
>
|
||||
<button className="mod-cta" onClick={addNewCustomStatus}>
|
||||
+ Add Status
|
||||
</button>
|
||||
</SettingItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
components/SettingsUI.tsx/NoStatusesMessage.tsx
Normal file
7
components/SettingsUI.tsx/NoStatusesMessage.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from "react";
|
||||
|
||||
export const NoStatusesMessage: React.FC = () => (
|
||||
<div className="setting-item-description">
|
||||
No statuses available. Enable templates or add custom statuses first.
|
||||
</div>
|
||||
);
|
||||
105
components/SettingsUI.tsx/QuickCommandsSettings.tsx
Normal file
105
components/SettingsUI.tsx/QuickCommandsSettings.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { PluginSettings, StatusTemplate } from "@/types/pluginSettings";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { StatusGroup } from "./StatusGroup";
|
||||
import { NoStatusesMessage } from "./NoStatusesMessage";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
templates: StatusTemplate[];
|
||||
};
|
||||
|
||||
type GroupedStatuses = {
|
||||
customStatuses: PluginSettings["customStatuses"];
|
||||
templateGroups: Array<{
|
||||
template: StatusTemplate;
|
||||
statuses: PluginSettings["customStatuses"];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const QuickCommandsSettings: React.FC<Props> = ({
|
||||
settings,
|
||||
onChange,
|
||||
templates,
|
||||
}) => {
|
||||
const groupedStatuses = useMemo((): GroupedStatuses => {
|
||||
const customStatuses = settings.customStatuses.filter(
|
||||
(s) => s.name !== "unknown",
|
||||
);
|
||||
const templateGroups = [];
|
||||
if (!settings.useCustomStatusesOnly) {
|
||||
for (const templateId of settings.enabledTemplates) {
|
||||
const template = templates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
const statuses = template.statuses.filter(
|
||||
(s) => s.name !== "unknown",
|
||||
);
|
||||
if (statuses.length > 0) {
|
||||
templateGroups.push({ template, statuses });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { customStatuses, templateGroups };
|
||||
}, [settings, templates]);
|
||||
|
||||
const currentQuickCommands = settings.quickStatusCommands || [];
|
||||
|
||||
const handleQuickCommandToggle = useCallback(
|
||||
(statusName: string, enabled: boolean) => {
|
||||
const updatedCommands = enabled
|
||||
? [
|
||||
...currentQuickCommands.filter(
|
||||
(cmd) => cmd !== statusName,
|
||||
),
|
||||
statusName,
|
||||
]
|
||||
: currentQuickCommands.filter((cmd) => cmd !== statusName);
|
||||
onChange("quickStatusCommands", updatedCommands);
|
||||
},
|
||||
[currentQuickCommands, onChange],
|
||||
);
|
||||
|
||||
const hasAnyStatuses =
|
||||
groupedStatuses.customStatuses.length > 0 ||
|
||||
groupedStatuses.templateGroups.length > 0;
|
||||
|
||||
return (
|
||||
<div className="quick-commands-settings">
|
||||
<h3>Quick status commands</h3>
|
||||
<p>
|
||||
Select which statuses should have dedicated commands in the
|
||||
command palette. These can be assigned hotkeys for quick access.
|
||||
</p>
|
||||
<div className="quick-commands-container">
|
||||
{!hasAnyStatuses ? (
|
||||
<NoStatusesMessage />
|
||||
) : (
|
||||
<>
|
||||
{groupedStatuses.customStatuses.length > 0 && (
|
||||
<StatusGroup
|
||||
statuses={groupedStatuses.customStatuses}
|
||||
title="Custom Statuses"
|
||||
description="Your manually defined statuses"
|
||||
currentQuickCommands={currentQuickCommands}
|
||||
onToggle={handleQuickCommandToggle}
|
||||
/>
|
||||
)}
|
||||
{groupedStatuses.templateGroups.map(
|
||||
({ template, statuses }) => (
|
||||
<StatusGroup
|
||||
key={template.id}
|
||||
statuses={statuses}
|
||||
title={template.name}
|
||||
description={template.description}
|
||||
currentQuickCommands={currentQuickCommands}
|
||||
onToggle={handleQuickCommandToggle}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
29
components/SettingsUI.tsx/SettingItem.tsx
Normal file
29
components/SettingsUI.tsx/SettingItem.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from "react";
|
||||
|
||||
interface SettingItemProps {
|
||||
name: string;
|
||||
description?: string;
|
||||
vertical?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SettingItem: React.FC<SettingItemProps> = ({
|
||||
name,
|
||||
description,
|
||||
children,
|
||||
vertical,
|
||||
}) => (
|
||||
<div className={`setting-item ${vertical && "setting-item-vertical"}`}>
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">{name}</div>
|
||||
{description && (
|
||||
<div className="setting-item-description">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={vertical ? "setting-item-full" : "setting-item-control"}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
45
components/SettingsUI.tsx/StatusGroup.tsx
Normal file
45
components/SettingsUI.tsx/StatusGroup.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React from "react";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
|
||||
export type StatusGroupProps = {
|
||||
statuses: PluginSettings["customStatuses"];
|
||||
title: string;
|
||||
description?: string;
|
||||
currentQuickCommands: string[];
|
||||
onToggle: (statusName: string, enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export const StatusGroup: React.FC<StatusGroupProps> = ({
|
||||
statuses,
|
||||
title,
|
||||
description,
|
||||
currentQuickCommands,
|
||||
onToggle,
|
||||
}) => (
|
||||
<div className="status-group">
|
||||
<div className="status-group-header">
|
||||
<div className="status-group-title">{title}</div>
|
||||
{description && (
|
||||
<div className="status-group-description">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="status-group-items">
|
||||
{statuses.map((status) => (
|
||||
<SettingItem
|
||||
key={`${status.templateId || "custom"}:${status.name}`}
|
||||
name={`${status.icon} ${status.name}`}
|
||||
description={status.description || ""}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentQuickCommands.includes(status.name)}
|
||||
onChange={(e) =>
|
||||
onToggle(status.name, e.target.checked)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
48
components/SettingsUI.tsx/TemplateItem.tsx
Normal file
48
components/SettingsUI.tsx/TemplateItem.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import React from "react";
|
||||
import { StatusTemplate } from "@/types/pluginSettings";
|
||||
import { StatusDisplay } from "../atoms/StatusDisplay";
|
||||
import { SelectableListItem } from "../atoms/SelectableListItem";
|
||||
|
||||
interface TemplateItemProps {
|
||||
template: StatusTemplate;
|
||||
isEnabled: boolean;
|
||||
onToggle: (templateId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const TemplateItem: React.FC<TemplateItemProps> = ({
|
||||
template,
|
||||
isEnabled,
|
||||
onToggle,
|
||||
}) => (
|
||||
<SelectableListItem
|
||||
selected={isEnabled}
|
||||
onClick={() => onToggle(template.id, !isEnabled)}
|
||||
className={`template-item ${isEnabled ? "enabled" : ""}`}
|
||||
icon={
|
||||
<input
|
||||
type="checkbox"
|
||||
className="template-checkbox"
|
||||
checked={isEnabled}
|
||||
readOnly
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div className="template-header">
|
||||
<span className="setting-item-name">{template.name}</span>
|
||||
</div>
|
||||
<div className="setting-item-description">
|
||||
{template.description}:
|
||||
</div>
|
||||
<div className="template-statuses">
|
||||
{template.statuses.map((status, index) => (
|
||||
<StatusDisplay
|
||||
key={index}
|
||||
status={status}
|
||||
variant="template"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SelectableListItem>
|
||||
);
|
||||
54
components/SettingsUI.tsx/TemplateSettings.tsx
Normal file
54
components/SettingsUI.tsx/TemplateSettings.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { PluginSettings, StatusTemplate } from "@/types/pluginSettings";
|
||||
import { TemplateItem } from "./TemplateItem";
|
||||
|
||||
interface TemplateSettingsProps {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
templates: StatusTemplate[];
|
||||
}
|
||||
|
||||
export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
|
||||
settings,
|
||||
onChange,
|
||||
templates,
|
||||
}) => {
|
||||
const handleTemplateToggle = useCallback(
|
||||
(templateId: string, enabled: boolean) => {
|
||||
let newEnabledTemplated = [...settings.enabledTemplates];
|
||||
if (enabled) {
|
||||
if (!newEnabledTemplated.includes(templateId)) {
|
||||
newEnabledTemplated.push(templateId);
|
||||
}
|
||||
} else {
|
||||
newEnabledTemplated = newEnabledTemplated.filter(
|
||||
(id: string) => id !== templateId,
|
||||
);
|
||||
}
|
||||
onChange("enabledTemplates", newEnabledTemplated);
|
||||
},
|
||||
[onChange, settings.enabledTemplates],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Status templates</h3>
|
||||
<p>
|
||||
Enable predefined templates to quickly add common status
|
||||
workflows
|
||||
</p>
|
||||
<div>
|
||||
{templates.map((template) => (
|
||||
<TemplateItem
|
||||
key={template.id}
|
||||
template={template}
|
||||
isEnabled={settings.enabledTemplates.includes(
|
||||
template.id,
|
||||
)}
|
||||
onToggle={handleTemplateToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
components/SettingsUI.tsx/UISettings.tsx
Normal file
95
components/SettingsUI.tsx/UISettings.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import React from "react";
|
||||
import { Select } from "../atoms/Select";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
|
||||
const handleChange =
|
||||
(key: keyof PluginSettings) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(key, e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ui-settings">
|
||||
<h3>User interface</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Show status bar"
|
||||
description="Display the status bar"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showStatusBar}
|
||||
onChange={handleChange("showStatusBar")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Auto-hide status bar"
|
||||
description="Hide the status bar when status is unknown"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoHideStatusBar}
|
||||
onChange={handleChange("autoHideStatusBar")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Show status icons in file explorer"
|
||||
description="Display status icons in the file explorer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showStatusIconsInExplorer}
|
||||
onChange={handleChange("showStatusIconsInExplorer")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Status icon in file explorer position"
|
||||
description="Change the position of the icon"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ display: "Filename left", value: "file-name-left" },
|
||||
{ display: "Filename right", value: "file-name-right" },
|
||||
{ display: "Absolute right", value: "absolute-right" },
|
||||
]}
|
||||
defaultValue={settings.fileExplorerIconPosition}
|
||||
onChange={(value) =>
|
||||
onChange("fileExplorerIconPosition", value)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Hide unknown status in file explorer"
|
||||
description="Hide status icons for files with unknown status in the file explorer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.hideUnknownStatusInExplorer || false}
|
||||
onChange={handleChange("hideUnknownStatusInExplorer")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Exclude unassigned notes from status pane"
|
||||
description="Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.excludeUnknownStatus || false}
|
||||
onChange={handleChange("excludeUnknownStatus")}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
components/SettingsUI.tsx/index.tsx
Normal file
45
components/SettingsUI.tsx/index.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import { UISettings } from "./UISettings";
|
||||
import { TemplateSettings } from "./TemplateSettings";
|
||||
import { BehaviourSettings } from "./BehaviourSettings";
|
||||
import { CustomStatusSettings } from "./CustomStatusSettings";
|
||||
import { QuickCommandsSettings } from "./QuickCommandsSettings";
|
||||
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
const SettingsUI: React.FC<Props> = ({ settings, onChange }) => {
|
||||
const [localSettings, setLocalSettings] = useState(settings);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings);
|
||||
}, [settings]);
|
||||
|
||||
const handleChange = (key: keyof PluginSettings, value: unknown) => {
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: value }));
|
||||
onChange(key, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="note-status-settings">
|
||||
<TemplateSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
templates={PREDEFINED_TEMPLATES}
|
||||
/>
|
||||
<UISettings settings={localSettings} onChange={handleChange} />
|
||||
<BehaviourSettings settings={settings} onChange={handleChange} />
|
||||
<CustomStatusSettings settings={settings} onChange={handleChange} />
|
||||
<QuickCommandsSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
templates={PREDEFINED_TEMPLATES}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SettingsUI;
|
||||
55
components/StatusBar/StatusBar.tsx
Normal file
55
components/StatusBar/StatusBar.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { FC } from "react";
|
||||
import {
|
||||
StatusBarProvider,
|
||||
Props as StatusBarContextProps,
|
||||
} from "./StatusBarContext";
|
||||
import { StatusBarGroup } from "./StatusBarGroup";
|
||||
|
||||
export type Props = {
|
||||
statuses: GroupedStatuses;
|
||||
templates?: { [key: string]: { description: string } };
|
||||
hideIfNotStatuses?: boolean;
|
||||
onStatusClick: StatusBarContextProps["onStatusClick"];
|
||||
};
|
||||
|
||||
export const StatusBar: FC<Props> = ({
|
||||
statuses,
|
||||
hideIfNotStatuses,
|
||||
onStatusClick,
|
||||
}) => {
|
||||
const statusEntries = Object.entries(statuses);
|
||||
const hasStatuses = statusEntries.flatMap((s) => s[1]).length > 0;
|
||||
|
||||
if (!hasStatuses) {
|
||||
if (hideIfNotStatuses) return null;
|
||||
return (
|
||||
<span
|
||||
className="status-bar-item mod-clickable"
|
||||
onClick={() => onStatusClick({ name: "", icon: "" })}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
No status
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBarProvider onStatusClick={onStatusClick}>
|
||||
<div className="status-bar-group-row">
|
||||
{statusEntries.map(([frontmatterTagName, statusList]) => (
|
||||
<StatusBarGroup
|
||||
key={frontmatterTagName}
|
||||
statuses={statusList}
|
||||
template={{
|
||||
description: "Note status",
|
||||
name: "",
|
||||
statuses: statusList,
|
||||
id: "inANearFutureBeWillGroupStatusesByTemplates",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StatusBarProvider>
|
||||
);
|
||||
};
|
||||
34
components/StatusBar/StatusBarContext.tsx
Normal file
34
components/StatusBar/StatusBarContext.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { createContext, useContext, ReactNode } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
interface StatusBarContextValue {
|
||||
onStatusClick: (status: NoteStatus) => void;
|
||||
}
|
||||
|
||||
const StatusBarContext = createContext<StatusBarContextValue>({
|
||||
onStatusClick: () => {},
|
||||
});
|
||||
|
||||
export const useStatusBarContext = () => useContext(StatusBarContext);
|
||||
|
||||
export type Props = {
|
||||
children: ReactNode;
|
||||
onStatusClick: StatusBarContextValue["onStatusClick"];
|
||||
};
|
||||
|
||||
export const StatusBarProvider: React.FC<Props> = ({
|
||||
children,
|
||||
onStatusClick,
|
||||
}) => {
|
||||
const value = {
|
||||
onStatusClick: (status: NoteStatus) => {
|
||||
onStatusClick(status);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusBarContext.Provider value={value}>
|
||||
{children}
|
||||
</StatusBarContext.Provider>
|
||||
);
|
||||
};
|
||||
17
components/StatusBar/StatusBarEnableButton.tsx
Normal file
17
components/StatusBar/StatusBarEnableButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
61
components/StatusBar/StatusBarGroup.tsx
Normal file
61
components/StatusBar/StatusBarGroup.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { FC, useState } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { StatusTemplate } from "@/types/pluginSettings";
|
||||
import { GroupLabel } from "../atoms/GroupLabel";
|
||||
import { CollapsibleCounter } from "../atoms/CollapsibleCounter";
|
||||
import { StatusDisplay } from "../atoms/StatusDisplay";
|
||||
import { useStatusBarContext } from "./StatusBarContext";
|
||||
|
||||
export interface StatusBarGroupProps {
|
||||
statuses: NoteStatus[];
|
||||
template: StatusTemplate;
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
export const StatusBarGroup: FC<StatusBarGroupProps> = ({
|
||||
statuses,
|
||||
template,
|
||||
maxVisible = 3,
|
||||
}) => {
|
||||
const [isUncollapsed, setIsUncollapsed] = useState(false);
|
||||
const visibleStatuses = isUncollapsed
|
||||
? statuses
|
||||
: statuses.slice(0, maxVisible);
|
||||
const hiddenCount = Math.max(0, statuses.length - maxVisible);
|
||||
const { onStatusClick } = useStatusBarContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<GroupLabel name={template.name} isHighlighted={isUncollapsed} />
|
||||
<div className="status-bar-group-container">
|
||||
{visibleStatuses.map((status, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
animation:
|
||||
isUncollapsed && i >= maxVisible
|
||||
? `status-slide-in var(--anim-duration-fast) ease-out ${i * 0.05}s both`
|
||||
: "none",
|
||||
}}
|
||||
title={status.description}
|
||||
>
|
||||
<StatusDisplay
|
||||
status={status}
|
||||
variant="badge"
|
||||
onClick={() => {
|
||||
onStatusClick(status);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<CollapsibleCounter
|
||||
count={hiddenCount}
|
||||
isCollapsed={!isUncollapsed}
|
||||
onToggle={() => setIsUncollapsed(!isUncollapsed)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
78
components/StatusDashboard/CurrentNoteSection.tsx
Normal file
78
components/StatusDashboard/CurrentNoteSection.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { TFile } from "obsidian";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { StatusDisplay } from "@/components/atoms/StatusDisplay";
|
||||
import { GroupLabel } from "@/components/atoms/GroupLabel";
|
||||
|
||||
interface CurrentNoteInfo {
|
||||
file: TFile | null;
|
||||
statuses: Record<string, NoteStatus[]>;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
interface CurrentNoteSectionProps {
|
||||
currentNote: CurrentNoteInfo;
|
||||
}
|
||||
|
||||
export const CurrentNoteSection = ({
|
||||
currentNote,
|
||||
}: CurrentNoteSectionProps) => {
|
||||
return (
|
||||
<div className="status-dashboard-section">
|
||||
<div className="status-dashboard-section-header">
|
||||
<h3>Current Note</h3>
|
||||
</div>
|
||||
<div className="status-dashboard-current-note">
|
||||
{currentNote.file ? (
|
||||
<>
|
||||
<div className="current-note-info">
|
||||
<div className="current-note-name">
|
||||
{currentNote.file.basename}
|
||||
</div>
|
||||
<div className="current-note-path">
|
||||
{currentNote.file.path}
|
||||
</div>
|
||||
<div className="current-note-modified">
|
||||
Last modified:{" "}
|
||||
{new Date(
|
||||
currentNote.lastModified,
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="current-note-statuses">
|
||||
{Object.entries(currentNote.statuses).map(
|
||||
([tag, statuses]) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="current-note-tag-group"
|
||||
>
|
||||
<GroupLabel name={tag} />
|
||||
<div className="current-note-status-list">
|
||||
{statuses.length > 0 ? (
|
||||
statuses.map((status) => (
|
||||
<StatusDisplay
|
||||
key={`${status.templateId || "custom"}:${status.name}`}
|
||||
status={status}
|
||||
variant="badge"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className="current-note-no-status">
|
||||
No status assigned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="current-note-empty">
|
||||
No note currently active
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
160
components/StatusDashboard/QuickActionsPanel.tsx
Normal file
160
components/StatusDashboard/QuickActionsPanel.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
export type DashboardAction =
|
||||
| "refresh"
|
||||
| "open-grouped-view"
|
||||
| "find-unassigned"
|
||||
| "change-status"
|
||||
| "cycle-status"
|
||||
| "clear-status"
|
||||
| "copy-status"
|
||||
| "paste-status"
|
||||
| "search-by-status"
|
||||
| "search-by-specific-status"
|
||||
| "toggle-multiple-mode"
|
||||
| "set-quick-status";
|
||||
|
||||
interface QuickActionsPanelProps {
|
||||
hasCurrentFile: boolean;
|
||||
hasCurrentNoteStatuses: boolean;
|
||||
useMultipleStatuses: boolean;
|
||||
quickStatusCommands: string[];
|
||||
onAction: (action: DashboardAction, value?: string) => void;
|
||||
}
|
||||
|
||||
export const QuickActionsPanel = ({
|
||||
hasCurrentFile,
|
||||
hasCurrentNoteStatuses,
|
||||
useMultipleStatuses,
|
||||
quickStatusCommands,
|
||||
onAction,
|
||||
}: QuickActionsPanelProps) => {
|
||||
return (
|
||||
<div className="status-dashboard-section">
|
||||
<div className="status-dashboard-section-header">
|
||||
<h3>Quick Actions</h3>
|
||||
</div>
|
||||
<div className="quick-actions">
|
||||
{/* Views Group */}
|
||||
<div className="quick-actions-group">
|
||||
<div className="quick-actions-group-title">📊 Views</div>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => onAction("open-grouped-view")}
|
||||
title="Open grouped status view"
|
||||
>
|
||||
📊 Grouped Statuses
|
||||
</button>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => onAction("find-unassigned")}
|
||||
title="Find notes without status"
|
||||
>
|
||||
🔍 Find Unassigned Notes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Status Group - Only show when not in multi-status mode and has quick statuses */}
|
||||
{!useMultipleStatuses && quickStatusCommands.length > 0 && (
|
||||
<div className="quick-actions-group">
|
||||
<div className="quick-actions-group-title">
|
||||
⚡ Quick Status
|
||||
</div>
|
||||
{quickStatusCommands.map((statusName) => (
|
||||
<button
|
||||
key={statusName}
|
||||
className="quick-action-btn"
|
||||
onClick={() =>
|
||||
onAction("set-quick-status", statusName)
|
||||
}
|
||||
disabled={!hasCurrentFile}
|
||||
title={`Set status to ${statusName}`}
|
||||
>
|
||||
⚡ {statusName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Note Group */}
|
||||
<div className="quick-actions-group">
|
||||
<div className="quick-actions-group-title">
|
||||
📝 Current Note
|
||||
</div>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => onAction("change-status")}
|
||||
disabled={!hasCurrentFile}
|
||||
title="Change status of current note"
|
||||
>
|
||||
📝 Change Status
|
||||
</button>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => onAction("cycle-status")}
|
||||
disabled={!hasCurrentFile || useMultipleStatuses}
|
||||
title="Cycle through available statuses"
|
||||
>
|
||||
📝 Cycle Status
|
||||
</button>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => onAction("clear-status")}
|
||||
disabled={!hasCurrentFile}
|
||||
title="Clear status from current note"
|
||||
>
|
||||
📝 Clear Status
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Clipboard Group */}
|
||||
<div className="quick-actions-group">
|
||||
<div className="quick-actions-group-title">
|
||||
📋 Clipboard
|
||||
</div>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => onAction("copy-status")}
|
||||
disabled={!hasCurrentFile}
|
||||
title="Copy status to clipboard"
|
||||
>
|
||||
📋 Copy Status
|
||||
</button>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => onAction("paste-status")}
|
||||
disabled={!hasCurrentFile}
|
||||
title="Paste status from clipboard"
|
||||
>
|
||||
📋 Paste Status
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tools Group */}
|
||||
<div className="quick-actions-group">
|
||||
<div className="quick-actions-group-title">⚙️ Tools</div>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => onAction("search-by-status")}
|
||||
disabled={!hasCurrentFile || !hasCurrentNoteStatuses}
|
||||
title="Search for notes with same status"
|
||||
>
|
||||
⚙️ Search by Status
|
||||
</button>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => onAction("toggle-multiple-mode")}
|
||||
title="Toggle multiple statuses mode"
|
||||
>
|
||||
⚙️ Toggle Multi-Status
|
||||
</button>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => onAction("refresh")}
|
||||
title="Refresh dashboard data"
|
||||
>
|
||||
⚙️ Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
83
components/StatusDashboard/StatusDashboard.tsx
Normal file
83
components/StatusDashboard/StatusDashboard.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { VaultStatsCard } from "./VaultStatsCard";
|
||||
import { CurrentNoteSection } from "./CurrentNoteSection";
|
||||
import { StatusDistributionChart } from "./StatusDistributionChart";
|
||||
import { QuickActionsPanel, DashboardAction } from "./QuickActionsPanel";
|
||||
import { VaultStats } from "./useVaultStats";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import { TFile } from "obsidian";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
interface CurrentNoteInfo {
|
||||
file: TFile | null;
|
||||
statuses: Record<string, NoteStatus[]>;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
interface StatusDashboardProps {
|
||||
onAction: (action: DashboardAction, value?: string) => void;
|
||||
settings: PluginSettings;
|
||||
vaultStats: VaultStats;
|
||||
currentNote: CurrentNoteInfo;
|
||||
isLoading: boolean;
|
||||
availableStatuses: NoteStatus[];
|
||||
}
|
||||
|
||||
export const StatusDashboard = ({
|
||||
onAction,
|
||||
settings,
|
||||
vaultStats,
|
||||
currentNote,
|
||||
isLoading,
|
||||
availableStatuses,
|
||||
}: StatusDashboardProps) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="status-dashboard">
|
||||
<div className="status-dashboard-loading">
|
||||
Loading dashboard...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if current note has any statuses
|
||||
const hasCurrentNoteStatuses =
|
||||
currentNote.file &&
|
||||
Object.values(currentNote.statuses).some(
|
||||
(statuses) => statuses.length > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="status-dashboard">
|
||||
<div className="status-dashboard-header">
|
||||
<h2 className="status-dashboard-title">Status Dashboard</h2>
|
||||
<button
|
||||
className="status-dashboard-refresh"
|
||||
onClick={() => onAction("refresh")}
|
||||
title="Refresh Dashboard"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="status-dashboard-content">
|
||||
<CurrentNoteSection currentNote={currentNote} />
|
||||
<VaultStatsCard vaultStats={vaultStats} />
|
||||
<StatusDistributionChart
|
||||
vaultStats={vaultStats}
|
||||
availableStatuses={availableStatuses}
|
||||
onStatusClick={(statusName) =>
|
||||
onAction("search-by-specific-status", statusName)
|
||||
}
|
||||
/>
|
||||
<QuickActionsPanel
|
||||
hasCurrentFile={!!currentNote.file}
|
||||
hasCurrentNoteStatuses={!!hasCurrentNoteStatuses}
|
||||
useMultipleStatuses={settings.useMultipleStatuses}
|
||||
quickStatusCommands={settings.quickStatusCommands}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
126
components/StatusDashboard/StatusDistributionChart.tsx
Normal file
126
components/StatusDashboard/StatusDistributionChart.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { useMemo } from "react";
|
||||
import { StatusDisplay } from "@/components/atoms/StatusDisplay";
|
||||
import { VaultStats } from "./useVaultStats";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
interface StatusDistributionChartProps {
|
||||
vaultStats: VaultStats;
|
||||
availableStatuses: NoteStatus[];
|
||||
onStatusClick?: (statusName: string) => void;
|
||||
}
|
||||
|
||||
export const StatusDistributionChart = ({
|
||||
vaultStats,
|
||||
availableStatuses,
|
||||
onStatusClick,
|
||||
}: StatusDistributionChartProps) => {
|
||||
const statusChart = useMemo(() => {
|
||||
const total = Object.values(vaultStats.statusDistribution).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
if (total === 0) return [];
|
||||
|
||||
return Object.entries(vaultStats.statusDistribution)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([statusName, count]) => ({
|
||||
name: statusName,
|
||||
count,
|
||||
percentage: Math.round((count / total) * 100),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [vaultStats.statusDistribution]);
|
||||
|
||||
const statusMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
availableStatuses.forEach((s) => {
|
||||
const scopedIdentifier = s.templateId
|
||||
? `${s.templateId}:${s.name}`
|
||||
: s.name;
|
||||
map.set(scopedIdentifier, s);
|
||||
});
|
||||
|
||||
// Also handle the case where we need to map legacy statuses to their first template
|
||||
Object.keys(vaultStats.statusDistribution).forEach(
|
||||
(statusIdentifier) => {
|
||||
if (!map.has(statusIdentifier)) {
|
||||
// This might be a legacy status that got assigned to a template
|
||||
if (statusIdentifier.includes(":")) {
|
||||
const [templateId, statusName] =
|
||||
statusIdentifier.split(":");
|
||||
const templateStatus = availableStatuses.find(
|
||||
(s) =>
|
||||
s.templateId === templateId &&
|
||||
s.name === statusName,
|
||||
);
|
||||
if (templateStatus) {
|
||||
map.set(statusIdentifier, templateStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return map;
|
||||
}, [availableStatuses, vaultStats.statusDistribution]);
|
||||
|
||||
return (
|
||||
<div className="status-dashboard-section">
|
||||
<div className="status-dashboard-section-header">
|
||||
<h3>Status Distribution</h3>
|
||||
</div>
|
||||
<div className="status-distribution">
|
||||
{statusChart.length > 0 ? (
|
||||
<div className="status-chart">
|
||||
{statusChart.map(({ name, count, percentage }) => {
|
||||
const status = statusMap.get(name);
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`status-chart-item ${onStatusClick ? "status-chart-item--clickable" : ""}`}
|
||||
onClick={() => onStatusClick?.(name)}
|
||||
title={
|
||||
onStatusClick
|
||||
? `Click to search for notes with status: ${name}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="status-chart-info">
|
||||
{status ? (
|
||||
<StatusDisplay
|
||||
status={status}
|
||||
variant="badge"
|
||||
/>
|
||||
) : (
|
||||
<span className="status-unknown-badge">
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<span className="status-chart-count">
|
||||
{count} notes ({percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-chart-bar">
|
||||
<div
|
||||
className="status-chart-fill"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor:
|
||||
status?.color ||
|
||||
"var(--interactive-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="status-distribution-empty">
|
||||
No status data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
components/StatusDashboard/VaultStatsCard.tsx
Normal file
54
components/StatusDashboard/VaultStatsCard.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { VaultStats } from "./useVaultStats";
|
||||
|
||||
interface VaultStatsCardProps {
|
||||
vaultStats: VaultStats;
|
||||
}
|
||||
|
||||
export const VaultStatsCard = ({ vaultStats }: VaultStatsCardProps) => {
|
||||
return (
|
||||
<div className="status-dashboard-section">
|
||||
<div className="status-dashboard-section-header">
|
||||
<h3>Vault Overview</h3>
|
||||
</div>
|
||||
<div className="vault-overview">
|
||||
<div className="vault-stats-grid">
|
||||
<div className="vault-stat-card">
|
||||
<div className="vault-stat-number">
|
||||
{vaultStats.totalNotes}
|
||||
</div>
|
||||
<div className="vault-stat-label">Total Notes</div>
|
||||
</div>
|
||||
<div className="vault-stat-card">
|
||||
<div className="vault-stat-number">
|
||||
{vaultStats.notesWithStatus}
|
||||
</div>
|
||||
<div className="vault-stat-label">
|
||||
Notes with Status
|
||||
</div>
|
||||
</div>
|
||||
<div className="vault-stat-card">
|
||||
<div className="vault-stat-number">
|
||||
{vaultStats.totalNotes > 0
|
||||
? Math.round(
|
||||
(vaultStats.notesWithStatus /
|
||||
vaultStats.totalNotes) *
|
||||
100,
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</div>
|
||||
<div className="vault-stat-label">Coverage</div>
|
||||
</div>
|
||||
<div className="vault-stat-card">
|
||||
<div className="vault-stat-number">
|
||||
{Object.values(
|
||||
vaultStats.statusDistribution,
|
||||
).reduce((sum, count) => sum + count, 0)}
|
||||
</div>
|
||||
<div className="vault-stat-label">Total Statuses</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
components/StatusDashboard/useCurrentNote.tsx
Normal file
44
components/StatusDashboard/useCurrentNote.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { TFile } from "obsidian";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import {
|
||||
BaseNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
|
||||
interface CurrentNoteInfo {
|
||||
file: TFile | null;
|
||||
statuses: Record<string, NoteStatus[]>;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
export const useCurrentNote = () => {
|
||||
const [currentNote, setCurrentNote] = useState<CurrentNoteInfo>({
|
||||
file: null,
|
||||
statuses: {},
|
||||
lastModified: 0,
|
||||
});
|
||||
|
||||
const updateCurrentNote = useCallback(() => {
|
||||
const activeFile = BaseNoteStatusService.app.workspace.getActiveFile();
|
||||
|
||||
if (!activeFile) {
|
||||
setCurrentNote({ file: null, statuses: {}, lastModified: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const noteStatusService = new NoteStatusService(activeFile);
|
||||
noteStatusService.populateStatuses();
|
||||
|
||||
setCurrentNote({
|
||||
file: activeFile,
|
||||
statuses: noteStatusService.statuses,
|
||||
lastModified: activeFile.stat.mtime,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentNote,
|
||||
updateCurrentNote,
|
||||
};
|
||||
};
|
||||
121
components/StatusDashboard/useVaultStats.tsx
Normal file
121
components/StatusDashboard/useVaultStats.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { TFile } from "obsidian";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { BaseNoteStatusService } from "@/core/noteStatusService";
|
||||
import settingsService from "@/core/settingsService";
|
||||
|
||||
export interface VaultStats {
|
||||
totalNotes: number;
|
||||
notesWithStatus: number;
|
||||
statusDistribution: Record<string, number>;
|
||||
tagDistribution: Record<string, number>;
|
||||
recentChanges: Array<{
|
||||
file: TFile;
|
||||
status: NoteStatus;
|
||||
timestamp: number;
|
||||
action: "added" | "removed";
|
||||
}>;
|
||||
}
|
||||
|
||||
export const useVaultStats = () => {
|
||||
const [vaultStats, setVaultStats] = useState<VaultStats>({
|
||||
totalNotes: 0,
|
||||
notesWithStatus: 0,
|
||||
statusDistribution: {},
|
||||
tagDistribution: {},
|
||||
recentChanges: [],
|
||||
});
|
||||
|
||||
const calculateVaultStats = useCallback((): VaultStats => {
|
||||
const files = BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
|
||||
let notesWithStatus = 0;
|
||||
const statusDistribution: Record<string, number> = {};
|
||||
const tagDistribution: Record<string, number> = {};
|
||||
|
||||
// Initialize distribution using full scoped identifiers
|
||||
availableStatuses.forEach((status) => {
|
||||
const scopedIdentifier = status.templateId
|
||||
? `${status.templateId}:${status.name}`
|
||||
: status.name;
|
||||
statusDistribution[scopedIdentifier] = 0;
|
||||
});
|
||||
|
||||
statusMetadataKeys.forEach((tag) => {
|
||||
tagDistribution[tag] = 0;
|
||||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
let hasAnyStatus = false;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
hasAnyStatus = true;
|
||||
tagDistribution[key]++;
|
||||
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
|
||||
// Determine the scoped identifier to use
|
||||
let scopedIdentifier: string;
|
||||
|
||||
if (statusStr.includes(":")) {
|
||||
// Already scoped
|
||||
scopedIdentifier = statusStr;
|
||||
} else {
|
||||
// Legacy status - find first template that has this status
|
||||
const firstTemplateWithStatus =
|
||||
availableStatuses.find(
|
||||
(s) => s.name === statusStr && s.templateId,
|
||||
);
|
||||
scopedIdentifier = firstTemplateWithStatus
|
||||
? `${firstTemplateWithStatus.templateId}:${statusStr}`
|
||||
: statusStr; // Fallback to unscoped if no template found
|
||||
}
|
||||
|
||||
// Initialize status if not already present
|
||||
if (
|
||||
!statusDistribution.hasOwnProperty(scopedIdentifier)
|
||||
) {
|
||||
statusDistribution[scopedIdentifier] = 0;
|
||||
}
|
||||
statusDistribution[scopedIdentifier]++;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (hasAnyStatus) {
|
||||
notesWithStatus++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalNotes: files.length,
|
||||
notesWithStatus,
|
||||
statusDistribution,
|
||||
tagDistribution,
|
||||
recentChanges: [],
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateVaultStats = useCallback(() => {
|
||||
const stats = calculateVaultStats();
|
||||
setVaultStats(stats);
|
||||
}, [calculateVaultStats]);
|
||||
|
||||
return {
|
||||
vaultStats,
|
||||
updateVaultStats,
|
||||
};
|
||||
};
|
||||
67
components/StatusFileInfoPopup/StatusFileInfoPopup.tsx
Normal file
67
components/StatusFileInfoPopup/StatusFileInfoPopup.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React from "react";
|
||||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { StatusDisplay } from "../atoms/StatusDisplay";
|
||||
|
||||
export interface Props {
|
||||
statuses: GroupedStatuses;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const StatusFileInfoPopup: React.FC<Props> = ({ statuses }) => {
|
||||
const statusEntries = Object.entries(statuses);
|
||||
|
||||
if (statusEntries.length === 0) {
|
||||
return (
|
||||
<div className="status-popup-empty">
|
||||
<span className="status-empty-icon">📋</span>
|
||||
No statuses found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="status-info-popup" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="status-popup-header">
|
||||
<span className="status-header-icon">📊</span>
|
||||
Status Overview
|
||||
</div>
|
||||
|
||||
<div className="status-popup-content">
|
||||
{statusEntries.map(([groupName, statusList]) => (
|
||||
<div key={groupName} className="status-group">
|
||||
<div className="status-group-header">
|
||||
<span className="status-group-name">
|
||||
{groupName.toLowerCase()}
|
||||
</span>
|
||||
<span className="status-group-count">
|
||||
{statusList.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="status-group-items">
|
||||
{statusList.map((status, index) => (
|
||||
<div
|
||||
key={`${groupName}-${index}`}
|
||||
className="status-item"
|
||||
>
|
||||
<StatusDisplay
|
||||
status={status}
|
||||
variant="badge"
|
||||
/>
|
||||
{status.description && (
|
||||
<div
|
||||
className="status-description"
|
||||
title={status.description}
|
||||
>
|
||||
{status.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
25
components/atoms/CollapsibleCounter.tsx
Normal file
25
components/atoms/CollapsibleCounter.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { FC } from "react";
|
||||
|
||||
export interface CollapsibleCounterProps {
|
||||
count: number;
|
||||
isCollapsed: boolean;
|
||||
onToggle: React.MouseEventHandler<HTMLSpanElement>;
|
||||
}
|
||||
|
||||
export const CollapsibleCounter: FC<CollapsibleCounterProps> = ({
|
||||
count,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
}) => {
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="mod-clickable collapsible-counter-container"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{isCollapsed ? "+" : "-"}
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
42
components/atoms/GroupLabel.tsx
Normal file
42
components/atoms/GroupLabel.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { FC } from "react";
|
||||
|
||||
export interface GroupLabelProps {
|
||||
name: string;
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
export const GroupLabel: FC<GroupLabelProps> = ({
|
||||
name,
|
||||
isHighlighted = false,
|
||||
}) => {
|
||||
if (!name) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="setting-item-name group-label-container"
|
||||
style={{
|
||||
color: isHighlighted
|
||||
? "var(--text-normal)"
|
||||
: "var(--text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-folder"
|
||||
>
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
94
components/atoms/Input.tsx
Normal file
94
components/atoms/Input.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React from "react";
|
||||
|
||||
export type InputVariant = "text" | "color" | "search";
|
||||
|
||||
interface BaseInputProps {
|
||||
variant: InputVariant;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
interface TextInputProps extends BaseInputProps {
|
||||
variant: "text";
|
||||
}
|
||||
|
||||
interface ColorInputProps extends BaseInputProps {
|
||||
variant: "color";
|
||||
}
|
||||
|
||||
interface SearchInputProps extends BaseInputProps {
|
||||
variant: "search";
|
||||
}
|
||||
|
||||
export type InputProps = TextInputProps | ColorInputProps | SearchInputProps;
|
||||
|
||||
const getInputStyles = (variant: InputVariant): React.CSSProperties => {
|
||||
const baseStyles: React.CSSProperties = {
|
||||
border: "1px solid var(--background-modifier-border)",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "var(--background-primary)",
|
||||
color: "var(--text-normal)",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
switch (variant) {
|
||||
case "text":
|
||||
case "search":
|
||||
return {
|
||||
...baseStyles,
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
};
|
||||
case "color":
|
||||
return {
|
||||
...baseStyles,
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
padding: "2px",
|
||||
cursor: "pointer",
|
||||
};
|
||||
default:
|
||||
return baseStyles;
|
||||
}
|
||||
};
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
variant,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
style,
|
||||
onFocus,
|
||||
onBlur,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputStyles = getInputStyles(variant);
|
||||
const combinedStyles = { ...inputStyles, ...style };
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={variant === "color" ? "color" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
style={combinedStyles}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
24
components/atoms/SearchFilter.tsx
Normal file
24
components/atoms/SearchFilter.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from "react";
|
||||
import { Input } from "./Input";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onFilterChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const SearchFilter = React.forwardRef<HTMLInputElement, Props>(
|
||||
({ value, onFilterChange, placeholder = "Search..." }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
variant="search"
|
||||
value={value}
|
||||
onChange={onFilterChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SearchFilter.displayName = "SearchFilter";
|
||||
26
components/atoms/Select.tsx
Normal file
26
components/atoms/Select.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React, { ReactNode } from "react";
|
||||
|
||||
type SelectProps = {
|
||||
options: { value: string | number; display: ReactNode }[];
|
||||
onChange: (value: string) => void;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({
|
||||
options,
|
||||
onChange,
|
||||
defaultValue,
|
||||
}) => {
|
||||
return (
|
||||
<select
|
||||
defaultValue={defaultValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.display}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
100
components/atoms/SelectableListItem.tsx
Normal file
100
components/atoms/SelectableListItem.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React, { ReactNode, useState } from "react";
|
||||
|
||||
interface SelectableListItemProps {
|
||||
selected?: boolean;
|
||||
focused?: boolean;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const SelectableListItem: React.FC<SelectableListItemProps> = ({
|
||||
selected = false,
|
||||
focused = false,
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
className = "",
|
||||
title,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
setTimeout(() => {
|
||||
onClick();
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`selectable-list-item ${className}`}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
title={title}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "8px 12px",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
borderBottom: "1px solid var(--background-modifier-border)",
|
||||
transition: "background-color 150ms ease",
|
||||
background:
|
||||
selected || isHovered || focused
|
||||
? "var(--background-modifier-hover)"
|
||||
: "",
|
||||
outline: focused
|
||||
? "2px solid var(--interactive-accent)"
|
||||
: "none",
|
||||
outlineOffset: "-2px",
|
||||
}}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
className="selectable-list-item-icon"
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
minWidth: "20px",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="selectable-list-item-content"
|
||||
style={{
|
||||
flex: "1",
|
||||
fontSize: "var(--font-ui-small)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{selected && (
|
||||
<div
|
||||
className="selectable-list-item-check"
|
||||
style={{
|
||||
color: "var(--interactive-accent)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
143
components/atoms/StatusDisplay.tsx
Normal file
143
components/atoms/StatusDisplay.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { FC, memo, useState } from "react";
|
||||
import { getStatusTooltip } from "@/utils/statusUtils";
|
||||
|
||||
export type StatusDisplayVariant = "chip" | "badge" | "template";
|
||||
|
||||
interface StatusDisplayProps {
|
||||
status: NoteStatus;
|
||||
variant: StatusDisplayVariant;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const StatusDisplay: FC<StatusDisplayProps> = memo(
|
||||
({ status, variant, removable = false, onRemove, onClick }) => {
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
const getDisplayName = () => {
|
||||
return status.templateId
|
||||
? `${status.name} (${status.templateId})`
|
||||
: status.name;
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onRemove) {
|
||||
setIsRemoving(true);
|
||||
setTimeout(() => {
|
||||
onRemove();
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
if (variant === "chip") {
|
||||
return (
|
||||
<div
|
||||
className="note-status-chip"
|
||||
title={getStatusTooltip(status)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "4px 8px",
|
||||
background: "var(--interactive-accent)",
|
||||
color: "var(--text-on-accent)",
|
||||
borderRadius: "var(--radius-s)",
|
||||
fontSize: "var(--font-ui-smaller)",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
transition: "all 150ms ease",
|
||||
opacity: isRemoving ? "0.5" : "1",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="note-status-chip-icon">
|
||||
{status.icon ? status.icon : "📝"}
|
||||
</span>
|
||||
<span className="note-status-chip-text">
|
||||
{getDisplayName()}
|
||||
</span>
|
||||
{removable && (
|
||||
<div
|
||||
className="note-status-chip-remove"
|
||||
onClick={handleRemove}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "badge") {
|
||||
return (
|
||||
<div
|
||||
className="status-badge-container"
|
||||
style={{
|
||||
backgroundColor: `${status.color}15`,
|
||||
border: `1px solid ${status.color}30`,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="status-badge-item">
|
||||
<span className="status-badge-icon">
|
||||
{status.icon ? status.icon : "📝"}
|
||||
</span>
|
||||
<span className="status-badge-text">
|
||||
{getDisplayName()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "template") {
|
||||
return (
|
||||
<div className="template-status-chip" onClick={handleClick}>
|
||||
<span
|
||||
className="template-status-color-dot"
|
||||
style={
|
||||
{
|
||||
"--dot-color": status.color,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{status.icon ? status.icon : "📝"} {getDisplayName()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
79
components/atoms/StatusSelector.tsx
Normal file
79
components/atoms/StatusSelector.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import React, { memo, useCallback } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { SelectableListItem } from "./SelectableListItem";
|
||||
import { getStatusTooltip, isStatusSelected } from "@/utils/statusUtils";
|
||||
|
||||
interface StatusOptionProps {
|
||||
status: NoteStatus;
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export const StatusModalOption: React.FC<StatusOptionProps> = memo(
|
||||
({ status, isSelected, isFocused, onSelect }) => {
|
||||
const displayName = status.templateId
|
||||
? `${status.name} (${status.templateId})`
|
||||
: status.name;
|
||||
|
||||
return (
|
||||
<SelectableListItem
|
||||
selected={isSelected}
|
||||
focused={isFocused}
|
||||
icon={status.icon}
|
||||
onClick={onSelect}
|
||||
className="note-status-option"
|
||||
title={
|
||||
status.description ? getStatusTooltip(status) : undefined
|
||||
}
|
||||
>
|
||||
{displayName}
|
||||
</SelectableListItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export interface Props {
|
||||
currentStatuses: NoteStatus[];
|
||||
availableStatuses: NoteStatus[];
|
||||
focusedIndex?: number;
|
||||
onToggleStatus: (status: NoteStatus, selected: boolean) => void;
|
||||
}
|
||||
|
||||
export const StatusSelector: React.FC<Props> = ({
|
||||
currentStatuses,
|
||||
availableStatuses,
|
||||
focusedIndex = -1,
|
||||
onToggleStatus,
|
||||
}) => {
|
||||
const handleSelectStatus = useCallback(
|
||||
async (status: NoteStatus) => {
|
||||
const selected = isStatusSelected(status, currentStatuses);
|
||||
onToggleStatus(status, !selected);
|
||||
},
|
||||
[currentStatuses, onToggleStatus],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="note-status-options"
|
||||
style={{
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
border: "1px solid var(--background-modifier-border)",
|
||||
borderRadius: "var(--radius-s)",
|
||||
background: "var(--background-primary)",
|
||||
}}
|
||||
>
|
||||
{availableStatuses.map((status, index) => (
|
||||
<StatusModalOption
|
||||
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}`}
|
||||
status={status}
|
||||
isSelected={isStatusSelected(status, currentStatuses)}
|
||||
isFocused={index === focusedIndex}
|
||||
onSelect={() => handleSelectStatus(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { StatusBarController } from "./status-bar-controller";
|
||||
|
||||
export { StatusBarController as StatusBar };
|
||||
export default StatusBarController;
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { StatusService } from "../../services/status-service";
|
||||
import { StatusBarView } from "./status-bar-view";
|
||||
|
||||
/**
|
||||
* Controller for the status bar
|
||||
*/
|
||||
export class StatusBarController {
|
||||
private view: StatusBarView;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private currentStatuses: string[] = ["unknown"];
|
||||
|
||||
constructor(
|
||||
statusBarContainer: HTMLElement,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
) {
|
||||
this.view = new StatusBarView(statusBarContainer);
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
|
||||
this.update(["unknown"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status bar with new statuses
|
||||
*/
|
||||
public update(statuses: string[]): void {
|
||||
this.currentStatuses = statuses;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the status bar
|
||||
*/
|
||||
private render(): void {
|
||||
this.view.reset();
|
||||
|
||||
if (!this.settings.showStatusBar) {
|
||||
this.view.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.settings.useMultipleStatuses) {
|
||||
this.renderStatuses([this.currentStatuses[0]]);
|
||||
} else {
|
||||
this.renderStatuses(this.currentStatuses);
|
||||
}
|
||||
|
||||
this.handleAutoHide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render statuses - handles both single and multiple status cases
|
||||
*/
|
||||
private renderStatuses(statuses: string[]): void {
|
||||
const statusDetails = statuses.map((status) => {
|
||||
const statusObj = this.statusService
|
||||
.getAllStatuses()
|
||||
.find((s) => s.name === status);
|
||||
return {
|
||||
name: status,
|
||||
icon: this.statusService.getStatusIcon(status),
|
||||
tooltipText: statusObj?.description
|
||||
? `${status} - ${statusObj.description}`
|
||||
: status,
|
||||
};
|
||||
});
|
||||
|
||||
this.view.renderStatuses(statusDetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auto-hide behavior
|
||||
*/
|
||||
private handleAutoHide(): void {
|
||||
const onlyUnknown =
|
||||
this.currentStatuses.length === 1 &&
|
||||
this.currentStatuses[0] === "unknown";
|
||||
|
||||
if (this.settings.autoHideStatusBar && onlyUnknown) {
|
||||
this.view.hide();
|
||||
} else {
|
||||
this.view.show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
this.view.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import { setTooltip } from "obsidian";
|
||||
|
||||
/**
|
||||
* Renders the status bar UI
|
||||
*/
|
||||
export class StatusBarView {
|
||||
private element: HTMLElement;
|
||||
|
||||
constructor(element: HTMLElement) {
|
||||
this.element = element;
|
||||
this.element.addClass("note-status-bar");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the element and resets CSS classes
|
||||
*/
|
||||
reset(): void {
|
||||
this.element.empty();
|
||||
this.element.removeClass("left", "hidden", "auto-hide", "visible");
|
||||
this.element.addClass("note-status-bar");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the status bar
|
||||
*/
|
||||
hide(): void {
|
||||
this.element.addClass("hidden");
|
||||
this.element.removeClass("visible");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the status bar
|
||||
*/
|
||||
show(): void {
|
||||
this.element.removeClass("hidden");
|
||||
this.element.addClass("visible");
|
||||
}
|
||||
|
||||
renderStatuses(
|
||||
statuses: Array<{ name: string; icon: string; tooltipText: string }>,
|
||||
): void {
|
||||
if (statuses.length === 1) {
|
||||
this.renderSingleStatus(
|
||||
statuses[0].name,
|
||||
statuses[0].icon,
|
||||
statuses[0].tooltipText,
|
||||
);
|
||||
} else {
|
||||
this.renderMultipleStatuses(statuses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single status
|
||||
*/
|
||||
private renderSingleStatus(
|
||||
status: string,
|
||||
icon: string,
|
||||
tooltipText: string,
|
||||
): void {
|
||||
const statusText = this.element.createEl("span", {
|
||||
text: `Status: ${status}`,
|
||||
cls: `note-status-${status}`,
|
||||
});
|
||||
setTooltip(statusText, tooltipText);
|
||||
|
||||
const statusIcon = this.element.createEl("span", {
|
||||
text: icon,
|
||||
cls: `note-status-icon status-${status}`,
|
||||
});
|
||||
setTooltip(statusIcon, tooltipText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render multiple statuses
|
||||
*/
|
||||
private renderMultipleStatuses(
|
||||
statuses: Array<{ name: string; icon: string; tooltipText: string }>,
|
||||
): void {
|
||||
this.element.createEl("span", {
|
||||
text: "Statuses: ",
|
||||
cls: "note-status-label",
|
||||
});
|
||||
|
||||
const badgesContainer = this.element.createEl("span", {
|
||||
cls: "note-status-badges",
|
||||
});
|
||||
|
||||
statuses.forEach((status) =>
|
||||
this.createStatusBadge(badgesContainer, status),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a status badge for multiple status display
|
||||
*/
|
||||
private createStatusBadge(
|
||||
container: HTMLElement,
|
||||
status: { name: string; icon: string; tooltipText: string },
|
||||
): void {
|
||||
const badge = container.createEl("span", {
|
||||
cls: `note-status-badge status-${status.name}`,
|
||||
});
|
||||
setTooltip(badge, status.tooltipText);
|
||||
|
||||
badge.createEl("span", {
|
||||
text: status.icon,
|
||||
cls: "note-status-badge-icon",
|
||||
});
|
||||
|
||||
badge.createEl("span", {
|
||||
text: status.name,
|
||||
cls: "note-status-badge-text",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the element
|
||||
*/
|
||||
destroy(): void {
|
||||
this.element.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/**
|
||||
* Event handlers setup and cleanup for the dropdown
|
||||
*/
|
||||
|
||||
/**
|
||||
* Setup event handlers for the dropdown
|
||||
*/
|
||||
export function setupDropdownEvents(options: {
|
||||
clickOutsideHandler: (e: MouseEvent) => void;
|
||||
escapeKeyHandler: (e: KeyboardEvent) => void;
|
||||
}): void {
|
||||
const { clickOutsideHandler, escapeKeyHandler } = options;
|
||||
|
||||
document.addEventListener("click", clickOutsideHandler);
|
||||
document.addEventListener("keydown", escapeKeyHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dropdown event handlers
|
||||
*/
|
||||
export function removeDropdownEvents(options: {
|
||||
clickOutsideHandler: (e: MouseEvent) => void;
|
||||
escapeKeyHandler: (e: KeyboardEvent) => void;
|
||||
}): void {
|
||||
const { clickOutsideHandler, escapeKeyHandler } = options;
|
||||
|
||||
document.removeEventListener("click", clickOutsideHandler);
|
||||
document.removeEventListener("keydown", escapeKeyHandler);
|
||||
}
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
import { MarkdownView, Editor, Notice, TFile, App } from "obsidian";
|
||||
import { DropdownUI } from "./dropdown-ui";
|
||||
import { DropdownOptions, DropdownDependencies } from "./types";
|
||||
import { createDummyTarget } from "./dropdown-position";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
|
||||
/**
|
||||
* High-level manager for status dropdown interactions
|
||||
*/
|
||||
export class DropdownManager {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private currentStatuses: string[] = ["unknown"];
|
||||
private toolbarButton?: HTMLElement;
|
||||
private dropdownUI: DropdownUI;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
|
||||
const deps: DropdownDependencies = { app, settings, statusService };
|
||||
this.dropdownUI = new DropdownUI(deps);
|
||||
this.setupDropdownCallbacks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up dropdown callbacks
|
||||
*/
|
||||
private setupDropdownCallbacks(): void {
|
||||
this.dropdownUI.setOnRemoveStatusHandler(async (status, targetFile) => {
|
||||
if (!targetFile) return;
|
||||
|
||||
const isMultiple = Array.isArray(targetFile);
|
||||
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
operation: "remove",
|
||||
showNotice: isMultiple,
|
||||
});
|
||||
});
|
||||
|
||||
this.dropdownUI.setOnSelectStatusHandler(async (status, targetFile) => {
|
||||
// Check if handling multiple files
|
||||
const isMultipleFiles =
|
||||
Array.isArray(targetFile) && targetFile.length > 1;
|
||||
|
||||
if (isMultipleFiles) {
|
||||
const files = targetFile as TFile[];
|
||||
// Count how many files already have this status
|
||||
const filesWithStatus = files.filter((file) =>
|
||||
this.statusService.getFileStatuses(file).includes(status),
|
||||
);
|
||||
|
||||
// If ALL have the status, remove it. Otherwise, add it
|
||||
const operation =
|
||||
filesWithStatus.length === files.length
|
||||
? "remove"
|
||||
: !this.settings.useMultipleStatuses
|
||||
? "set"
|
||||
: "add";
|
||||
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
isMultipleSelection: true,
|
||||
operation: operation,
|
||||
});
|
||||
} else {
|
||||
// For individual files, maintain default behavior
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the dropdown UI based on current statuses
|
||||
*/
|
||||
public update(currentStatuses: string[] | string, _file?: TFile): void {
|
||||
this.currentStatuses = Array.isArray(currentStatuses)
|
||||
? [...currentStatuses]
|
||||
: [currentStatuses];
|
||||
|
||||
this.dropdownUI.updateStatuses(this.currentStatuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.dropdownUI.updateSettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position from cursor or fallback
|
||||
*/
|
||||
private getCursorPosition(
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
): { x: number; y: number } {
|
||||
try {
|
||||
const cursor = editor.getCursor("head");
|
||||
const lineElement = view.contentEl.querySelector(
|
||||
`.cm-line:nth-child(${cursor.line + 1})`,
|
||||
);
|
||||
|
||||
if (lineElement) {
|
||||
const rect = lineElement.getBoundingClientRect();
|
||||
return { x: rect.left + 20, y: rect.bottom + 5 };
|
||||
}
|
||||
|
||||
const editorEl = view.contentEl.querySelector(".cm-editor");
|
||||
if (editorEl) {
|
||||
const rect = editorEl.getBoundingClientRect();
|
||||
return { x: rect.left + 100, y: rect.top + 100 };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting position for dropdown:", error);
|
||||
}
|
||||
|
||||
return { x: window.innerWidth / 2, y: window.innerHeight / 3 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal function to open the status dropdown
|
||||
*/
|
||||
public openStatusDropdown(options: DropdownOptions): void {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
const files = options.files || (activeFile ? [activeFile] : []);
|
||||
if (!files.length) {
|
||||
new Notice("No files selected");
|
||||
return;
|
||||
}
|
||||
if (!files.length || !files[0]) {
|
||||
new Notice("No files selected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dropdownUI.isOpen) {
|
||||
this.resetDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
const isSingleFile = files.length === 1;
|
||||
|
||||
// Set up target files appropriately
|
||||
if (isSingleFile) {
|
||||
const targetFile = files[0];
|
||||
this.dropdownUI.setTargetFile(targetFile);
|
||||
const currentStatuses =
|
||||
this.statusService.getFileStatuses(targetFile);
|
||||
this.dropdownUI.updateStatuses(currentStatuses);
|
||||
} else {
|
||||
// For multiple files, set the whole collection
|
||||
this.dropdownUI.setTargetFiles(files);
|
||||
const commonStatuses = this.findCommonStatuses(files);
|
||||
this.dropdownUI.updateStatuses(commonStatuses);
|
||||
}
|
||||
|
||||
this.positionAndOpenDropdown(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset dropdown state before opening
|
||||
*/
|
||||
public resetDropdown(): void {
|
||||
this.dropdownUI.close();
|
||||
this.dropdownUI.setTargetFile(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position and open the dropdown
|
||||
*/
|
||||
private positionAndOpenDropdown(options: {
|
||||
target?: HTMLElement;
|
||||
position?: { x: number; y: number };
|
||||
editor?: Editor;
|
||||
view?: MarkdownView;
|
||||
}): void {
|
||||
if (options.editor && options.view) {
|
||||
const position = this.getCursorPosition(
|
||||
options.editor,
|
||||
options.view,
|
||||
);
|
||||
this.openWithPosition(position);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.target) {
|
||||
if (options.position) {
|
||||
this.dropdownUI.open(options.target, options.position);
|
||||
} else {
|
||||
const rect = options.target.getBoundingClientRect();
|
||||
this.dropdownUI.open(options.target, {
|
||||
x: rect.left,
|
||||
y: rect.bottom + 5,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.position) {
|
||||
this.openWithPosition(options.position);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openWithPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 3,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open dropdown at a specific position using dummy target
|
||||
*/
|
||||
private openWithPosition(position: { x: number; y: number }): void {
|
||||
const dummyTarget = createDummyTarget(position);
|
||||
this.dropdownUI.open(dummyTarget, position);
|
||||
|
||||
setTimeout(() => {
|
||||
if (dummyTarget.parentNode) {
|
||||
dummyTarget.parentNode.removeChild(dummyTarget);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find common statuses across multiple files
|
||||
*/
|
||||
private findCommonStatuses(files: TFile[]): string[] {
|
||||
if (files.length === 0) return ["unknown"];
|
||||
|
||||
const firstFileStatuses = this.statusService.getFileStatuses(files[0]);
|
||||
|
||||
return firstFileStatuses.filter(
|
||||
(status) =>
|
||||
status !== "unknown" &&
|
||||
files.every((file) =>
|
||||
this.statusService.getFileStatuses(file).includes(status),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render method (kept for compatibility)
|
||||
*/
|
||||
public render(): void {
|
||||
// No-op - dropdown component handles rendering internally
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dropdown when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
this.dropdownUI.dispose();
|
||||
|
||||
if (this.toolbarButton) {
|
||||
this.toolbarButton.remove();
|
||||
this.toolbarButton = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
/**
|
||||
* Dropdown positioning utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Position the dropdown
|
||||
*/
|
||||
export function positionDropdown(options: {
|
||||
dropdownElement: HTMLElement;
|
||||
targetEl: HTMLElement;
|
||||
position?: { x: number; y: number };
|
||||
}): void {
|
||||
const { dropdownElement, targetEl, position } = options;
|
||||
|
||||
if (position) {
|
||||
positionAt(dropdownElement, position.x, position.y);
|
||||
} else {
|
||||
positionRelativeTo(dropdownElement, targetEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the dropdown at specific coordinates
|
||||
*/
|
||||
function positionAt(dropdownElement: HTMLElement, x: number, y: number): void {
|
||||
dropdownElement.addClass("note-status-popover-fixed");
|
||||
dropdownElement.style.setProperty("--pos-x-px", `${x}px`);
|
||||
dropdownElement.style.setProperty("--pos-y-px", `${y}px`);
|
||||
|
||||
setTimeout(() => adjustPositionToViewport(dropdownElement), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the dropdown position to ensure it's visible in the viewport
|
||||
*/
|
||||
function adjustPositionToViewport(dropdownElement: HTMLElement): void {
|
||||
const rect = dropdownElement.getBoundingClientRect();
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
dropdownElement.addClass("note-status-popover-right");
|
||||
dropdownElement.style.setProperty("--right-offset-px", "10px");
|
||||
} else {
|
||||
dropdownElement.removeClass("note-status-popover-right");
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
dropdownElement.addClass("note-status-popover-bottom");
|
||||
dropdownElement.style.setProperty("--bottom-offset-px", "10px");
|
||||
} else {
|
||||
dropdownElement.removeClass("note-status-popover-bottom");
|
||||
}
|
||||
|
||||
const maxHeight = window.innerHeight - rect.top - 20;
|
||||
dropdownElement.style.setProperty("--max-height-px", `${maxHeight}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the dropdown relative to a target element
|
||||
*/
|
||||
function positionRelativeTo(
|
||||
dropdownElement: HTMLElement,
|
||||
targetEl: HTMLElement,
|
||||
): void {
|
||||
dropdownElement.addClass("note-status-popover-fixed");
|
||||
|
||||
const targetRect = targetEl.getBoundingClientRect();
|
||||
|
||||
dropdownElement.style.setProperty(
|
||||
"--pos-y-px",
|
||||
`${targetRect.bottom + 5}px`,
|
||||
);
|
||||
dropdownElement.style.setProperty("--pos-x-px", `${targetRect.left}px`);
|
||||
|
||||
setTimeout(() => adjustRelativePosition(dropdownElement, targetRect), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust position when positioned relative to an element
|
||||
*/
|
||||
function adjustRelativePosition(
|
||||
dropdownElement: HTMLElement,
|
||||
targetRect: DOMRect,
|
||||
): void {
|
||||
const rect = dropdownElement.getBoundingClientRect();
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
dropdownElement.addClass("note-status-popover-right");
|
||||
dropdownElement.style.setProperty(
|
||||
"--right-offset-px",
|
||||
`${window.innerWidth - targetRect.right}px`,
|
||||
);
|
||||
} else {
|
||||
dropdownElement.removeClass("note-status-popover-right");
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
dropdownElement.addClass("note-status-popover-bottom");
|
||||
dropdownElement.style.setProperty(
|
||||
"--bottom-offset-px",
|
||||
`${window.innerHeight - targetRect.top + 5}px`,
|
||||
);
|
||||
} else {
|
||||
dropdownElement.removeClass("note-status-popover-bottom");
|
||||
}
|
||||
|
||||
const maxHeight = window.innerHeight - rect.top - 20;
|
||||
dropdownElement.style.setProperty("--max-height-px", `${maxHeight}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dummy target element for positioning
|
||||
*/
|
||||
export function createDummyTarget(position: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): HTMLElement {
|
||||
const dummyTarget = document.createElement("div");
|
||||
dummyTarget.addClass("note-status-dummy-target");
|
||||
dummyTarget.style.setProperty("--pos-x-px", `${position.x}px`);
|
||||
dummyTarget.style.setProperty("--pos-y-px", `${position.y}px`);
|
||||
document.body.appendChild(dummyTarget);
|
||||
return dummyTarget;
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
import { setIcon, TFile, setTooltip } from "obsidian";
|
||||
import { StatusRemoveHandler, StatusSelectHandler } from "./types";
|
||||
import { NoteStatusSettings, Status } from "models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
/**
|
||||
* Render the dropdown content
|
||||
*/
|
||||
export function renderDropdownContent(options: {
|
||||
dropdownElement: HTMLElement;
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
currentStatuses: string[];
|
||||
targetFile: TFile | null;
|
||||
targetFiles: TFile[];
|
||||
onRemoveStatus: StatusRemoveHandler;
|
||||
onSelectStatus: StatusSelectHandler;
|
||||
}): void {
|
||||
const {
|
||||
dropdownElement,
|
||||
settings,
|
||||
statusService,
|
||||
currentStatuses,
|
||||
targetFile,
|
||||
targetFiles,
|
||||
onRemoveStatus,
|
||||
onSelectStatus,
|
||||
} = options;
|
||||
|
||||
dropdownElement.empty();
|
||||
|
||||
// Create UI sections
|
||||
createHeader(dropdownElement, targetFiles);
|
||||
|
||||
const target = targetFiles.length > 1 ? targetFiles : targetFile;
|
||||
createStatusChips(
|
||||
dropdownElement,
|
||||
currentStatuses,
|
||||
statusService,
|
||||
target ?? [],
|
||||
onRemoveStatus,
|
||||
);
|
||||
const searchInput = createSearchFilter(dropdownElement);
|
||||
|
||||
// Create status options container
|
||||
const statusOptionsContainer = dropdownElement.createDiv({
|
||||
cls: "note-status-options-container",
|
||||
});
|
||||
|
||||
// Get all available statuses (excluding 'unknown')
|
||||
const allStatuses = statusService
|
||||
.getAllStatuses()
|
||||
.filter((status) => status.name !== "unknown");
|
||||
|
||||
// Function to populate options with filtering
|
||||
const populateOptions = (filter = "") => {
|
||||
populateStatusOptions({
|
||||
container: statusOptionsContainer,
|
||||
statuses: allStatuses,
|
||||
currentStatuses,
|
||||
settings,
|
||||
targetFiles,
|
||||
onSelectStatus,
|
||||
filter,
|
||||
});
|
||||
};
|
||||
|
||||
// Initial population
|
||||
populateOptions();
|
||||
|
||||
// Add search functionality
|
||||
searchInput.addEventListener("input", () => {
|
||||
populateOptions(searchInput.value);
|
||||
});
|
||||
|
||||
// Focus search input after a short delay
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the dropdown header
|
||||
*/
|
||||
function createHeader(
|
||||
dropdownElement: HTMLElement,
|
||||
targetFiles: TFile[],
|
||||
): void {
|
||||
const headerEl = dropdownElement.createDiv({
|
||||
cls: "note-status-popover-header",
|
||||
});
|
||||
const titleEl = headerEl.createDiv({ cls: "note-status-popover-title" });
|
||||
|
||||
const iconContainer = titleEl.createDiv({
|
||||
cls: "note-status-popover-icon",
|
||||
});
|
||||
setIcon(iconContainer, "tag");
|
||||
|
||||
titleEl.createSpan({
|
||||
text: "Note status",
|
||||
cls: "note-status-popover-label",
|
||||
});
|
||||
|
||||
// If multiple files are selected, show count
|
||||
if (targetFiles.length > 1) {
|
||||
titleEl.createSpan({
|
||||
text: ` (${targetFiles.length} files)`,
|
||||
cls: "note-status-popover-count",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the status chips section
|
||||
*/
|
||||
function createStatusChips(
|
||||
dropdownElement: HTMLElement,
|
||||
currentStatuses: string[],
|
||||
statusService: StatusService,
|
||||
targetFile: TFile | TFile[],
|
||||
onRemoveStatus: StatusRemoveHandler,
|
||||
): void {
|
||||
const chipsContainer = dropdownElement.createDiv({
|
||||
cls: "note-status-popover-chips",
|
||||
});
|
||||
|
||||
const hasNoValidStatus =
|
||||
currentStatuses.length === 0 ||
|
||||
(currentStatuses.length === 1 && currentStatuses[0] === "unknown");
|
||||
|
||||
if (hasNoValidStatus) {
|
||||
chipsContainer.createDiv({
|
||||
cls: "note-status-empty-indicator",
|
||||
text: "No status assigned",
|
||||
});
|
||||
} else {
|
||||
createStatusChipElements(
|
||||
chipsContainer,
|
||||
currentStatuses,
|
||||
statusService,
|
||||
targetFile,
|
||||
onRemoveStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create chips for all current statuses
|
||||
*/
|
||||
function createStatusChipElements(
|
||||
container: HTMLElement,
|
||||
currentStatuses: string[],
|
||||
statusService: StatusService,
|
||||
target: TFile | TFile[],
|
||||
onRemoveStatus: StatusRemoveHandler,
|
||||
): void {
|
||||
currentStatuses.forEach((status) => {
|
||||
if (status === "unknown") return;
|
||||
|
||||
const statusObj = statusService
|
||||
.getAllStatuses()
|
||||
.find((s) => s.name === status);
|
||||
if (!statusObj) return;
|
||||
|
||||
createSingleStatusChip(
|
||||
container,
|
||||
status,
|
||||
statusObj,
|
||||
target,
|
||||
onRemoveStatus,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single status chip
|
||||
*/
|
||||
function createSingleStatusChip(
|
||||
container: HTMLElement,
|
||||
status: string,
|
||||
statusObj: Status,
|
||||
target: TFile | TFile[],
|
||||
onRemoveStatus: StatusRemoveHandler,
|
||||
): void {
|
||||
const chipEl = container.createDiv({
|
||||
cls: `note-status-chip status-${status}`,
|
||||
});
|
||||
|
||||
// Status icon and name
|
||||
chipEl.createSpan({
|
||||
text: statusObj.icon,
|
||||
cls: "note-status-chip-icon",
|
||||
});
|
||||
|
||||
chipEl.createSpan({
|
||||
text: statusObj.name,
|
||||
cls: "note-status-chip-text",
|
||||
});
|
||||
|
||||
addRemoveButton(chipEl, status, statusObj, target, onRemoveStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a remove button to a status chip
|
||||
*/
|
||||
function addRemoveButton(
|
||||
chipEl: HTMLElement,
|
||||
status: string,
|
||||
statusObj: Status,
|
||||
targetFile: TFile | TFile[] | null,
|
||||
onRemoveStatus: StatusRemoveHandler,
|
||||
): void {
|
||||
const tooltipValue = statusObj.description
|
||||
? `${status} - ${statusObj.description}`
|
||||
: status;
|
||||
|
||||
setTooltip(chipEl, tooltipValue);
|
||||
|
||||
const removeBtn = chipEl.createDiv({
|
||||
cls: "note-status-chip-remove",
|
||||
attr: {
|
||||
"aria-label": `Remove ${status} status`,
|
||||
title: `Remove ${status} status`,
|
||||
},
|
||||
});
|
||||
setIcon(removeBtn, "x");
|
||||
|
||||
removeBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
chipEl.addClass("note-status-chip-removing");
|
||||
|
||||
setTimeout(async () => {
|
||||
if (targetFile) {
|
||||
await onRemoveStatus(status, targetFile);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the search filter input
|
||||
*/
|
||||
function createSearchFilter(dropdownElement: HTMLElement): HTMLInputElement {
|
||||
const searchContainer = dropdownElement.createDiv({
|
||||
cls: "note-status-popover-search",
|
||||
});
|
||||
|
||||
return searchContainer.createEl("input", {
|
||||
type: "text",
|
||||
placeholder: "Filter statuses...",
|
||||
cls: "note-status-popover-search-input",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate status options with optional filtering
|
||||
*/
|
||||
function populateStatusOptions(options: {
|
||||
container: HTMLElement;
|
||||
statuses: Status[];
|
||||
currentStatuses: string[];
|
||||
settings: NoteStatusSettings;
|
||||
targetFiles: TFile[];
|
||||
onSelectStatus: StatusSelectHandler;
|
||||
filter?: string;
|
||||
}): void {
|
||||
const {
|
||||
container,
|
||||
statuses,
|
||||
currentStatuses,
|
||||
settings,
|
||||
targetFiles,
|
||||
onSelectStatus,
|
||||
filter = "",
|
||||
} = options;
|
||||
|
||||
container.empty();
|
||||
|
||||
const filteredStatuses = filter
|
||||
? statuses.filter(
|
||||
(status) =>
|
||||
status.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
status.icon.includes(filter),
|
||||
)
|
||||
: statuses;
|
||||
|
||||
if (filteredStatuses.length === 0) {
|
||||
container.createDiv({
|
||||
cls: "note-status-empty-options",
|
||||
text: filter
|
||||
? `No statuses match "${filter}"`
|
||||
: "No statuses found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
filteredStatuses.forEach((status) => {
|
||||
createStatusOption({
|
||||
container,
|
||||
status,
|
||||
isSelected: currentStatuses.includes(status.name),
|
||||
settings,
|
||||
targetFiles,
|
||||
onSelectStatus,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single status option element
|
||||
*/
|
||||
function createStatusOption(options: {
|
||||
container: HTMLElement;
|
||||
status: Status;
|
||||
isSelected: boolean;
|
||||
settings: NoteStatusSettings;
|
||||
targetFiles: TFile[];
|
||||
onSelectStatus: StatusSelectHandler;
|
||||
}): void {
|
||||
const { container, status, isSelected, targetFiles, onSelectStatus } =
|
||||
options;
|
||||
|
||||
const optionEl = container.createDiv({
|
||||
cls: `note-status-option ${isSelected ? "is-selected" : ""} status-${status.name}`,
|
||||
});
|
||||
|
||||
// Status icon and name
|
||||
optionEl.createSpan({
|
||||
text: status.icon,
|
||||
cls: "note-status-option-icon",
|
||||
});
|
||||
|
||||
optionEl.createSpan({
|
||||
text: status.name,
|
||||
cls: "note-status-option-text",
|
||||
});
|
||||
|
||||
// Add tooltip if description available
|
||||
if (status.description) {
|
||||
setTooltip(optionEl, `${status.name} - ${status.description}`);
|
||||
}
|
||||
|
||||
// Check icon for selected status
|
||||
if (isSelected) {
|
||||
const checkIcon = optionEl.createDiv({
|
||||
cls: "note-status-option-check",
|
||||
});
|
||||
setIcon(checkIcon, "check");
|
||||
}
|
||||
|
||||
optionEl.addEventListener("click", () => {
|
||||
optionEl.addClass("note-status-option-selecting");
|
||||
setTimeout(async () => {
|
||||
if (targetFiles.length > 0) {
|
||||
await onSelectStatus(status.name, targetFiles);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import {
|
||||
DropdownDependencies,
|
||||
StatusRemoveHandler,
|
||||
StatusSelectHandler,
|
||||
} from "./types";
|
||||
import { positionDropdown } from "./dropdown-position";
|
||||
import { renderDropdownContent } from "./dropdown-render";
|
||||
import { setupDropdownEvents } from "./dropdown-events";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
|
||||
/**
|
||||
* Core UI component for the status dropdown
|
||||
*/
|
||||
export class DropdownUI {
|
||||
private app: App;
|
||||
private statusService: StatusService;
|
||||
private settings: NoteStatusSettings;
|
||||
|
||||
private dropdownElement: HTMLElement | null = null;
|
||||
private currentStatuses: string[] = ["unknown"];
|
||||
private targetFile: TFile | null = null;
|
||||
private targetFiles: TFile[] = [];
|
||||
private animationDuration = 220;
|
||||
|
||||
public isOpen = false;
|
||||
private onRemoveStatus: StatusRemoveHandler = async () => {};
|
||||
private onSelectStatus: StatusSelectHandler = async () => {};
|
||||
|
||||
// Event handlers
|
||||
private clickOutsideHandler: (e: MouseEvent) => void;
|
||||
private escapeKeyHandler: (e: KeyboardEvent) => void;
|
||||
|
||||
constructor({ app, settings, statusService }: DropdownDependencies) {
|
||||
this.app = app;
|
||||
this.statusService = statusService;
|
||||
this.settings = settings;
|
||||
|
||||
// Bind methods
|
||||
this.clickOutsideHandler = this.handleClickOutside.bind(this);
|
||||
this.escapeKeyHandler = this.handleEscapeKey.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the target file for status updates
|
||||
*/
|
||||
public setTargetFile(file: TFile | null): void {
|
||||
this.targetFile = file;
|
||||
this.targetFiles = file ? [file] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple target files for status updates
|
||||
*/
|
||||
public setTargetFiles(files: TFile[]): void {
|
||||
this.targetFiles = [...files];
|
||||
this.targetFile = files.length === 1 ? files[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register handler for removing a status
|
||||
*/
|
||||
public setOnRemoveStatusHandler(handler: StatusRemoveHandler): void {
|
||||
this.onRemoveStatus = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register handler for selecting a status
|
||||
*/
|
||||
public setOnSelectStatusHandler(handler: StatusSelectHandler): void {
|
||||
this.onSelectStatus = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current statuses
|
||||
*/
|
||||
public updateStatuses(statuses: string[] | string): void {
|
||||
this.currentStatuses = Array.isArray(statuses)
|
||||
? [...statuses]
|
||||
: [statuses];
|
||||
|
||||
if (this.isOpen && this.dropdownElement) {
|
||||
this.refreshDropdownContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
|
||||
if (this.isOpen && this.dropdownElement) {
|
||||
this.refreshDropdownContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the dropdown visibility
|
||||
*/
|
||||
public toggle(
|
||||
targetEl: HTMLElement,
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
setTimeout(() => {
|
||||
if (!this.isOpen && !this.dropdownElement) {
|
||||
this.open(targetEl, position);
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
this.open(targetEl, position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the dropdown
|
||||
*/
|
||||
public open(
|
||||
targetEl: HTMLElement,
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
if (this.isOpen || this.dropdownElement) {
|
||||
this.close();
|
||||
setTimeout(() => this.actuallyOpen(targetEl, position), 10);
|
||||
return;
|
||||
}
|
||||
|
||||
this.actuallyOpen(targetEl, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually open the dropdown (internal method)
|
||||
*/
|
||||
private actuallyOpen(
|
||||
targetEl: HTMLElement,
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
this.isOpen = true;
|
||||
|
||||
// Create dropdown element
|
||||
this.createDropdownElement();
|
||||
this.refreshDropdownContent();
|
||||
|
||||
// Position the dropdown
|
||||
positionDropdown({
|
||||
dropdownElement: this.dropdownElement!,
|
||||
targetEl,
|
||||
position,
|
||||
});
|
||||
|
||||
this.dropdownElement?.addClass("note-status-popover-animate-in");
|
||||
|
||||
// Add event listeners with slight delay to prevent immediate triggering
|
||||
setTimeout(() => {
|
||||
setupDropdownEvents({
|
||||
clickOutsideHandler: this.clickOutsideHandler,
|
||||
escapeKeyHandler: this.escapeKeyHandler,
|
||||
});
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the dropdown element
|
||||
*/
|
||||
private createDropdownElement(): void {
|
||||
this.dropdownElement = document.createElement("div");
|
||||
this.dropdownElement.addClass(
|
||||
"note-status-popover",
|
||||
"note-status-unified-dropdown",
|
||||
);
|
||||
document.body.appendChild(this.dropdownElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the dropdown
|
||||
*/
|
||||
public close(): void {
|
||||
if (!this.dropdownElement || !this.isOpen) return;
|
||||
|
||||
this.dropdownElement.addClass("note-status-popover-animate-out");
|
||||
|
||||
document.removeEventListener("click", this.clickOutsideHandler);
|
||||
document.removeEventListener("keydown", this.escapeKeyHandler);
|
||||
|
||||
this.isOpen = false;
|
||||
|
||||
if (this.dropdownElement) {
|
||||
this.dropdownElement.remove();
|
||||
this.dropdownElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the dropdown content
|
||||
*/
|
||||
private refreshDropdownContent(): void {
|
||||
if (!this.dropdownElement) return;
|
||||
|
||||
renderDropdownContent({
|
||||
dropdownElement: this.dropdownElement,
|
||||
settings: this.settings,
|
||||
statusService: this.statusService,
|
||||
currentStatuses: this.currentStatuses,
|
||||
targetFile: this.targetFile,
|
||||
targetFiles: this.targetFiles,
|
||||
onRemoveStatus: this.onRemoveStatus,
|
||||
onSelectStatus: this.onSelectStatus,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click outside the dropdown
|
||||
*/
|
||||
private handleClickOutside(e: MouseEvent): void {
|
||||
if (
|
||||
this.dropdownElement &&
|
||||
!this.dropdownElement.contains(e.target as Node)
|
||||
) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle escape key to close dropdown
|
||||
*/
|
||||
private handleEscapeKey(e: KeyboardEvent): void {
|
||||
if (e.key === "Escape") {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { DropdownManager } from "./dropdown-manager";
|
||||
import { DropdownOptions } from "./types";
|
||||
|
||||
export type { DropdownOptions };
|
||||
export { DropdownManager as StatusDropdown };
|
||||
export default DropdownManager;
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
// # Tipos comunes
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
import { App, TFile, Editor, MarkdownView } from "obsidian";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
/**
|
||||
* Options for opening status dropdown
|
||||
*/
|
||||
export interface DropdownOptions {
|
||||
target?: HTMLElement;
|
||||
position?: { x: number; y: number };
|
||||
files?: TFile[];
|
||||
editor?: Editor;
|
||||
view?: MarkdownView;
|
||||
mode?: "replace" | "add" | "remove" | "toggle";
|
||||
onStatusChange?: (statuses: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status removal handler function type
|
||||
*/
|
||||
export type StatusRemoveHandler = (
|
||||
status: string,
|
||||
targetFile?: TFile | TFile[],
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Status selection handler function type
|
||||
*/
|
||||
export type StatusSelectHandler = (
|
||||
status: string,
|
||||
targetFile: TFile | TFile[],
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Common dependencies for dropdown components
|
||||
*/
|
||||
export interface DropdownDependencies {
|
||||
app: App;
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { NoteStatusSettings } from "models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
export class ToolbarButton {
|
||||
private element: HTMLElement | null = null;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
|
||||
constructor(settings: NoteStatusSettings, statusService: StatusService) {
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
}
|
||||
|
||||
public createElement(): HTMLElement {
|
||||
const button = document.createElement("button");
|
||||
button.addClass(
|
||||
"note-status-toolbar-button",
|
||||
"clickable-icon",
|
||||
"view-action",
|
||||
);
|
||||
button.setAttribute("aria-label", "Note status");
|
||||
|
||||
this.element = button;
|
||||
return button;
|
||||
}
|
||||
|
||||
public updateDisplay(statuses: string[]): void {
|
||||
if (!this.element) return;
|
||||
|
||||
this.element.empty();
|
||||
|
||||
const hasValidStatus = statuses.length > 0 && statuses[0] !== "unknown";
|
||||
const badgeContainer = document.createElement("div");
|
||||
badgeContainer.addClass("note-status-toolbar-badge-container");
|
||||
|
||||
if (hasValidStatus) {
|
||||
this.renderStatusBadge(badgeContainer, statuses);
|
||||
} else {
|
||||
this.renderUnknownBadge(badgeContainer);
|
||||
}
|
||||
|
||||
this.element.appendChild(badgeContainer);
|
||||
}
|
||||
|
||||
private renderStatusBadge(
|
||||
container: HTMLElement,
|
||||
statuses: string[],
|
||||
): void {
|
||||
const primaryStatus = statuses[0];
|
||||
const icon = this.statusService.getStatusIcon(primaryStatus);
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.addClass(
|
||||
`note-status-toolbar-icon`,
|
||||
`status-${primaryStatus}`,
|
||||
);
|
||||
iconSpan.textContent = icon;
|
||||
container.appendChild(iconSpan);
|
||||
|
||||
if (this.settings.useMultipleStatuses && statuses.length > 1) {
|
||||
const countBadge = document.createElement("span");
|
||||
countBadge.addClass("note-status-count-badge");
|
||||
countBadge.textContent = `${statuses.length}`;
|
||||
container.appendChild(countBadge);
|
||||
}
|
||||
}
|
||||
|
||||
private renderUnknownBadge(container: HTMLElement): void {
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.addClass("note-status-toolbar-icon", "status-unknown");
|
||||
iconSpan.textContent = this.statusService.getStatusIcon("unknown");
|
||||
container.appendChild(iconSpan);
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import { NoteStatusSettings } from "../models/types";
|
||||
import { DEFAULT_ENABLED_TEMPLATES } from "../constants/status-templates";
|
||||
import { PluginSettings } from "types/pluginSettings";
|
||||
import { DEFAULT_ENABLED_TEMPLATES } from "./predefinedTemplates";
|
||||
|
||||
/**
|
||||
* Default plugin settings
|
||||
*/
|
||||
export const DEFAULT_SETTINGS: NoteStatusSettings = {
|
||||
export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
|
||||
fileExplorerIconPosition: "absolute-right",
|
||||
statusColors: {
|
||||
active: "var(--text-success)",
|
||||
onHold: "var(--text-warning)",
|
||||
|
|
@ -16,9 +14,7 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = {
|
|||
autoHideStatusBar: false,
|
||||
customStatuses: [],
|
||||
showStatusIconsInExplorer: true,
|
||||
hideUnknownStatusInExplorer: false, // Default to show unknown status
|
||||
collapsedStatuses: {},
|
||||
compactView: false,
|
||||
hideUnknownStatusInExplorer: true, // Default to hide unknown status
|
||||
enabledTemplates: DEFAULT_ENABLED_TEMPLATES,
|
||||
useCustomStatusesOnly: false,
|
||||
useMultipleStatuses: true,
|
||||
|
|
@ -27,14 +23,3 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = {
|
|||
excludeUnknownStatus: true, // Default to exclude unknown status files for better performance
|
||||
quickStatusCommands: ["active", "completed"], // Add default quick commands
|
||||
};
|
||||
|
||||
/**
|
||||
* Default colors in hexadecimal format for backup and reset
|
||||
*/
|
||||
export const DEFAULT_COLORS: Record<string, string> = {
|
||||
active: "#00ff00", // Green for success
|
||||
onHold: "#ffaa00", // Orange for warning
|
||||
completed: "#00aaff", // Blue for accent
|
||||
dropped: "#ff0000", // Red for error
|
||||
unknown: "#888888", // Gray for muted
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/**
|
||||
* SVG icon definitions
|
||||
*/
|
||||
export const ICONS = {
|
||||
statusPane: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z"/>
|
||||
</svg>`,
|
||||
search: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="search-icon">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>`,
|
||||
clear: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>`,
|
||||
standardView: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
|
||||
compactView: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>`,
|
||||
refresh: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38"/></svg>`,
|
||||
collapseDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>`,
|
||||
collapseRight: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>`,
|
||||
file: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
|
||||
};
|
||||
209
constants/predefinedTemplates.ts
Normal file
209
constants/predefinedTemplates.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { StatusTemplate } from "types/pluginSettings";
|
||||
|
||||
/**
|
||||
* Predefined status templates
|
||||
*/
|
||||
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
|
||||
{
|
||||
id: "colorful",
|
||||
name: "Colorful workflow",
|
||||
description:
|
||||
"A colorful set of workflow statuses with descriptive icons",
|
||||
statuses: [
|
||||
{
|
||||
name: "idea",
|
||||
icon: "💡",
|
||||
color: "#FFEB3B",
|
||||
templateId: "colorful",
|
||||
},
|
||||
{
|
||||
name: "draft",
|
||||
icon: "📝",
|
||||
color: "#E0E0E0",
|
||||
templateId: "colorful",
|
||||
},
|
||||
{
|
||||
name: "inProgress",
|
||||
icon: "🔧",
|
||||
color: "#FFC107",
|
||||
templateId: "colorful",
|
||||
},
|
||||
{
|
||||
name: "editing",
|
||||
icon: "🖊️",
|
||||
color: "#2196F3",
|
||||
templateId: "colorful",
|
||||
},
|
||||
{
|
||||
name: "pending",
|
||||
icon: "⏳",
|
||||
color: "#9C27B0",
|
||||
templateId: "colorful",
|
||||
},
|
||||
{
|
||||
name: "onHold",
|
||||
icon: "⏸",
|
||||
color: "#9E9E9E",
|
||||
templateId: "colorful",
|
||||
},
|
||||
{
|
||||
name: "needsUpdate",
|
||||
icon: "🔄",
|
||||
color: "#FF5722",
|
||||
templateId: "colorful",
|
||||
},
|
||||
{
|
||||
name: "completed",
|
||||
icon: "✅",
|
||||
color: "#4CAF50",
|
||||
templateId: "colorful",
|
||||
},
|
||||
{
|
||||
name: "archived",
|
||||
icon: "📦",
|
||||
color: "#795548",
|
||||
templateId: "colorful",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "minimal",
|
||||
name: "Minimal workflow",
|
||||
description: "A simplified set of essential workflow statuses",
|
||||
statuses: [
|
||||
{
|
||||
name: "todo",
|
||||
icon: "📌",
|
||||
color: "#F44336",
|
||||
templateId: "minimal",
|
||||
},
|
||||
{
|
||||
name: "inProgress",
|
||||
icon: "⚙️",
|
||||
color: "#2196F3",
|
||||
templateId: "minimal",
|
||||
},
|
||||
{
|
||||
name: "review",
|
||||
icon: "👀",
|
||||
color: "#9C27B0",
|
||||
templateId: "minimal",
|
||||
},
|
||||
{
|
||||
name: "done",
|
||||
icon: "✓",
|
||||
color: "#4CAF50",
|
||||
templateId: "minimal",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "academic",
|
||||
name: "Academic research",
|
||||
description: "Status workflow for academic research and writing",
|
||||
statuses: [
|
||||
{
|
||||
name: "research",
|
||||
icon: "🔍",
|
||||
color: "#2196F3",
|
||||
templateId: "academic",
|
||||
},
|
||||
{
|
||||
name: "outline",
|
||||
icon: "📑",
|
||||
color: "#9E9E9E",
|
||||
templateId: "academic",
|
||||
},
|
||||
{
|
||||
name: "draft",
|
||||
icon: "✏️",
|
||||
color: "#FFC107",
|
||||
templateId: "academic",
|
||||
},
|
||||
{
|
||||
name: "review",
|
||||
icon: "🔬",
|
||||
color: "#9C27B0",
|
||||
templateId: "academic",
|
||||
},
|
||||
{
|
||||
name: "revision",
|
||||
icon: "📝",
|
||||
color: "#FF5722",
|
||||
templateId: "academic",
|
||||
},
|
||||
{
|
||||
name: "final",
|
||||
icon: "📚",
|
||||
color: "#4CAF50",
|
||||
templateId: "academic",
|
||||
},
|
||||
{
|
||||
name: "published",
|
||||
icon: "🎓",
|
||||
color: "#795548",
|
||||
templateId: "academic",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project",
|
||||
name: "Project management",
|
||||
description: "Status workflow for project management and tracking",
|
||||
statuses: [
|
||||
{
|
||||
name: "planning",
|
||||
icon: "🗓️",
|
||||
color: "#9E9E9E",
|
||||
templateId: "project",
|
||||
},
|
||||
{
|
||||
name: "backlog",
|
||||
icon: "📋",
|
||||
color: "#E0E0E0",
|
||||
templateId: "project",
|
||||
},
|
||||
{
|
||||
name: "ready",
|
||||
icon: "🚦",
|
||||
color: "#8BC34A",
|
||||
templateId: "project",
|
||||
},
|
||||
{
|
||||
name: "inDevelopment",
|
||||
icon: "👨💻",
|
||||
color: "#2196F3",
|
||||
templateId: "project",
|
||||
},
|
||||
{
|
||||
name: "testing",
|
||||
icon: "🧪",
|
||||
color: "#9C27B0",
|
||||
templateId: "project",
|
||||
},
|
||||
{
|
||||
name: "review",
|
||||
icon: "👁️",
|
||||
color: "#FFC107",
|
||||
templateId: "project",
|
||||
},
|
||||
{
|
||||
name: "approved",
|
||||
icon: "👍",
|
||||
color: "#4CAF50",
|
||||
templateId: "project",
|
||||
},
|
||||
{
|
||||
name: "live",
|
||||
icon: "🚀",
|
||||
color: "#3F51B5",
|
||||
templateId: "project",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Default template IDs that should be enabled by default
|
||||
*/
|
||||
export const DEFAULT_ENABLED_TEMPLATES = ["colorful"];
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import { Status } from "../models/types";
|
||||
|
||||
/**
|
||||
* Status Template interface
|
||||
*/
|
||||
export interface StatusTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
statuses: Status[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined status templates
|
||||
*/
|
||||
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
|
||||
{
|
||||
id: "colorful",
|
||||
name: "Colorful workflow",
|
||||
description:
|
||||
"A colorful set of workflow statuses with descriptive icons",
|
||||
statuses: [
|
||||
{ name: "idea", icon: "💡", color: "#FFEB3B" },
|
||||
{ name: "draft", icon: "📝", color: "#E0E0E0" },
|
||||
{ name: "inProgress", icon: "🔧", color: "#FFC107" },
|
||||
{ name: "editing", icon: "🖊️", color: "#2196F3" },
|
||||
{ name: "pending", icon: "⏳", color: "#9C27B0" },
|
||||
{ name: "onHold", icon: "⏸", color: "#9E9E9E" },
|
||||
{ name: "needsUpdate", icon: "🔄", color: "#FF5722" },
|
||||
{ name: "completed", icon: "✅", color: "#4CAF50" },
|
||||
{ name: "archived", icon: "📦", color: "#795548" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "minimal",
|
||||
name: "Minimal workflow",
|
||||
description: "A simplified set of essential workflow statuses",
|
||||
statuses: [
|
||||
{ name: "todo", icon: "📌", color: "#F44336" },
|
||||
{ name: "inProgress", icon: "⚙️", color: "#2196F3" },
|
||||
{ name: "review", icon: "👀", color: "#9C27B0" },
|
||||
{ name: "done", icon: "✓", color: "#4CAF50" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "academic",
|
||||
name: "Academic research",
|
||||
description: "Status workflow for academic research and writing",
|
||||
statuses: [
|
||||
{ name: "research", icon: "🔍", color: "#2196F3" },
|
||||
{ name: "outline", icon: "📑", color: "#9E9E9E" },
|
||||
{ name: "draft", icon: "✏️", color: "#FFC107" },
|
||||
{ name: "review", icon: "🔬", color: "#9C27B0" },
|
||||
{ name: "revision", icon: "📝", color: "#FF5722" },
|
||||
{ name: "final", icon: "📚", color: "#4CAF50" },
|
||||
{ name: "published", icon: "🎓", color: "#795548" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project",
|
||||
name: "Project management",
|
||||
description: "Status workflow for project management and tracking",
|
||||
statuses: [
|
||||
{ name: "planning", icon: "🗓️", color: "#9E9E9E" },
|
||||
{ name: "backlog", icon: "📋", color: "#E0E0E0" },
|
||||
{ name: "ready", icon: "🚦", color: "#8BC34A" },
|
||||
{ name: "inDevelopment", icon: "👨💻", color: "#2196F3" },
|
||||
{ name: "testing", icon: "🧪", color: "#9C27B0" },
|
||||
{ name: "review", icon: "👁️", color: "#FFC107" },
|
||||
{ name: "approved", icon: "👍", color: "#4CAF50" },
|
||||
{ name: "live", icon: "🚀", color: "#3F51B5" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Default template IDs that should be enabled by default
|
||||
*/
|
||||
export const DEFAULT_ENABLED_TEMPLATES = ["colorful"];
|
||||
314
core/commandsService.ts
Normal file
314
core/commandsService.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { Plugin, Notice, TFile, App } from "obsidian";
|
||||
import { NoteStatusService, BaseNoteStatusService } from "./noteStatusService";
|
||||
import settingsService from "./settingsService";
|
||||
import eventBus from "./eventBus";
|
||||
|
||||
export class CommandsService {
|
||||
private plugin: Plugin;
|
||||
private app: App;
|
||||
private registeredCommands: Set<string> = new Set();
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
this.plugin = plugin;
|
||||
this.app = plugin.app;
|
||||
}
|
||||
|
||||
private createStatusService(file: TFile): NoteStatusService {
|
||||
const service = new NoteStatusService(file);
|
||||
service.populateStatuses();
|
||||
return service;
|
||||
}
|
||||
|
||||
registerAllCommands(): void {
|
||||
// Change status of current note
|
||||
this.plugin.addCommand({
|
||||
id: "change-status",
|
||||
name: "Change status of current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService.populateStatuses();
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: statusService,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("change-status");
|
||||
|
||||
// Cycle through statuses
|
||||
this.plugin.addCommand({
|
||||
id: "cycle-status",
|
||||
name: "Cycle to next status",
|
||||
checkCallback: (checking: boolean) => {
|
||||
if (settingsService.settings.useMultipleStatuses) {
|
||||
// For now the cycle status is for single statuses
|
||||
return false;
|
||||
}
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!checking &&
|
||||
!settingsService.settings.useMultipleStatuses
|
||||
) {
|
||||
const allStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
|
||||
if (allStatuses.length === 0) {
|
||||
new Notice("No statuses available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current file data
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService.populateStatuses();
|
||||
const statuses =
|
||||
statusService.statuses[
|
||||
settingsService.settings.tagPrefix
|
||||
] || [];
|
||||
const currentStatus = statuses.length
|
||||
? statuses?.[0].name
|
||||
: undefined;
|
||||
let nextIndex = 0;
|
||||
if (currentStatus) {
|
||||
const currentIndex = allStatuses.findIndex(
|
||||
(s) => s.name === currentStatus,
|
||||
);
|
||||
if (currentIndex !== -1) {
|
||||
nextIndex =
|
||||
currentIndex === -1
|
||||
? 0
|
||||
: (currentIndex + 1) % allStatuses.length;
|
||||
}
|
||||
}
|
||||
|
||||
const nextStatus = allStatuses[nextIndex];
|
||||
const scopedIdentifier = nextStatus.templateId
|
||||
? BaseNoteStatusService.formatStatusIdentifier({
|
||||
templateId: nextStatus.templateId,
|
||||
name: nextStatus.name,
|
||||
})
|
||||
: nextStatus.name;
|
||||
|
||||
statusService
|
||||
.addStatus(
|
||||
settingsService.settings.tagPrefix,
|
||||
scopedIdentifier,
|
||||
)
|
||||
.then((resolve) => {
|
||||
new Notice(`Status changed to ${nextStatus.name}`);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("cycle-status");
|
||||
|
||||
// Register dynamic quick status commands
|
||||
this.registerQuickStatusCommands();
|
||||
|
||||
// Clear status
|
||||
this.plugin.addCommand({
|
||||
id: "clear-status",
|
||||
name: "Clear status",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService
|
||||
.clearStatus(settingsService.settings.tagPrefix)
|
||||
.then(() => {
|
||||
new Notice("Status cleared");
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("clear-status");
|
||||
|
||||
// Copy status from current note
|
||||
this.plugin.addCommand({
|
||||
id: "copy-status",
|
||||
name: "Copy status from current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
const statuses = this.getFileStatuses(file);
|
||||
navigator.clipboard
|
||||
.writeText(statuses.join(", "))
|
||||
.then(() => {
|
||||
new Notice(`Copied status: ${statuses.join(", ")}`);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("copy-status");
|
||||
|
||||
// Paste status to current note
|
||||
this.plugin.addCommand({
|
||||
id: "paste-status",
|
||||
name: "Paste status to current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService.populateStatuses();
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const statuses = text.split(", ");
|
||||
|
||||
statusService
|
||||
.overrideStatuses(
|
||||
settingsService.settings.tagPrefix,
|
||||
statuses,
|
||||
)
|
||||
.then(() => {
|
||||
new Notice(
|
||||
`Pasted status: ${statuses.join(", ")}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("paste-status");
|
||||
|
||||
// Toggle multiple statuses mode
|
||||
this.plugin.addCommand({
|
||||
id: "toggle-multiple-statuses",
|
||||
name: "Toggle multiple statuses mode",
|
||||
callback: () => {
|
||||
const currentValue =
|
||||
settingsService.settings.useMultipleStatuses;
|
||||
settingsService.setValue("useMultipleStatuses", !currentValue);
|
||||
new Notice(
|
||||
`Multiple statuses mode ${!currentValue ? "enabled" : "disabled"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("toggle-multiple-statuses");
|
||||
|
||||
// Search notes by status
|
||||
this.plugin.addCommand({
|
||||
id: "search-by-status",
|
||||
name: "Search notes by current status",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
const statuses = this.getFileStatuses(file);
|
||||
|
||||
if (!statuses || statuses.length === 0) return false;
|
||||
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
const queries = statuses.map(
|
||||
(status) => `[${tagPrefix}:"${status}"]`,
|
||||
);
|
||||
const query = queries.join(" OR ");
|
||||
|
||||
// @ts-ignore
|
||||
this.app.internalPlugins.plugins[
|
||||
"global-search"
|
||||
].instance.openGlobalSearch(query);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("search-by-status");
|
||||
}
|
||||
|
||||
/**
|
||||
* Register quick status commands based on settings
|
||||
*/
|
||||
private registerQuickStatusCommands(): void {
|
||||
const quickCommands =
|
||||
settingsService.settings.quickStatusCommands || [];
|
||||
const allStatuses = BaseNoteStatusService.getAllAvailableStatuses();
|
||||
|
||||
quickCommands.forEach((statusName) => {
|
||||
const status = allStatuses.find((s) => s.name === statusName);
|
||||
if (!status) return;
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: `set-status-${statusName}`,
|
||||
name: `Set status to ${statusName}`,
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
if (settingsService.settings.useMultipleStatuses) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!checking &&
|
||||
!settingsService.settings.useMultipleStatuses
|
||||
) {
|
||||
const statusService = this.createStatusService(file);
|
||||
const scopedIdentifier = status.templateId
|
||||
? BaseNoteStatusService.formatStatusIdentifier({
|
||||
templateId: status.templateId,
|
||||
name: status.name,
|
||||
})
|
||||
: status.name;
|
||||
|
||||
statusService
|
||||
.overrideStatuses(
|
||||
settingsService.settings.tagPrefix,
|
||||
[scopedIdentifier],
|
||||
)
|
||||
.then(() => {
|
||||
new Notice(`Status set to ${statusName}`);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add(`set-status-${statusName}`);
|
||||
});
|
||||
}
|
||||
|
||||
private getFileStatuses(file: TFile): string[] {
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService.populateStatuses();
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
const statuses = statusService.statuses[tagPrefix] || [];
|
||||
return statuses.length > 0 ? statuses.map((s) => s.name) : ["unknown"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all quick status commands
|
||||
*/
|
||||
private removeQuickStatusCommands(): void {
|
||||
const allStatuses = BaseNoteStatusService.getAllAvailableStatuses();
|
||||
|
||||
//TODO: If there is an update the
|
||||
allStatuses.forEach((status) => {
|
||||
// @ts-ignore
|
||||
this.app.commands.removeCommand(
|
||||
`note-status:set-status-${status.name}`,
|
||||
);
|
||||
// @ts-ignore
|
||||
this.app.commands.removeCommand(
|
||||
`note-status:toggle-status-${status.name}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
// Remove existing commands
|
||||
this.removeQuickStatusCommands();
|
||||
this.registeredCommands.clear();
|
||||
}
|
||||
}
|
||||
50
core/eventBus.ts
Normal file
50
core/eventBus.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { EventBusEvents, EventName } from "@/types/eventBus";
|
||||
|
||||
class EventBus {
|
||||
private events: { [K in EventName]: Map<string, EventBusEvents[K]> } = {
|
||||
"active-file-change": new Map(),
|
||||
"plugin-settings-changed": new Map(),
|
||||
"frontmatter-manually-changed": new Map(),
|
||||
"triggered-open-modal": new Map(),
|
||||
};
|
||||
|
||||
subscribe<T extends EventName>(
|
||||
event: T,
|
||||
callback: EventBusEvents[T],
|
||||
id: string,
|
||||
) {
|
||||
this.events[event].set(id, callback);
|
||||
return () => {
|
||||
this.events[event].delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
unsubscribe(event: EventName, id: string) {
|
||||
this.events[event].delete(id);
|
||||
}
|
||||
|
||||
publish(
|
||||
event: "active-file-change",
|
||||
...args: Parameters<EventBusEvents["active-file-change"]>
|
||||
): void;
|
||||
publish(
|
||||
event: "plugin-settings-changed",
|
||||
...args: Parameters<EventBusEvents["plugin-settings-changed"]>
|
||||
): void;
|
||||
publish(
|
||||
event: "frontmatter-manually-changed",
|
||||
...args: Parameters<EventBusEvents["frontmatter-manually-changed"]>
|
||||
): void;
|
||||
publish(
|
||||
event: "triggered-open-modal",
|
||||
...args: Parameters<EventBusEvents["triggered-open-modal"]>
|
||||
): void;
|
||||
publish(event: EventName, ...args: unknown[]) {
|
||||
const callbacks = this.events[event];
|
||||
callbacks.forEach((callback) => {
|
||||
(callback as (...args: unknown[]) => void)(...args);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new EventBus();
|
||||
156
core/lazyElementObserver.ts
Normal file
156
core/lazyElementObserver.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Interface for element processing callbacks
|
||||
*/
|
||||
export interface IElementProcessor {
|
||||
processElement(element: HTMLElement): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for observing DOM changes in file explorer
|
||||
* Single responsibility: DOM observation and element detection
|
||||
*/
|
||||
export class LazyElementObserver {
|
||||
private mutationObserver: MutationObserver | null = null;
|
||||
private intersectionObserver: IntersectionObserver | null = null;
|
||||
private processedElements = new WeakSet<HTMLElement>();
|
||||
private elementProcessor: IElementProcessor;
|
||||
|
||||
private readonly INTERSECTION_ROOT_MARGIN = "50px";
|
||||
private readonly FILE_SELECTOR = "[data-path]";
|
||||
|
||||
constructor(elementProcessor: IElementProcessor) {
|
||||
this.elementProcessor = elementProcessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up observers for a container element
|
||||
*/
|
||||
setupObservers(container: Element): void {
|
||||
this.cleanup();
|
||||
|
||||
this.initializeIntersectionObserver();
|
||||
this.initializeMutationObserver(container);
|
||||
this.observeExistingElements(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks element as unprocessed for re-processing
|
||||
*/
|
||||
markElementForReprocessing(element: HTMLElement): void {
|
||||
this.processedElements.delete(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all observers and reset state
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.disconnectObservers();
|
||||
this.resetState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes intersection observer for visibility detection
|
||||
*/
|
||||
private initializeIntersectionObserver(): void {
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
(entries) => this.handleIntersectionEntries(entries),
|
||||
{ rootMargin: this.INTERSECTION_ROOT_MARGIN },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles intersection observer entries
|
||||
*/
|
||||
private handleIntersectionEntries(
|
||||
entries: IntersectionObserverEntry[],
|
||||
): void {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const element = entry.target as HTMLElement;
|
||||
this.processElementIfNeeded(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes element if not already processed
|
||||
*/
|
||||
private processElementIfNeeded(element: HTMLElement): void {
|
||||
if (!this.processedElements.has(element)) {
|
||||
this.elementProcessor.processElement(element);
|
||||
this.processedElements.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes mutation observer for DOM changes
|
||||
*/
|
||||
private initializeMutationObserver(container: Element): void {
|
||||
this.mutationObserver = new MutationObserver((mutations) =>
|
||||
this.handleMutations(mutations),
|
||||
);
|
||||
|
||||
this.mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mutation observer entries
|
||||
*/
|
||||
private handleMutations(mutations: MutationRecord[]): void {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
this.handleAddedElement(node as HTMLElement);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles newly added DOM elements
|
||||
*/
|
||||
private handleAddedElement(element: HTMLElement): void {
|
||||
this.observeElement(element);
|
||||
|
||||
const fileElements = element.querySelectorAll(this.FILE_SELECTOR);
|
||||
fileElements.forEach((el) => this.observeElement(el as HTMLElement));
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes existing elements in container
|
||||
*/
|
||||
private observeExistingElements(container: Element): void {
|
||||
const fileElements = container.querySelectorAll(this.FILE_SELECTOR);
|
||||
fileElements.forEach((el) => this.observeElement(el as HTMLElement));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts observing a single element
|
||||
*/
|
||||
private observeElement(element: HTMLElement): void {
|
||||
const dataPath = element.getAttribute("data-path");
|
||||
if (dataPath && this.intersectionObserver) {
|
||||
this.intersectionObserver.observe(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects all observers
|
||||
*/
|
||||
private disconnectObservers(): void {
|
||||
this.mutationObserver?.disconnect();
|
||||
this.intersectionObserver?.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets internal state
|
||||
*/
|
||||
private resetState(): void {
|
||||
this.mutationObserver = null;
|
||||
this.intersectionObserver = null;
|
||||
this.processedElements = new WeakSet();
|
||||
}
|
||||
}
|
||||
540
core/noteStatusService.ts
Normal file
540
core/noteStatusService.ts
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import {
|
||||
GroupedStatuses,
|
||||
NoteStatus,
|
||||
NoteStatus as NoteStatusType,
|
||||
StatusIdentifier,
|
||||
ScopedStatusName,
|
||||
} from "@/types/noteStatus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import eventBus from "./eventBus";
|
||||
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
|
||||
|
||||
export abstract class BaseNoteStatusService {
|
||||
static app: App;
|
||||
statuses: GroupedStatuses;
|
||||
|
||||
constructor() {
|
||||
this.statuses = {};
|
||||
}
|
||||
|
||||
static initialize(app: App) {
|
||||
BaseNoteStatusService.app = app;
|
||||
}
|
||||
|
||||
static parseStatusIdentifier(
|
||||
identifier: StatusIdentifier,
|
||||
): ScopedStatusName {
|
||||
if (typeof identifier === "string") {
|
||||
if (identifier.includes(":")) {
|
||||
const [templateId, name] = identifier.split(":", 2);
|
||||
return { templateId, name };
|
||||
}
|
||||
return { name: identifier };
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
static formatStatusIdentifier(scopedName: ScopedStatusName): string {
|
||||
if (scopedName.templateId) {
|
||||
return `${scopedName.templateId}:${scopedName.name}`;
|
||||
}
|
||||
return scopedName.name;
|
||||
}
|
||||
|
||||
static resolveStatusFromIdentifier(
|
||||
identifier: StatusIdentifier,
|
||||
): NoteStatus | undefined {
|
||||
const parsed = BaseNoteStatusService.parseStatusIdentifier(identifier);
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
|
||||
if (parsed.templateId) {
|
||||
return availableStatuses.find(
|
||||
(s) =>
|
||||
s.name === parsed.name &&
|
||||
s.templateId === parsed.templateId,
|
||||
);
|
||||
}
|
||||
|
||||
return availableStatuses.find((s) => s.name === parsed.name);
|
||||
}
|
||||
|
||||
private static allEnabledTemplatesStatuses(): NoteStatusType[] {
|
||||
if (settingsService.settings.useCustomStatusesOnly) {
|
||||
return [];
|
||||
}
|
||||
const enabledTemplates = PREDEFINED_TEMPLATES.filter((template) =>
|
||||
settingsService.settings.enabledTemplates.includes(template.id),
|
||||
);
|
||||
return enabledTemplates.flatMap((t) => t.statuses);
|
||||
}
|
||||
|
||||
static getAllAvailableStatuses(): NoteStatus[] {
|
||||
const availableStatuses = [
|
||||
...settingsService.settings.customStatuses,
|
||||
...BaseNoteStatusService.allEnabledTemplatesStatuses(),
|
||||
];
|
||||
return availableStatuses;
|
||||
}
|
||||
|
||||
// static async insertStatusMetadataInEditor(file: TFile): Promise<void> {
|
||||
// const tagPrefix = settingsService.settings.tagPrefix;
|
||||
//
|
||||
// await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
// file,
|
||||
// (frontmatter) => {
|
||||
// const noteStatusFrontmatter =
|
||||
// (frontmatter?.[tagPrefix] as string[]) || [];
|
||||
// if (!noteStatusFrontmatter.length) {
|
||||
// frontmatter[tagPrefix] = [];
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
|
||||
protected statusNameToObject(statusName: string | StatusIdentifier) {
|
||||
if (typeof statusName === "string") {
|
||||
return BaseNoteStatusService.resolveStatusFromIdentifier(
|
||||
statusName,
|
||||
);
|
||||
}
|
||||
return BaseNoteStatusService.resolveStatusFromIdentifier(statusName);
|
||||
}
|
||||
|
||||
protected getStatusMetadataKeys(): string[] {
|
||||
return [settingsService.settings.tagPrefix];
|
||||
}
|
||||
|
||||
abstract populateStatuses(): void;
|
||||
abstract removeStatus(
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
): Promise<boolean>;
|
||||
abstract addStatus(
|
||||
frontmatterTagName: string,
|
||||
statusIdentifier: StatusIdentifier,
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class NoteStatusService extends BaseNoteStatusService {
|
||||
private file: TFile;
|
||||
|
||||
constructor(file: TFile) {
|
||||
super();
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public populateStatuses(): void {
|
||||
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
|
||||
this.statuses = Object.fromEntries(
|
||||
STATUS_METADATA_KEYS.map((key) => [key, []]),
|
||||
);
|
||||
|
||||
if (!this.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(this.file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) {
|
||||
return;
|
||||
}
|
||||
|
||||
STATUS_METADATA_KEYS.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
let statuses: (NoteStatusType | undefined)[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
statuses = value.map((v) =>
|
||||
this.statusNameToObject(v.toString()),
|
||||
);
|
||||
} else {
|
||||
statuses = [this.statusNameToObject(value.toString())];
|
||||
}
|
||||
|
||||
statuses = statuses.filter((s) => s !== undefined);
|
||||
if (
|
||||
statuses.length &&
|
||||
!settingsService.settings.useMultipleStatuses
|
||||
) {
|
||||
statuses = statuses.slice(0, 1);
|
||||
}
|
||||
this.statuses[key] = statuses as NoteStatusType[];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeStatus(
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
): Promise<boolean> {
|
||||
let removed = false;
|
||||
const targetIdentifier = status.templateId
|
||||
? BaseNoteStatusService.formatStatusIdentifier({
|
||||
templateId: status.templateId,
|
||||
name: status.name,
|
||||
})
|
||||
: status.name;
|
||||
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
(frontmatter?.[frontmatterTagName] as string[]) || [];
|
||||
if (!noteStatusFrontmatter.length) return;
|
||||
|
||||
if (Array.isArray(noteStatusFrontmatter)) {
|
||||
// First try to find exact match (scoped or legacy)
|
||||
let i = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) => statusName === targetIdentifier,
|
||||
);
|
||||
|
||||
// If not found and we're looking for a scoped status, try legacy format
|
||||
if (i === -1 && status.templateId) {
|
||||
i = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) => statusName === status.name,
|
||||
);
|
||||
}
|
||||
|
||||
if (i !== -1) {
|
||||
noteStatusFrontmatter.splice(i, 1);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (removed) {
|
||||
eventBus.publish("frontmatter-manually-changed", {
|
||||
file: this.file,
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async clearStatus(frontmatterTagName: string): Promise<boolean> {
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
(frontmatter) => {
|
||||
if (frontmatterTagName in frontmatter) {
|
||||
frontmatter[frontmatterTagName] = [];
|
||||
}
|
||||
},
|
||||
);
|
||||
eventBus.publish("frontmatter-manually-changed", { file: this.file });
|
||||
return true;
|
||||
}
|
||||
|
||||
async overrideStatuses(
|
||||
frontmatterTagName: string,
|
||||
statusIdentifiers: StatusIdentifier[],
|
||||
): Promise<boolean> {
|
||||
const formattedIdentifiers = statusIdentifiers.map((id) =>
|
||||
typeof id === "string"
|
||||
? id
|
||||
: BaseNoteStatusService.formatStatusIdentifier(id),
|
||||
);
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
(frontmatter) => {
|
||||
if (
|
||||
frontmatterTagName in frontmatter &&
|
||||
Array.isArray(frontmatter[frontmatterTagName])
|
||||
) {
|
||||
frontmatter[frontmatterTagName].splice(0);
|
||||
frontmatter[frontmatterTagName].push(
|
||||
...formattedIdentifiers,
|
||||
);
|
||||
}
|
||||
frontmatter[frontmatterTagName] = [...formattedIdentifiers];
|
||||
},
|
||||
);
|
||||
eventBus.publish("frontmatter-manually-changed", { file: this.file });
|
||||
return true;
|
||||
}
|
||||
|
||||
async addStatus(
|
||||
frontmatterTagName: string,
|
||||
statusIdentifier: StatusIdentifier,
|
||||
): Promise<boolean> {
|
||||
let added = false;
|
||||
const formattedIdentifier =
|
||||
typeof statusIdentifier === "string"
|
||||
? statusIdentifier
|
||||
: BaseNoteStatusService.formatStatusIdentifier(
|
||||
statusIdentifier,
|
||||
);
|
||||
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
(frontmatter?.[frontmatterTagName] as string[]) || [];
|
||||
if (!settingsService.settings.useMultipleStatuses) {
|
||||
frontmatter[frontmatterTagName] = [formattedIdentifier];
|
||||
added = true;
|
||||
} else {
|
||||
// Ensure frontmatter property exists as an array
|
||||
if (
|
||||
!frontmatter[frontmatterTagName] ||
|
||||
!Array.isArray(frontmatter[frontmatterTagName])
|
||||
) {
|
||||
frontmatter[frontmatterTagName] = [];
|
||||
}
|
||||
|
||||
// Check if we already have this status (exact match)
|
||||
const exactMatch = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) =>
|
||||
statusName === formattedIdentifier,
|
||||
);
|
||||
|
||||
if (exactMatch === -1) {
|
||||
// Check if we have a legacy version of this scoped status
|
||||
let legacyIndex = -1;
|
||||
if (
|
||||
typeof statusIdentifier !== "string" &&
|
||||
statusIdentifier.templateId
|
||||
) {
|
||||
legacyIndex = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) =>
|
||||
statusName === statusIdentifier.name,
|
||||
);
|
||||
}
|
||||
|
||||
if (legacyIndex !== -1) {
|
||||
// Replace legacy with scoped version
|
||||
noteStatusFrontmatter[legacyIndex] =
|
||||
formattedIdentifier;
|
||||
added = true;
|
||||
} else {
|
||||
// Add new status
|
||||
frontmatter[frontmatterTagName].push(
|
||||
formattedIdentifier,
|
||||
);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if (added) {
|
||||
eventBus.publish("frontmatter-manually-changed", {
|
||||
file: this.file,
|
||||
});
|
||||
}
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
export class MultipleNoteStatusService extends BaseNoteStatusService {
|
||||
private files: TFile[];
|
||||
|
||||
constructor(files: TFile[]) {
|
||||
super();
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
selectedFilesQTY() {
|
||||
return this.files.length;
|
||||
}
|
||||
|
||||
public populateStatuses(): void {
|
||||
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
|
||||
|
||||
this.statuses = Object.fromEntries(
|
||||
STATUS_METADATA_KEYS.map((key) => [key, []]),
|
||||
);
|
||||
|
||||
const allStatuses = new Map<string, Set<string>>();
|
||||
|
||||
this.files.forEach((file) => {
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) return;
|
||||
|
||||
STATUS_METADATA_KEYS.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
if (!allStatuses.has(key)) {
|
||||
allStatuses.set(key, new Set());
|
||||
}
|
||||
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((name) =>
|
||||
allStatuses.get(key)!.add(name.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
STATUS_METADATA_KEYS.forEach((key) => {
|
||||
const statusNames = allStatuses.get(key);
|
||||
if (statusNames) {
|
||||
const statuses = Array.from(statusNames)
|
||||
.map((name) => this.statusNameToObject(name))
|
||||
.filter((s) => s !== undefined) as NoteStatusType[];
|
||||
|
||||
this.statuses[key] = statuses;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeStatus(
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
): Promise<boolean> {
|
||||
let removedFromAny = false;
|
||||
const targetIdentifier = status.templateId
|
||||
? BaseNoteStatusService.formatStatusIdentifier({
|
||||
templateId: status.templateId,
|
||||
name: status.name,
|
||||
})
|
||||
: status.name;
|
||||
|
||||
const promises = this.files.map(async (file) => {
|
||||
let removed = false;
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
frontmatter[frontmatterTagName];
|
||||
if (!noteStatusFrontmatter) return;
|
||||
|
||||
if (Array.isArray(noteStatusFrontmatter)) {
|
||||
// First try to find exact match (scoped or legacy)
|
||||
let index = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) =>
|
||||
statusName === targetIdentifier,
|
||||
);
|
||||
|
||||
// If not found and we're looking for a scoped status, try legacy format
|
||||
if (index === -1 && status.templateId) {
|
||||
index = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) =>
|
||||
statusName === status.name,
|
||||
);
|
||||
}
|
||||
|
||||
if (index !== -1) {
|
||||
noteStatusFrontmatter.splice(index, 1);
|
||||
removed = true;
|
||||
}
|
||||
} else if (
|
||||
noteStatusFrontmatter === targetIdentifier ||
|
||||
(status.templateId &&
|
||||
noteStatusFrontmatter === status.name)
|
||||
) {
|
||||
delete frontmatter[frontmatterTagName];
|
||||
removed = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
return removed;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
removedFromAny = results.some((result) => result);
|
||||
|
||||
if (removedFromAny) {
|
||||
this.files.forEach((file) => {
|
||||
eventBus.publish("frontmatter-manually-changed", { file });
|
||||
});
|
||||
}
|
||||
|
||||
return removedFromAny;
|
||||
}
|
||||
|
||||
async addStatus(
|
||||
frontmatterTagName: string,
|
||||
statusIdentifier: StatusIdentifier,
|
||||
): Promise<boolean> {
|
||||
let addedToAny = false;
|
||||
const formattedIdentifier =
|
||||
typeof statusIdentifier === "string"
|
||||
? statusIdentifier
|
||||
: BaseNoteStatusService.formatStatusIdentifier(
|
||||
statusIdentifier,
|
||||
);
|
||||
|
||||
const promises = this.files.map(async (file) => {
|
||||
let added = false;
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
frontmatter[frontmatterTagName];
|
||||
|
||||
if (!noteStatusFrontmatter) {
|
||||
frontmatter[frontmatterTagName] = [formattedIdentifier];
|
||||
added = true;
|
||||
} else if (Array.isArray(noteStatusFrontmatter)) {
|
||||
if (
|
||||
!noteStatusFrontmatter.includes(formattedIdentifier)
|
||||
) {
|
||||
noteStatusFrontmatter.push(formattedIdentifier);
|
||||
added = true;
|
||||
}
|
||||
} else {
|
||||
if (noteStatusFrontmatter !== formattedIdentifier) {
|
||||
frontmatter[frontmatterTagName] = [
|
||||
noteStatusFrontmatter,
|
||||
formattedIdentifier,
|
||||
];
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return added;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
addedToAny = results.some((result) => result);
|
||||
|
||||
if (addedToAny) {
|
||||
this.files.forEach((file) => {
|
||||
eventBus.publish("frontmatter-manually-changed", { file });
|
||||
});
|
||||
}
|
||||
|
||||
return addedToAny;
|
||||
}
|
||||
|
||||
getFilesWithStatus(
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
): TFile[] {
|
||||
const targetIdentifier = status.templateId
|
||||
? BaseNoteStatusService.formatStatusIdentifier({
|
||||
templateId: status.templateId,
|
||||
name: status.name,
|
||||
})
|
||||
: status.name;
|
||||
|
||||
return this.files.filter((file) => {
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) return false;
|
||||
|
||||
const value = frontmatter[frontmatterTagName];
|
||||
if (!value) return false;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Check for exact match first, then legacy format if scoped
|
||||
return (
|
||||
value.includes(targetIdentifier) ||
|
||||
(status.templateId && value.includes(status.name))
|
||||
);
|
||||
}
|
||||
// Check for exact match first, then legacy format if scoped
|
||||
return (
|
||||
value === targetIdentifier ||
|
||||
(status.templateId && value === status.name)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
18
core/selectorService.ts
Normal file
18
core/selectorService.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { App } from "obsidian";
|
||||
import {
|
||||
MultipleNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "./noteStatusService";
|
||||
|
||||
class SelectorService {
|
||||
static app: App;
|
||||
public noteStatusService: NoteStatusService | MultipleNoteStatusService;
|
||||
|
||||
constructor(
|
||||
noteStatusService: NoteStatusService | MultipleNoteStatusService,
|
||||
) {
|
||||
this.noteStatusService = noteStatusService;
|
||||
}
|
||||
}
|
||||
|
||||
export default SelectorService;
|
||||
46
core/settingsService.ts
Normal file
46
core/settingsService.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import { Plugin } from "obsidian";
|
||||
import eventBus from "./eventBus";
|
||||
|
||||
class SettingsService {
|
||||
private plugin: Plugin;
|
||||
public settings: PluginSettings;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async initialize(plugin: Plugin): Promise<void> {
|
||||
this.plugin = plugin;
|
||||
await this.loadSettings();
|
||||
}
|
||||
|
||||
setValue(key: keyof PluginSettings, value: unknown) {
|
||||
if (!(key in this.settings)) {
|
||||
throw new Error(`The "${key}" setting is not a known setting key`);
|
||||
}
|
||||
// const oldValue = this.settings[key]; // TODO: Send the old value
|
||||
this.settings[key] = value;
|
||||
this.saveSettings().catch(console.error);
|
||||
// INFO: Send the propgation event
|
||||
eventBus.publish("plugin-settings-changed", {
|
||||
key,
|
||||
value,
|
||||
currentSettings: this.settings,
|
||||
});
|
||||
}
|
||||
|
||||
private async loadSettings() {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_PLUGIN_SETTINGS,
|
||||
await this.plugin.loadData(),
|
||||
);
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
private async saveSettings() {
|
||||
await this.plugin.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SettingsService();
|
||||
|
|
@ -26,7 +26,7 @@ const context = await esbuild.context({
|
|||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["main.ts"],
|
||||
entryPoints: ["main.tsx"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
|
|
@ -51,6 +51,9 @@ const context = await esbuild.context({
|
|||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
jsx: "transform",
|
||||
jsxFactory: "React.createElement",
|
||||
jsxFragment: "React.Fragment",
|
||||
});
|
||||
|
||||
// CSS context for watch mode
|
||||
|
|
|
|||
|
|
@ -1,323 +0,0 @@
|
|||
// integrations/commands/command-integration.ts
|
||||
import { App, Editor, MarkdownView, Notice, TFile } from "obsidian";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { StatusDropdown } from "components/status-dropdown";
|
||||
import { StatusPaneViewController } from "views/status-pane-view";
|
||||
import NoteStatus from "main";
|
||||
|
||||
export class CommandIntegration {
|
||||
private app: App;
|
||||
private plugin: NoteStatus;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: NoteStatus,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown,
|
||||
) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
// Re-register commands when settings change
|
||||
this.unload();
|
||||
this.registerCommands();
|
||||
}
|
||||
|
||||
public registerCommands(): void {
|
||||
// Open status pane
|
||||
this.plugin.addCommand({
|
||||
id: "open-status-pane",
|
||||
name: "Open status pane",
|
||||
callback: () => StatusPaneViewController.open(this.app),
|
||||
});
|
||||
|
||||
// Change status of current note
|
||||
this.plugin.addCommand({
|
||||
id: "change-status",
|
||||
name: "Change status of current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusDropdown.openStatusDropdown({ files: [file] });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Add status to current note
|
||||
this.plugin.addCommand({
|
||||
id: "add-status",
|
||||
name: "Add status to current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
files: [file],
|
||||
mode: this.settings.useMultipleStatuses
|
||||
? "add"
|
||||
: "replace",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Insert status metadata
|
||||
this.plugin.addCommand({
|
||||
id: "insert-status-metadata",
|
||||
name: "Insert status metadata",
|
||||
editorCheckCallback: (
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
) => {
|
||||
if (!view.file) return false;
|
||||
|
||||
const statuses = this.statusService.getFileStatuses(view.file);
|
||||
const hasNoStatus =
|
||||
statuses.length === 1 && statuses[0] === "unknown";
|
||||
|
||||
if (!checking && hasNoStatus) {
|
||||
this.statusService.insertStatusMetadataInEditor(editor);
|
||||
new Notice("Status metadata inserted");
|
||||
}
|
||||
return hasNoStatus;
|
||||
},
|
||||
});
|
||||
|
||||
// Cycle through statuses
|
||||
this.plugin.addCommand({
|
||||
id: "cycle-status",
|
||||
name: "Cycle to next status",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.cycleStatus(file);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Register dynamic quick status commands
|
||||
this.registerQuickStatusCommands();
|
||||
|
||||
// Clear status
|
||||
this.plugin.addCommand({
|
||||
id: "clear-status",
|
||||
name: "Clear status (set to unknown)",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: "unknown",
|
||||
operation: "set",
|
||||
});
|
||||
new Notice("Status cleared");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Copy status from current note
|
||||
this.plugin.addCommand({
|
||||
id: "copy-status",
|
||||
name: "Copy status from current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
const statuses = this.statusService.getFileStatuses(file);
|
||||
this.app.clipboard = statuses;
|
||||
new Notice(`Copied status: ${statuses.join(", ")}`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Paste status to current note
|
||||
this.plugin.addCommand({
|
||||
id: "paste-status",
|
||||
name: "Paste status to current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
const clipboard = this.app.clipboard;
|
||||
if (!file || !clipboard || !Array.isArray(clipboard))
|
||||
return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: clipboard,
|
||||
operation: "set",
|
||||
});
|
||||
new Notice(`Pasted status: ${clipboard.join(", ")}`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Toggle multiple statuses mode
|
||||
this.plugin.addCommand({
|
||||
id: "toggle-multiple-statuses",
|
||||
name: "Toggle multiple statuses mode",
|
||||
callback: () => {
|
||||
this.settings.useMultipleStatuses =
|
||||
!this.settings.useMultipleStatuses;
|
||||
this.plugin.saveSettings();
|
||||
new Notice(
|
||||
`Multiple statuses mode ${this.settings.useMultipleStatuses ? "enabled" : "disabled"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Search notes by status
|
||||
this.plugin.addCommand({
|
||||
id: "search-by-status",
|
||||
name: "Search notes by current status",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
const statuses = this.statusService.getFileStatuses(file);
|
||||
const query = `[${this.settings.tagPrefix}:"${statuses[0]}"]`;
|
||||
this.app.internalPlugins
|
||||
.getPluginById("global-search")
|
||||
?.instance.openGlobalSearch(query);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register quick status commands based on settings
|
||||
*/
|
||||
private registerQuickStatusCommands(): void {
|
||||
const quickCommands = this.settings.quickStatusCommands || [];
|
||||
const allStatuses = this.statusService.getAllStatuses();
|
||||
|
||||
quickCommands.forEach((statusName) => {
|
||||
const status = allStatuses.find((s) => s.name === statusName);
|
||||
if (!status) return;
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: `set-status-${statusName}`,
|
||||
name: `Set status to ${statusName}`,
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: statusName,
|
||||
operation: "set",
|
||||
});
|
||||
new Notice(`Status set to ${statusName}`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Add toggle command for multiple status mode
|
||||
if (this.settings.useMultipleStatuses) {
|
||||
this.plugin.addCommand({
|
||||
id: `toggle-status-${statusName}`,
|
||||
name: `Toggle status ${statusName}`,
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: statusName,
|
||||
operation: "toggle",
|
||||
});
|
||||
const currentStatuses =
|
||||
this.statusService.getFileStatuses(file);
|
||||
const hasStatus =
|
||||
currentStatuses.includes(statusName);
|
||||
new Notice(
|
||||
`Status ${statusName} ${hasStatus ? "added" : "removed"}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private cycleStatus(file: TFile): void {
|
||||
const allStatuses = this.statusService
|
||||
.getAllStatuses()
|
||||
.filter((s) => s.name !== "unknown")
|
||||
.map((s) => s.name);
|
||||
|
||||
if (allStatuses.length === 0) {
|
||||
new Notice("No statuses available");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStatuses = this.statusService.getFileStatuses(file);
|
||||
const currentStatus = currentStatuses[0];
|
||||
|
||||
let nextIndex = 0;
|
||||
if (currentStatus !== "unknown") {
|
||||
const currentIndex = allStatuses.indexOf(currentStatus);
|
||||
nextIndex = (currentIndex + 1) % allStatuses.length;
|
||||
}
|
||||
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: allStatuses[nextIndex],
|
||||
operation: "set",
|
||||
});
|
||||
|
||||
new Notice(`Status changed to ${allStatuses[nextIndex]}`);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
// Remove existing commands
|
||||
this.removeQuickStatusCommands();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all quick status commands
|
||||
*/
|
||||
private removeQuickStatusCommands(): void {
|
||||
const allStatuses = this.statusService.getAllStatuses();
|
||||
|
||||
allStatuses.forEach((status) => {
|
||||
this.app.commands.removeCommand(
|
||||
`note-status:set-status-${status.name}`,
|
||||
);
|
||||
this.app.commands.removeCommand(
|
||||
`note-status:toggle-status-${status.name}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
55
integrations/commands/commandsIntegration.ts
Normal file
55
integrations/commands/commandsIntegration.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import eventBus from "@/core/eventBus";
|
||||
import { Plugin } from "obsidian";
|
||||
import { CommandsService } from "../../core/commandsService";
|
||||
|
||||
export class CommandsIntegration {
|
||||
private static instance: CommandsIntegration | null = null;
|
||||
private plugin: Plugin;
|
||||
private commandsService: CommandsService;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
if (CommandsIntegration.instance) {
|
||||
throw new Error(
|
||||
"CommandsIntegration instance already created. Use getInstance() instead.",
|
||||
);
|
||||
}
|
||||
this.plugin = plugin;
|
||||
this.commandsService = new CommandsService(plugin);
|
||||
CommandsIntegration.instance = this;
|
||||
}
|
||||
|
||||
static getInstance(): CommandsIntegration | null {
|
||||
return CommandsIntegration.instance;
|
||||
}
|
||||
|
||||
async integrate(): Promise<void> {
|
||||
// Register all commands from the service
|
||||
this.commandsService.registerAllCommands();
|
||||
|
||||
eventBus.subscribe(
|
||||
"plugin-settings-changed",
|
||||
({ key }) => {
|
||||
if (
|
||||
key === "quickStatusCommands" ||
|
||||
key === "useMultipleStatuses"
|
||||
) {
|
||||
this.commandsService.destroy();
|
||||
/// BUG: if removed a command will persist because is not removed, you need the oldStates to send it to be disabled // const oldValue = this.settings[key]; // TODO: Send the old value
|
||||
this.commandsService.registerAllCommands(); // INFO: Reset the registered commands
|
||||
}
|
||||
},
|
||||
"commandsIntegrationSubscription2",
|
||||
);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
eventBus.unsubscribe(
|
||||
"plugin-settings-changed",
|
||||
"commandsIntegrationSubscription2",
|
||||
);
|
||||
if (this.commandsService) {
|
||||
this.commandsService.destroy();
|
||||
}
|
||||
CommandsIntegration.instance = null;
|
||||
}
|
||||
}
|
||||
127
integrations/context-menu/contextMenuIntegration.tsx
Normal file
127
integrations/context-menu/contextMenuIntegration.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import eventBus from "core/eventBus";
|
||||
import {
|
||||
Menu,
|
||||
Notice,
|
||||
Plugin,
|
||||
TAbstractFile,
|
||||
TFile,
|
||||
WorkspaceLeaf,
|
||||
} from "obsidian";
|
||||
import {
|
||||
MultipleNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
|
||||
export class ContextMenuIntegration {
|
||||
private static instance: ContextMenuIntegration | null = null;
|
||||
private plugin: Plugin;
|
||||
private currentLeaf: WorkspaceLeaf | null = null;
|
||||
private noteStatusService:
|
||||
| NoteStatusService
|
||||
| MultipleNoteStatusService
|
||||
| null = null;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
if (ContextMenuIntegration.instance) {
|
||||
throw new Error("The context menu instance is already created");
|
||||
}
|
||||
this.plugin = plugin;
|
||||
ContextMenuIntegration.instance = this;
|
||||
}
|
||||
|
||||
async integrate() {
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on(
|
||||
"files-menu",
|
||||
(
|
||||
menu: Menu,
|
||||
files: TAbstractFile[],
|
||||
source: string,
|
||||
leaf?: WorkspaceLeaf,
|
||||
) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Change note state")
|
||||
.setIcon("rotate-ccw") // Lucide icon
|
||||
.onClick(async () => {
|
||||
const tFiles = files.filter(
|
||||
(f): f is TFile => f instanceof TFile,
|
||||
);
|
||||
if (tFiles.length) {
|
||||
this.openMultipleFilesStatusesModal(tFiles);
|
||||
} else {
|
||||
new Notice(
|
||||
"The selected files are not valid to add status, just .md files can have status in this plugin version",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on("file-menu", (menu, file, f, f2) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Change note state")
|
||||
.setIcon("rotate-ccw") // Lucide icon
|
||||
.onClick(async () => {
|
||||
if (file instanceof TFile) {
|
||||
this.openSingleFileStatusesModal(file);
|
||||
} else {
|
||||
new Notice(
|
||||
"The selected file is not valid to add status, just .md files can have status in this plugin version",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on(
|
||||
"editor-menu",
|
||||
(menu, editor, view) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Change note state")
|
||||
.setIcon("rotate-ccw") // Lucide icon
|
||||
.onClick(async () => {
|
||||
if (view.file) {
|
||||
if (view.file instanceof TFile) {
|
||||
this.openSingleFileStatusesModal(
|
||||
view.file,
|
||||
);
|
||||
} else {
|
||||
new Notice(
|
||||
"The selected file is not valid to add status, just .md files can have status in this plugin version",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private openMultipleFilesStatusesModal(tFiles: TFile[]) {
|
||||
const multipleStatusService = new MultipleNoteStatusService(tFiles);
|
||||
multipleStatusService.populateStatuses();
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: multipleStatusService,
|
||||
});
|
||||
}
|
||||
private openSingleFileStatusesModal(tFile: TFile) {
|
||||
const noteStatusService = new NoteStatusService(tFile);
|
||||
noteStatusService.populateStatuses();
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: noteStatusService,
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.currentLeaf = null;
|
||||
this.noteStatusService = null;
|
||||
ContextMenuIntegration.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ContextMenuIntegration;
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { App, Menu, TFile } from "obsidian";
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { ExplorerIntegration } from "integrations/explorer/explorer-integration";
|
||||
import { StatusContextMenu } from "integrations/context-menu/status-context-menu";
|
||||
|
||||
/**
|
||||
* Gestiona la integración de menús contextuales con el explorador de archivos
|
||||
*/
|
||||
export class FileContextMenuIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private explorerIntegration: ExplorerIntegration;
|
||||
private statusContextMenu: StatusContextMenu;
|
||||
private fileMenuEventRef: (menu: Menu, file: TFile, source: string) => void;
|
||||
private filesMenuEventRef: (menu: Menu, files: TFile[]) => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
explorerIntegration: ExplorerIntegration,
|
||||
statusContextMenu: StatusContextMenu,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.explorerIntegration = explorerIntegration;
|
||||
this.statusContextMenu = statusContextMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza la configuración
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra los eventos del menú contextual de archivos
|
||||
*/
|
||||
public registerFileContextMenuEvents(): void {
|
||||
this.fileMenuEventRef = (menu, file, source) => {
|
||||
if (
|
||||
source === "file-explorer-context-menu" &&
|
||||
file instanceof TFile &&
|
||||
file.extension === "md"
|
||||
) {
|
||||
this.addStatusChangeMenu(menu, file);
|
||||
}
|
||||
};
|
||||
|
||||
this.filesMenuEventRef = (menu, files) => {
|
||||
const mdFiles = files.filter(
|
||||
(file) => file instanceof TFile && file.extension === "md",
|
||||
) as TFile[];
|
||||
|
||||
if (mdFiles.length > 0) {
|
||||
this.addBatchStatusChangeMenu(menu, mdFiles);
|
||||
}
|
||||
};
|
||||
|
||||
this.app.workspace.on("file-menu", this.fileMenuEventRef);
|
||||
this.app.workspace.on("files-menu", this.filesMenuEventRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade opción de cambio de estado al menú contextual de un archivo
|
||||
*/
|
||||
private addStatusChangeMenu(menu: Menu, file: TFile): void {
|
||||
this.statusContextMenu.addStatusMenuItemToSingleFile(
|
||||
menu,
|
||||
file,
|
||||
(file) => {
|
||||
const selectedFiles =
|
||||
this.explorerIntegration.getSelectedFiles();
|
||||
if (selectedFiles.length > 1) {
|
||||
this.statusContextMenu.showForFiles(selectedFiles);
|
||||
} else {
|
||||
this.statusContextMenu.showForFiles([file]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade opción de cambio de estado para múltiples archivos
|
||||
*/
|
||||
private addBatchStatusChangeMenu(menu: Menu, files: TFile[]): void {
|
||||
this.statusContextMenu.addStatusMenuItemToBatch(
|
||||
menu,
|
||||
files,
|
||||
(files) => {
|
||||
this.statusContextMenu.showForFiles(files);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
if (this.fileMenuEventRef) {
|
||||
this.app.workspace.off("file-menu", this.fileMenuEventRef);
|
||||
}
|
||||
if (this.filesMenuEventRef) {
|
||||
this.app.workspace.off("files-menu", this.filesMenuEventRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { App, Menu, TFile } from "obsidian";
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { StatusDropdown } from "components/status-dropdown";
|
||||
import { ExplorerIntegration } from "integrations/explorer/explorer-integration";
|
||||
|
||||
/**
|
||||
* Gestiona los menús contextuales para cambios de estado
|
||||
*/
|
||||
export class StatusContextMenu {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
private explorerIntegration: ExplorerIntegration;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown,
|
||||
explorerIntegration: ExplorerIntegration,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
this.explorerIntegration = explorerIntegration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza la configuración
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade ítem de menú para cambiar estado de un archivo
|
||||
*/
|
||||
public addStatusMenuItemToSingleFile(
|
||||
menu: Menu,
|
||||
file: TFile,
|
||||
onClick: (file: TFile) => void,
|
||||
): void {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Change status")
|
||||
.setIcon("tag")
|
||||
.onClick(() => onClick(file)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade ítem de menú para cambiar estado de múltiples archivos
|
||||
*/
|
||||
public addStatusMenuItemToBatch(
|
||||
menu: Menu,
|
||||
files: TFile[],
|
||||
onClick: (files: TFile[]) => void,
|
||||
): void {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Change status")
|
||||
.setIcon("tag")
|
||||
.onClick(() => onClick(files)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el menú contextual para cambiar estado de uno o más archivos
|
||||
* @param files Archivos a los que cambiar el estado
|
||||
* @param position Posición opcional para mostrar el menú
|
||||
*/
|
||||
public showForFiles(
|
||||
files: TFile[],
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (files.length === 1) {
|
||||
this.showForSingleFile(files[0], position);
|
||||
} else {
|
||||
this.showForMultipleFiles(files, position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el menú contextual para un solo archivo
|
||||
* @param file Archivo al que cambiar el estado
|
||||
* @param position Posición opcional para mostrar el menú
|
||||
*/
|
||||
private showForSingleFile(
|
||||
file: TFile,
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
if (!(file instanceof TFile) || file.extension !== "md") return;
|
||||
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
position,
|
||||
files: [file],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el menú contextual para múltiples archivos
|
||||
* @param files Archivos a los que cambiar el estado
|
||||
* @param position Posición opcional para mostrar el menú
|
||||
*/
|
||||
private showForMultipleFiles(
|
||||
files: TFile[],
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
const menu = new Menu();
|
||||
|
||||
// Elemento de información (deshabilitado)
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`Update ${files.length} files`).setDisabled(true);
|
||||
return item;
|
||||
});
|
||||
|
||||
// Opción para gestionar estados
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Manage statuses...")
|
||||
.setIcon("tag")
|
||||
.onClick(() => {
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
position,
|
||||
files,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Mostrar el menú en la posición adecuada
|
||||
if (position) {
|
||||
menu.showAtPosition(position);
|
||||
} else {
|
||||
// Use a centered position
|
||||
menu.showAtPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { App, Editor, MarkdownView, Menu } from "obsidian";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { StatusDropdown } from "components/status-dropdown";
|
||||
|
||||
export class EditorIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
private editorMenuRef: (
|
||||
menu: Menu,
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
) => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public registerEditorMenus(): void {
|
||||
this.editorMenuRef = (
|
||||
menu: Menu,
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
) => {
|
||||
if (view.file) {
|
||||
this.addStatusMenuItems(menu, editor, view);
|
||||
}
|
||||
};
|
||||
|
||||
this.app.workspace.on("editor-menu", this.editorMenuRef);
|
||||
}
|
||||
|
||||
private addStatusMenuItems(
|
||||
menu: Menu,
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
): void {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Change note status")
|
||||
.setIcon("tag")
|
||||
.onClick(() => {
|
||||
if (view.file) {
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
files: [view.file],
|
||||
editor,
|
||||
view,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Only show insert metadata if it doesn't exist
|
||||
if (view.file) {
|
||||
const statuses = this.statusService.getFileStatuses(view.file);
|
||||
if (statuses.length === 1 && statuses[0] === "unknown") {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Insert status metadata")
|
||||
.setIcon("plus-circle")
|
||||
.onClick(() => {
|
||||
this.insertStatusMetadata(editor);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public insertStatusMetadata(editor: Editor): void {
|
||||
this.statusService.insertStatusMetadataInEditor(editor);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
if (this.editorMenuRef) {
|
||||
this.app.workspace.off("editor-menu", this.editorMenuRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { EditorIntegration } from "./editor-integration";
|
||||
export { ToolbarIntegration } from "./toolbar-integration";
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import { App, MarkdownView } from "obsidian";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { StatusService } from "../../services/status-service";
|
||||
import { StatusDropdown } from "../../components/status-dropdown";
|
||||
import { ToolbarButton } from "components/toolbar-button";
|
||||
|
||||
/**
|
||||
* Gestiona la integración con la barra de herramientas del editor
|
||||
*/
|
||||
export class ToolbarIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
private buttonView: ToolbarButton;
|
||||
private buttonElement: HTMLElement | null = null;
|
||||
private currentLeafId: string | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
this.buttonView = new ToolbarButton(settings, statusService);
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.buttonView.updateSettings(settings);
|
||||
this.updateStatusDisplay([]);
|
||||
}
|
||||
|
||||
public addToolbarButtonToActiveLeaf(statuses?: string[]): void {
|
||||
const activeLeaf = this.app.workspace.activeLeaf;
|
||||
if (!activeLeaf?.view || !(activeLeaf.view instanceof MarkdownView))
|
||||
return;
|
||||
|
||||
const leafId = activeLeaf.id || activeLeaf.view.containerEl.id;
|
||||
|
||||
// Only recreate button if we're on a different leaf or button doesn't exist
|
||||
if (
|
||||
this.currentLeafId !== leafId ||
|
||||
!this.buttonElement ||
|
||||
!this.isButtonInDOM()
|
||||
) {
|
||||
this.recreateButton(activeLeaf.view, leafId);
|
||||
}
|
||||
|
||||
this.updateButtonDisplay(statuses);
|
||||
}
|
||||
|
||||
private recreateButton(view: MarkdownView, leafId: string): void {
|
||||
const toolbarContainer = view.containerEl.querySelector(
|
||||
".view-header .view-actions",
|
||||
);
|
||||
if (!toolbarContainer) return;
|
||||
|
||||
// Remove old button if it exists
|
||||
this.removeToolbarButton();
|
||||
|
||||
// Create new button
|
||||
this.buttonElement = this.buttonView.createElement();
|
||||
this.buttonElement.addEventListener(
|
||||
"click",
|
||||
this.handleButtonClick.bind(this),
|
||||
);
|
||||
|
||||
if (toolbarContainer.firstChild) {
|
||||
toolbarContainer.insertBefore(
|
||||
this.buttonElement,
|
||||
toolbarContainer.firstChild,
|
||||
);
|
||||
} else {
|
||||
toolbarContainer.appendChild(this.buttonElement);
|
||||
}
|
||||
|
||||
this.currentLeafId = leafId;
|
||||
}
|
||||
|
||||
private isButtonInDOM(): boolean {
|
||||
return this.buttonElement?.isConnected === true;
|
||||
}
|
||||
|
||||
private removeToolbarButton(): void {
|
||||
this.app.workspace.iterateAllLeaves((leaf) => {
|
||||
if (leaf.view instanceof MarkdownView) {
|
||||
const buttons = leaf.view.containerEl.querySelectorAll(
|
||||
".note-status-toolbar-button",
|
||||
);
|
||||
buttons.forEach((button) => button.remove());
|
||||
}
|
||||
});
|
||||
|
||||
this.buttonElement = null;
|
||||
this.currentLeafId = null;
|
||||
}
|
||||
|
||||
private updateButtonDisplay(overrideStatuses?: string[]): void {
|
||||
if (!this.buttonElement) return;
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) return;
|
||||
|
||||
const statuses = overrideStatuses?.length
|
||||
? overrideStatuses
|
||||
: this.statusService.getFileStatuses(activeFile);
|
||||
this.buttonView.updateDisplay(statuses);
|
||||
}
|
||||
|
||||
private handleButtonClick(e: MouseEvent): void {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) return;
|
||||
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
target: this.buttonElement || undefined,
|
||||
files: [activeFile],
|
||||
});
|
||||
}
|
||||
|
||||
public updateStatusDisplay(statuses: string[]): void {
|
||||
this.updateButtonDisplay(statuses);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
// this.buttonView.destroy();
|
||||
this.removeToolbarButton();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
import { App, TFile, setTooltip, debounce } from "obsidian";
|
||||
import { NoteStatusSettings, FileExplorerView } from "../../models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
/**
|
||||
* Manages the logic for file explorer status integration
|
||||
*/
|
||||
export class ExplorerIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private ui: ExplorerIntegrationUI;
|
||||
private iconUpdateQueue = new Set<string>();
|
||||
private isProcessingQueue = false;
|
||||
private debouncedUpdateAll: ReturnType<typeof debounce>;
|
||||
private fileItemsWasUndefined = false;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.ui = new ExplorerIntegrationUI(
|
||||
app,
|
||||
settings,
|
||||
statusService,
|
||||
() => {
|
||||
this.fileItemsWasUndefined = true;
|
||||
},
|
||||
);
|
||||
this.debouncedUpdateAll = debounce(
|
||||
this.processUpdateQueue.bind(this),
|
||||
100,
|
||||
true,
|
||||
);
|
||||
|
||||
// Listen for layout changes to detect when file explorer becomes available
|
||||
this.app.workspace.on("layout-change", () => {
|
||||
this.checkFileExplorerAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file explorer became available after being unavailable
|
||||
*/
|
||||
private checkFileExplorerAvailability(): void {
|
||||
if (!this.settings.showStatusIconsInExplorer) return;
|
||||
|
||||
const fileExplorer = this.ui.findFileExplorerView();
|
||||
if (fileExplorer?.fileItems && this.fileItemsWasUndefined) {
|
||||
this.fileItemsWasUndefined = false;
|
||||
setTimeout(() => this.updateAllFileExplorerIcons(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings and refreshes UI if necessary
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
const shouldRefreshIcons =
|
||||
this.settings.showStatusIconsInExplorer !==
|
||||
settings.showStatusIconsInExplorer ||
|
||||
this.settings.hideUnknownStatusInExplorer !==
|
||||
settings.hideUnknownStatusInExplorer;
|
||||
|
||||
this.settings = { ...settings };
|
||||
this.ui.updateSettings(settings);
|
||||
|
||||
if (shouldRefreshIcons) {
|
||||
this.ui.removeAllFileExplorerIcons();
|
||||
|
||||
if (settings.showStatusIconsInExplorer) {
|
||||
setTimeout(() => this.updateAllFileExplorerIcons(), 50);
|
||||
}
|
||||
} else if (settings.showStatusIconsInExplorer) {
|
||||
this.updateAllFileExplorerIcons();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates icons for a specific file
|
||||
*/
|
||||
public updateFileExplorerIcons(file: TFile): void {
|
||||
if (
|
||||
!file ||
|
||||
!this.settings.showStatusIconsInExplorer ||
|
||||
file.extension !== "md"
|
||||
)
|
||||
return;
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile?.path === file.path) {
|
||||
this.ui.updateSingleFileIconDirectly(file, this.statusService);
|
||||
}
|
||||
|
||||
this.queueFileUpdate(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all icons in the explorer
|
||||
*/
|
||||
public updateAllFileExplorerIcons(): void {
|
||||
if (!this.settings.showStatusIconsInExplorer) {
|
||||
this.ui.removeAllFileExplorerIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
this.processFilesInBatches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets selected files from the explorer
|
||||
*/
|
||||
public getSelectedFiles(): TFile[] {
|
||||
return this.ui.getSelectedFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a file for icon update
|
||||
*/
|
||||
private queueFileUpdate(file: TFile): void {
|
||||
if (!this.settings.showStatusIconsInExplorer || file.extension !== "md")
|
||||
return;
|
||||
|
||||
this.iconUpdateQueue.add(file.path);
|
||||
this.debouncedUpdateAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the update queue
|
||||
*/
|
||||
private async processUpdateQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return;
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
try {
|
||||
const fileExplorerView = this.ui.findFileExplorerView();
|
||||
if (!fileExplorerView || !fileExplorerView.fileItems) {
|
||||
this.fileItemsWasUndefined = true;
|
||||
setTimeout(() => this.debouncedUpdateAll(), 200);
|
||||
return;
|
||||
}
|
||||
|
||||
const allPaths = Array.from(this.iconUpdateQueue);
|
||||
const batchSize = 50;
|
||||
|
||||
for (let i = 0; i < allPaths.length; i += batchSize) {
|
||||
await this.processBatch(
|
||||
allPaths.slice(i, i + batchSize),
|
||||
fileExplorerView,
|
||||
);
|
||||
|
||||
if (i + batchSize < allPaths.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Note Status: Error processing file update queue",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
this.isProcessingQueue = false;
|
||||
|
||||
if (this.iconUpdateQueue.size > 0) {
|
||||
this.debouncedUpdateAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of files
|
||||
*/
|
||||
private async processBatch(
|
||||
paths: string[],
|
||||
fileExplorerView: FileExplorerView,
|
||||
): Promise<void> {
|
||||
for (const path of paths) {
|
||||
const file = this.app.vault.getFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
this.ui.updateSingleFileIcon(
|
||||
file,
|
||||
fileExplorerView,
|
||||
this.statusService,
|
||||
);
|
||||
}
|
||||
this.iconUpdateQueue.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process files in batches
|
||||
*/
|
||||
private async processFilesInBatches(): Promise<void> {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const batchSize = 100;
|
||||
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
files
|
||||
.slice(i, i + batchSize)
|
||||
.forEach((file) => this.queueFileUpdate(file));
|
||||
|
||||
if (i + batchSize < files.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when unloading the plugin
|
||||
*/
|
||||
public unload(): void {
|
||||
this.ui.removeAllFileExplorerIcons();
|
||||
this.debouncedUpdateAll.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages UI operations for file explorer icons
|
||||
*/
|
||||
export class ExplorerIntegrationUI {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private onFileItemsUndefined: () => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
onFileItemsUndefined: () => void,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.onFileItemsUndefined = onFileItemsUndefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the settings
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the file explorer view
|
||||
*/
|
||||
public findFileExplorerView(): FileExplorerView | null {
|
||||
const leaf = this.app.workspace.getLeavesOfType("file-explorer")[0];
|
||||
if (leaf?.view) return leaf.view as FileExplorerView;
|
||||
|
||||
for (const leaf of this.app.workspace.getLeavesOfType("")) {
|
||||
if (leaf.view && "fileItems" in leaf.view) {
|
||||
return leaf.view as FileExplorerView;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single file icon
|
||||
*/
|
||||
public updateSingleFileIcon(
|
||||
file: TFile,
|
||||
fileExplorerView: FileExplorerView,
|
||||
statusService: StatusService,
|
||||
): void {
|
||||
if (!this.settings.showStatusIconsInExplorer || file.extension !== "md")
|
||||
return;
|
||||
|
||||
try {
|
||||
// Check if fileItems is initialized
|
||||
if (!fileExplorerView.fileItems) {
|
||||
this.onFileItemsUndefined();
|
||||
return;
|
||||
}
|
||||
|
||||
const fileItem = fileExplorerView.fileItems[file.path];
|
||||
if (!fileItem) return;
|
||||
|
||||
const titleEl = fileItem.titleEl || fileItem.selfEl;
|
||||
if (!titleEl) return;
|
||||
|
||||
const statuses = statusService.getFileStatuses(file);
|
||||
|
||||
this.removeExistingIcons(titleEl);
|
||||
|
||||
if (this.shouldSkipIcon(statuses)) return;
|
||||
|
||||
this.addStatusIcons(titleEl, statuses, statusService);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Note Status: Error updating icon for ${file.path}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the icon for the active file directly
|
||||
*/
|
||||
public updateSingleFileIconDirectly(
|
||||
file: TFile,
|
||||
statusService: StatusService,
|
||||
): void {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (fileExplorer) {
|
||||
this.updateSingleFileIcon(file, fileExplorer, statusService);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all file explorer icons
|
||||
*/
|
||||
public removeAllFileExplorerIcons(): void {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (!fileExplorer?.fileItems) return;
|
||||
|
||||
Object.values(fileExplorer.fileItems).forEach((fileItem) => {
|
||||
const titleEl = fileItem.titleEl || fileItem.selfEl;
|
||||
if (titleEl) this.removeExistingIcons(titleEl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently selected files
|
||||
*/
|
||||
public getSelectedFiles(): TFile[] {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (!fileExplorer?.fileItems) return [];
|
||||
|
||||
return Object.values(fileExplorer.fileItems)
|
||||
.filter(
|
||||
(item) =>
|
||||
item.el?.classList.contains("is-selected") &&
|
||||
item.file instanceof TFile &&
|
||||
item.file.extension === "md",
|
||||
)
|
||||
.map((item) => item.file as TFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the icon should be skipped based on status
|
||||
*/
|
||||
private shouldSkipIcon(statuses: string[]): boolean {
|
||||
return (
|
||||
this.settings.hideUnknownStatusInExplorer &&
|
||||
statuses.length === 1 &&
|
||||
statuses[0] === "unknown"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes existing status icons
|
||||
*/
|
||||
private removeExistingIcons(element: HTMLElement): void {
|
||||
const iconSelectors = ".note-status-icon, .note-status-icon-container";
|
||||
element.querySelectorAll(iconSelectors).forEach((icon) => {
|
||||
if (
|
||||
icon.classList.contains("note-status-icon") ||
|
||||
icon.classList.contains("note-status-icon-container")
|
||||
) {
|
||||
icon.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds status icons to an element
|
||||
*/
|
||||
private addStatusIcons(
|
||||
titleEl: HTMLElement,
|
||||
statuses: string[],
|
||||
statusService: StatusService,
|
||||
): void {
|
||||
const iconContainer = document.createElement("span");
|
||||
iconContainer.className = "note-status-icon-container";
|
||||
|
||||
if (
|
||||
this.settings.useMultipleStatuses &&
|
||||
statuses.length > 0 &&
|
||||
statuses[0] !== "unknown"
|
||||
) {
|
||||
statuses.forEach((status) =>
|
||||
this.addSingleStatusIcon(iconContainer, status, statusService),
|
||||
);
|
||||
} else {
|
||||
const primaryStatus = statuses[0] || "unknown";
|
||||
if (
|
||||
primaryStatus !== "unknown" ||
|
||||
!this.settings.hideUnknownStatusInExplorer
|
||||
) {
|
||||
this.addSingleStatusIcon(
|
||||
iconContainer,
|
||||
primaryStatus,
|
||||
statusService,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (iconContainer.childElementCount > 0) {
|
||||
titleEl.appendChild(iconContainer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single status icon
|
||||
*/
|
||||
private addSingleStatusIcon(
|
||||
container: HTMLElement,
|
||||
status: string,
|
||||
statusService: StatusService,
|
||||
): void {
|
||||
const iconEl = document.createElement("span");
|
||||
iconEl.className = `note-status-icon nav-file-tag status-${status}`;
|
||||
iconEl.textContent = statusService.getStatusIcon(status);
|
||||
|
||||
const statusObj = statusService
|
||||
.getAllStatuses()
|
||||
.find((s) => s.name === status);
|
||||
const tooltipValue = statusObj?.description
|
||||
? `${status} - ${statusObj.description}`
|
||||
: status;
|
||||
setTooltip(iconEl, tooltipValue);
|
||||
|
||||
container.appendChild(iconEl);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { ExplorerIntegration } from "./explorer-integration";
|
||||
258
integrations/file-explorer/file-explorer-integration.tsx
Normal file
258
integrations/file-explorer/file-explorer-integration.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { FileExplorerIcon } from "@/components/FileExplorer/FileExplorerIcon";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import {
|
||||
LazyElementObserver,
|
||||
IElementProcessor,
|
||||
} from "@/core/lazyElementObserver";
|
||||
import { NoteStatusService } from "@/core/noteStatusService";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { Plugin, TFile, View } from "obsidian";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { StatusesInfoPopup } from "../popups/statusesInfoPopupIntegration";
|
||||
|
||||
export class FileExplorerIntegration implements IElementProcessor {
|
||||
private plugin: Plugin;
|
||||
private observerService: LazyElementObserver;
|
||||
|
||||
private readonly ICON_CLASS = "custom-icon";
|
||||
private readonly EVENT_SUBSCRIPTION_ID =
|
||||
"file-explorer-integration-subscription1";
|
||||
private readonly FILE_EXPLORER_TYPE = "file-explorer";
|
||||
private readonly CONTAINER_SELECTOR = ".nav-files-container";
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
this.plugin = plugin;
|
||||
this.observerService = new LazyElementObserver(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes file explorer integration
|
||||
*/
|
||||
async integrate(): Promise<void> {
|
||||
this.setupWorkspaceIntegration();
|
||||
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
({ file }) => {
|
||||
const element = this.findFileElement(file.path);
|
||||
if (!element) return;
|
||||
|
||||
this.refreshElementIcon(element, file.path);
|
||||
},
|
||||
this.EVENT_SUBSCRIPTION_ID,
|
||||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"plugin-settings-changed",
|
||||
({ key }) => {
|
||||
if (
|
||||
key === "showStatusIconsInExplorer" ||
|
||||
key === "hideUnknownStatusInExplorer" ||
|
||||
key === "enabledTemplates" ||
|
||||
key === "useCustomStatusesOnly" ||
|
||||
key === "customStatuses" ||
|
||||
key === "useMultipleStatuses" ||
|
||||
key === "tagPrefix" ||
|
||||
key === "strictStatuses" ||
|
||||
key === "fileExplorerIconPosition"
|
||||
) {
|
||||
this.destroy();
|
||||
this.integrate().catch((r) => console.error(r));
|
||||
}
|
||||
},
|
||||
"fileExplorerIntegrationSubscription2",
|
||||
);
|
||||
}
|
||||
|
||||
private getFileNoteStatusService(
|
||||
dataPath: string,
|
||||
): NoteStatusService | null {
|
||||
if (!dataPath.endsWith(".md")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = this.plugin.app.vault.getAbstractFileByPath(
|
||||
dataPath,
|
||||
) as TFile;
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noteStatusService = new NoteStatusService(file);
|
||||
noteStatusService.populateStatuses();
|
||||
return noteStatusService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes visible file explorer elements (IElementProcessor implementation)
|
||||
*/
|
||||
processElement(element: HTMLElement, dataPath?: string | null): void {
|
||||
if (!dataPath) {
|
||||
dataPath = element.getAttribute("data-path");
|
||||
}
|
||||
if (dataPath) {
|
||||
const textEl = element.querySelector(
|
||||
".nav-file-title-content, .tree-item-inner, .nav-folder-title-content",
|
||||
);
|
||||
if (!textEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteStatusService = this.getFileNoteStatusService(dataPath);
|
||||
|
||||
// Only render icons for markdown files
|
||||
if (noteStatusService) {
|
||||
this.render(textEl, noteStatusService.statuses);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(element: Element, statuses: GroupedStatuses): void {
|
||||
// Remove existing icon
|
||||
const existingIcon = element.querySelector(`.${this.ICON_CLASS}`);
|
||||
if (existingIcon) {
|
||||
existingIcon.remove();
|
||||
}
|
||||
|
||||
if (!settingsService.settings.showStatusIconsInExplorer) {
|
||||
return;
|
||||
}
|
||||
|
||||
let positionClassName = "";
|
||||
if (
|
||||
settingsService.settings.fileExplorerIconPosition ===
|
||||
"absolute-right"
|
||||
) {
|
||||
positionClassName = "custom-icon__absolute-right";
|
||||
}
|
||||
const icon = createSpan({ cls: [this.ICON_CLASS, positionClassName] });
|
||||
const root = createRoot(icon);
|
||||
root.render(
|
||||
<FileExplorerIcon
|
||||
statuses={statuses}
|
||||
onMouseEnter={(s) => this.openModalInfo(s)}
|
||||
onMouseLeave={this.closeModalInfo}
|
||||
hideUnknownStatus={
|
||||
settingsService.settings.hideUnknownStatusInExplorer
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
if (
|
||||
settingsService.settings.fileExplorerIconPosition ===
|
||||
"file-name-right"
|
||||
) {
|
||||
element.append(icon);
|
||||
} else {
|
||||
element.prepend(icon);
|
||||
}
|
||||
}
|
||||
|
||||
private openModalInfo(statuses: GroupedStatuses) {
|
||||
if (!this.plugin) {
|
||||
return;
|
||||
}
|
||||
StatusesInfoPopup.open(statuses);
|
||||
}
|
||||
private closeModalInfo() {
|
||||
StatusesInfoPopup.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup integration and unsubscribe from events
|
||||
*/
|
||||
destroy(): void {
|
||||
this.observerService.cleanup();
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
this.EVENT_SUBSCRIPTION_ID,
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
"fileExplorerIntegrationSubscription2",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up workspace-level integration
|
||||
*/
|
||||
private setupWorkspaceIntegration(): void {
|
||||
this.plugin.app.workspace.onLayoutReady(() => {
|
||||
this.patchFileExplorer();
|
||||
this.registerActiveLeafChangeHandler();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers handler for active leaf changes
|
||||
*/
|
||||
private registerActiveLeafChangeHandler(): void {
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on("active-leaf-change", () => {
|
||||
this.patchFileExplorer();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds file element in the file explorer
|
||||
*/
|
||||
private findFileElement(filePath: string): HTMLElement | null {
|
||||
const fileExplorerView = this.getFileExplorerView();
|
||||
if (!fileExplorerView) return null;
|
||||
|
||||
return fileExplorerView.containerEl.querySelector(
|
||||
`[data-path="${filePath}"]`,
|
||||
) as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes icon for a specific element
|
||||
*/
|
||||
private refreshElementIcon(element: HTMLElement, dataPath: string): void {
|
||||
//
|
||||
// Remove existing icon
|
||||
// this.iconRenderer.removeIcon(element);
|
||||
|
||||
// Mark for reprocessing
|
||||
this.observerService.markElementForReprocessing(element);
|
||||
|
||||
this.processElement(element, dataPath);
|
||||
//
|
||||
// // Re-add icon with updated status
|
||||
// this.iconRenderer.renderIcon(element, dataPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the file explorer with observers
|
||||
*/
|
||||
private patchFileExplorer(): void {
|
||||
const container = this.getFileExplorerContainer();
|
||||
if (!container) return;
|
||||
|
||||
this.observerService.setupObservers(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file explorer view
|
||||
*/
|
||||
private getFileExplorerView(): View | null {
|
||||
const leaves = this.plugin.app.workspace.getLeavesOfType(
|
||||
this.FILE_EXPLORER_TYPE,
|
||||
);
|
||||
return leaves[0]?.view || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file explorer container element
|
||||
*/
|
||||
private getFileExplorerContainer(): Element | null {
|
||||
const fileExplorerView = this.getFileExplorerView();
|
||||
if (!fileExplorerView) return null;
|
||||
|
||||
return fileExplorerView.containerEl.querySelector(
|
||||
this.CONTAINER_SELECTOR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { MetadataIntegration } from "./metadata-integration";
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { ExplorerIntegration } from "../explorer/explorer-integration";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
export class MetadataIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private explorerIntegration: ExplorerIntegration;
|
||||
private metadataChangedRef: (file: TFile) => void;
|
||||
private metadataResolvedRef: () => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
explorerIntegration: ExplorerIntegration,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.explorerIntegration = explorerIntegration;
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public registerMetadataEvents(): void {
|
||||
this.metadataChangedRef = (file) => {
|
||||
if (file instanceof TFile && file.extension === "md") {
|
||||
this.handleMetadataChanged(file);
|
||||
}
|
||||
};
|
||||
|
||||
this.metadataResolvedRef = () => {
|
||||
setTimeout(() => {
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
this.app.metadataCache.on("changed", this.metadataChangedRef);
|
||||
this.app.metadataCache.on("resolved", this.metadataResolvedRef);
|
||||
}
|
||||
|
||||
private handleMetadataChanged(file: TFile): void {
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
this.explorerIntegration.updateFileExplorerIcons(file);
|
||||
}
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile?.path === file.path) {
|
||||
const statuses = this.statusService.getFileStatuses(file);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("note-status:status-changed", {
|
||||
detail: { statuses, file: file.path },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
if (this.metadataChangedRef) {
|
||||
this.app.metadataCache.off("changed", this.metadataChangedRef);
|
||||
}
|
||||
if (this.metadataResolvedRef) {
|
||||
this.app.metadataCache.off("resolved", this.metadataResolvedRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
139
integrations/modals/statusModalIntegration.tsx
Normal file
139
integrations/modals/statusModalIntegration.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { App, Modal, Notice } from "obsidian";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import {
|
||||
ChangeStatusModal,
|
||||
Props,
|
||||
} from "@/components/ChangeStatusModal/ChangeStatusModal";
|
||||
import SelectorService from "@/core/selectorService";
|
||||
import {
|
||||
MultipleNoteStatusService,
|
||||
NoteStatusService,
|
||||
BaseNoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
import eventBus from "@/core/eventBus";
|
||||
|
||||
export class StatusModalIntegration extends Modal {
|
||||
private root: Root | null = null;
|
||||
private static instance: StatusModalIntegration | null = null;
|
||||
private selectorService: SelectorService;
|
||||
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
static open(
|
||||
app: App,
|
||||
noteStatusService: NoteStatusService | MultipleNoteStatusService,
|
||||
) {
|
||||
if (StatusModalIntegration.instance) {
|
||||
throw new Error("Status Modal is already open");
|
||||
}
|
||||
StatusModalIntegration.instance = new StatusModalIntegration(app);
|
||||
StatusModalIntegration.instance.selectorService = new SelectorService(
|
||||
noteStatusService,
|
||||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
() => {
|
||||
// TODO: There are multiple calls to populateStatuses, in this case the noteStatusService is passed by reference, so redundant computations
|
||||
// FIXME: Line 27
|
||||
StatusModalIntegration.instance?.selectorService.noteStatusService.populateStatuses();
|
||||
StatusModalIntegration.instance?.render();
|
||||
},
|
||||
"statusModalIntegrationSubscription1",
|
||||
);
|
||||
|
||||
StatusModalIntegration.instance.open();
|
||||
}
|
||||
|
||||
private onRemoveStatus: Props["onRemoveStatus"] = async (
|
||||
frontmatterTagName,
|
||||
status,
|
||||
) => {
|
||||
const removed =
|
||||
await this.selectorService.noteStatusService.removeStatus(
|
||||
frontmatterTagName,
|
||||
status,
|
||||
);
|
||||
if (removed) {
|
||||
new Notice(
|
||||
`Status ${status.name} removed successfully from the note`,
|
||||
);
|
||||
} else {
|
||||
new Notice(
|
||||
`Something went wrong removing the status ${status.name} from the note`,
|
||||
);
|
||||
}
|
||||
};
|
||||
private onSelectStatus: Props["onSelectStatus"] = async (
|
||||
frontmatterTagName,
|
||||
status,
|
||||
) => {
|
||||
const scopedIdentifier = status.templateId
|
||||
? BaseNoteStatusService.formatStatusIdentifier({
|
||||
templateId: status.templateId,
|
||||
name: status.name,
|
||||
})
|
||||
: status.name;
|
||||
|
||||
const added = await this.selectorService.noteStatusService.addStatus(
|
||||
frontmatterTagName,
|
||||
scopedIdentifier,
|
||||
);
|
||||
if (added) {
|
||||
new Notice(`Status ${status.name} added successfully to the note`);
|
||||
} else {
|
||||
new Notice(
|
||||
`Something went wrong adding the status ${status.name} to the note`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onOpen() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
const { contentEl } = this;
|
||||
|
||||
if (!this.root) {
|
||||
this.root = createRoot(contentEl);
|
||||
}
|
||||
|
||||
let filesQuantity = 1;
|
||||
if (
|
||||
this.selectorService.noteStatusService instanceof
|
||||
MultipleNoteStatusService
|
||||
) {
|
||||
filesQuantity =
|
||||
this.selectorService.noteStatusService.selectedFilesQTY();
|
||||
}
|
||||
this.root.render(
|
||||
<ChangeStatusModal
|
||||
availableStatuses={NoteStatusService.getAllAvailableStatuses()}
|
||||
currentStatuses={
|
||||
this.selectorService.noteStatusService.statuses ?? {}
|
||||
}
|
||||
filesQuantity={filesQuantity}
|
||||
onRemoveStatus={this.onRemoveStatus}
|
||||
onSelectStatus={this.onSelectStatus}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
StatusModalIntegration.instance = null;
|
||||
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
"statusModalIntegrationSubscription1",
|
||||
);
|
||||
}
|
||||
}
|
||||
47
integrations/popups/statusesInfoPopupIntegration.tsx
Normal file
47
integrations/popups/statusesInfoPopupIntegration.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { createRoot, Root } from "react-dom/client";
|
||||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { StatusFileInfoPopup } from "@/components/StatusFileInfoPopup/StatusFileInfoPopup";
|
||||
|
||||
export class StatusesInfoPopup {
|
||||
private root: Root | null = null;
|
||||
private static instance: StatusesInfoPopup | null = null;
|
||||
private element: HTMLElement | null = null;
|
||||
private statuses: GroupedStatuses;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static open(statuses: GroupedStatuses) {
|
||||
StatusesInfoPopup.close();
|
||||
|
||||
StatusesInfoPopup.instance = new StatusesInfoPopup();
|
||||
StatusesInfoPopup.instance.statuses = statuses;
|
||||
StatusesInfoPopup.instance.show();
|
||||
}
|
||||
|
||||
static close() {
|
||||
if (StatusesInfoPopup.instance) {
|
||||
StatusesInfoPopup.instance.destroy();
|
||||
StatusesInfoPopup.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
private show() {
|
||||
this.element = createDiv({ cls: "" });
|
||||
|
||||
document.body.appendChild(this.element);
|
||||
|
||||
this.root = createRoot(this.element);
|
||||
this.root.render(<StatusFileInfoPopup statuses={this.statuses} />);
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
52
integrations/settings/pluginSettings.tsx
Normal file
52
integrations/settings/pluginSettings.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import SettingsUI from "@/components/SettingsUI.tsx";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { Plugin, PluginSettingTab } from "obsidian";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
|
||||
export class PluginSettingIntegration extends PluginSettingTab {
|
||||
private static instance: PluginSettingIntegration | null = null;
|
||||
private root: Root | null = null;
|
||||
private plugin: Plugin;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
if (PluginSettingIntegration.instance) {
|
||||
throw new Error("The status bar instance is already created");
|
||||
}
|
||||
super(plugin.app, plugin);
|
||||
this.plugin = plugin;
|
||||
PluginSettingIntegration.instance = this;
|
||||
}
|
||||
|
||||
async integrate() {
|
||||
// INFO: This will integrate the "display" function component
|
||||
this.plugin.addSettingTab(this);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
containerEl.empty();
|
||||
this.root = createRoot(containerEl);
|
||||
|
||||
this.root.render(
|
||||
<SettingsUI
|
||||
settings={settingsService.settings}
|
||||
onChange={(key, value) => {
|
||||
settingsService.setValue(key, value);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
PluginSettingIntegration.instance = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import { App } from "obsidian";
|
||||
import { Status } from "../../models/types";
|
||||
import NoteStatus from "main";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { NoteStatusSettingsUI } from "./settings-ui";
|
||||
import { SettingsUICallbacks } from "./types";
|
||||
|
||||
/**
|
||||
* Controller for settings UI, handles business logic and plugin state updates
|
||||
*/
|
||||
export class NoteStatusSettingsController implements SettingsUICallbacks {
|
||||
private app: App;
|
||||
private plugin: NoteStatus;
|
||||
private statusService: StatusService;
|
||||
private ui: NoteStatusSettingsUI;
|
||||
|
||||
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.statusService = statusService;
|
||||
this.ui = new NoteStatusSettingsUI(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the settings interface
|
||||
*/
|
||||
display(containerEl: HTMLElement): void {
|
||||
this.ui.render(containerEl, this.plugin.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles template enable/disable toggle
|
||||
*/
|
||||
onTemplateToggle: SettingsUICallbacks["onTemplateToggle"] = async (
|
||||
templateId,
|
||||
enabled,
|
||||
) => {
|
||||
if (enabled) {
|
||||
if (!this.plugin.settings.enabledTemplates.includes(templateId)) {
|
||||
this.plugin.settings.enabledTemplates.push(templateId);
|
||||
}
|
||||
} else {
|
||||
this.plugin.settings.enabledTemplates =
|
||||
this.plugin.settings.enabledTemplates.filter(
|
||||
(id: string) => id !== templateId,
|
||||
);
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles general setting changes
|
||||
*/
|
||||
onSettingChange: SettingsUICallbacks["onSettingChange"] = async (
|
||||
key,
|
||||
value,
|
||||
) => {
|
||||
this.plugin.settings[key] = value;
|
||||
await this.plugin.saveSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles custom status field changes
|
||||
*/
|
||||
onCustomStatusChange: SettingsUICallbacks["onCustomStatusChange"] = async (
|
||||
index,
|
||||
field,
|
||||
value,
|
||||
) => {
|
||||
const status = this.plugin.settings.customStatuses[index];
|
||||
if (!status) return;
|
||||
|
||||
if (field === "name") {
|
||||
const oldName = status.name;
|
||||
status.name = value;
|
||||
|
||||
if (oldName !== status.name) {
|
||||
this.plugin.settings.statusColors[status.name] =
|
||||
this.plugin.settings.statusColors[oldName];
|
||||
delete this.plugin.settings.statusColors[oldName];
|
||||
}
|
||||
} else if (field === "color") {
|
||||
this.plugin.settings.statusColors[status.name] = value;
|
||||
} else {
|
||||
status[field] = value;
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles custom status removal
|
||||
*/
|
||||
onCustomStatusRemove: SettingsUICallbacks["onCustomStatusRemove"] = async (
|
||||
index,
|
||||
) => {
|
||||
const status = this.plugin.settings.customStatuses[index];
|
||||
if (!status) return;
|
||||
|
||||
this.plugin.settings.customStatuses.splice(index, 1);
|
||||
delete this.plugin.settings.statusColors[status.name];
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Re-render the custom statuses section
|
||||
const statusList = document.querySelector(
|
||||
".custom-status-list",
|
||||
) as HTMLElement;
|
||||
if (statusList) {
|
||||
this.ui.renderCustomStatuses(statusList, this.plugin.settings);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles adding new custom status
|
||||
*/
|
||||
|
||||
onCustomStatusAdd: SettingsUICallbacks["onCustomStatusAdd"] = async () => {
|
||||
const newStatus: Status = {
|
||||
name: `status${this.plugin.settings.customStatuses.length + 1}`,
|
||||
icon: "⭐",
|
||||
};
|
||||
|
||||
this.plugin.settings.customStatuses.push(newStatus);
|
||||
this.plugin.settings.statusColors[newStatus.name] = "#ffffff";
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Re-render the custom statuses section
|
||||
const statusList = document.querySelector(
|
||||
".custom-status-list",
|
||||
) as HTMLElement;
|
||||
if (statusList) {
|
||||
this.ui.renderCustomStatuses(statusList, this.plugin.settings);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { App, PluginSettingTab } from "obsidian";
|
||||
import NoteStatus from "main";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { NoteStatusSettingsController } from "./settings-controller";
|
||||
|
||||
/**
|
||||
* Settings tab for the Note Status plugin - delegates to controller
|
||||
*/
|
||||
export class NoteStatusSettingTab extends PluginSettingTab {
|
||||
private controller: NoteStatusSettingsController;
|
||||
|
||||
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
|
||||
super(app, plugin);
|
||||
this.controller = new NoteStatusSettingsController(
|
||||
app,
|
||||
plugin,
|
||||
statusService,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the settings interface
|
||||
*/
|
||||
display(): void {
|
||||
this.controller.display(this.containerEl);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,479 +0,0 @@
|
|||
import { Setting } from "obsidian";
|
||||
import { NoteStatusSettings, Status } from "../../models/types";
|
||||
import { PREDEFINED_TEMPLATES } from "../../constants/status-templates";
|
||||
import { SettingsUICallbacks } from "./types";
|
||||
|
||||
/**
|
||||
* Pure UI component for rendering settings interface
|
||||
*/
|
||||
export class NoteStatusSettingsUI {
|
||||
private callbacks: SettingsUICallbacks;
|
||||
private quickCommandsContainer: HTMLElement | null = null;
|
||||
|
||||
constructor(callbacks: SettingsUICallbacks) {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the complete settings interface
|
||||
*/
|
||||
render(containerEl: HTMLElement, settings: NoteStatusSettings): void {
|
||||
containerEl.empty();
|
||||
|
||||
this.renderTemplateSettings(containerEl, settings);
|
||||
this.renderUISettings(containerEl, settings);
|
||||
this.renderTagSettings(containerEl, settings);
|
||||
this.renderCustomStatusSettings(containerEl, settings);
|
||||
this.renderQuickCommandsSettings(containerEl, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the status templates section
|
||||
*/
|
||||
private renderTemplateSettings(
|
||||
containerEl: HTMLElement,
|
||||
settings: NoteStatusSettings,
|
||||
): void {
|
||||
new Setting(containerEl).setName("Status templates").setHeading();
|
||||
|
||||
containerEl.createEl("p", {
|
||||
text: "Enable predefined templates to quickly add common status workflows",
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
|
||||
const templatesContainer = containerEl.createDiv({
|
||||
cls: "templates-container",
|
||||
});
|
||||
|
||||
PREDEFINED_TEMPLATES.forEach((template) => {
|
||||
const templateEl = templatesContainer.createDiv({
|
||||
cls: "template-item",
|
||||
});
|
||||
const headerEl = templateEl.createDiv({ cls: "template-header" });
|
||||
|
||||
const isEnabled = settings.enabledTemplates.includes(template.id);
|
||||
const checkbox = headerEl.createEl("input", {
|
||||
type: "checkbox",
|
||||
cls: "template-checkbox",
|
||||
});
|
||||
checkbox.checked = isEnabled;
|
||||
|
||||
checkbox.addEventListener("change", () => {
|
||||
this.callbacks.onTemplateToggle(template.id, checkbox.checked);
|
||||
this.refreshQuickCommandsList(settings);
|
||||
});
|
||||
|
||||
headerEl.createEl("span", {
|
||||
text: template.name,
|
||||
cls: "template-name",
|
||||
});
|
||||
|
||||
templateEl.createEl("div", {
|
||||
text: template.description,
|
||||
cls: "template-description",
|
||||
});
|
||||
|
||||
const statusesEl = templateEl.createDiv({
|
||||
cls: "template-statuses",
|
||||
});
|
||||
template.statuses.forEach((status) => {
|
||||
const statusEl = statusesEl.createEl("div", {
|
||||
cls: "template-status-chip",
|
||||
});
|
||||
const colorDot = statusEl.createEl("span", {
|
||||
cls: "status-color-dot",
|
||||
});
|
||||
colorDot.style.setProperty(
|
||||
"--dot-color",
|
||||
status.color || "#ffffff",
|
||||
);
|
||||
statusEl.createSpan({ text: `${status.icon} ${status.name}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the UI display settings section
|
||||
*/
|
||||
private renderUISettings(
|
||||
containerEl: HTMLElement,
|
||||
settings: NoteStatusSettings,
|
||||
): void {
|
||||
new Setting(containerEl).setName("User interface").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show status bar")
|
||||
.setDesc("Display the status bar")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.showStatusBar)
|
||||
.onChange((value) =>
|
||||
this.callbacks.onSettingChange("showStatusBar", value),
|
||||
),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Auto-hide status bar")
|
||||
.setDesc("Hide the status bar when status is unknown")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.autoHideStatusBar)
|
||||
.onChange((value) =>
|
||||
this.callbacks.onSettingChange(
|
||||
"autoHideStatusBar",
|
||||
value,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show status icons in file explorer")
|
||||
.setDesc("Display status icons in the file explorer")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.showStatusIconsInExplorer)
|
||||
.onChange((value) =>
|
||||
this.callbacks.onSettingChange(
|
||||
"showStatusIconsInExplorer",
|
||||
value,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Hide unknown status in file explorer")
|
||||
.setDesc(
|
||||
"Hide status icons for files with unknown status in the file explorer",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.hideUnknownStatusInExplorer || false)
|
||||
.onChange((value) =>
|
||||
this.callbacks.onSettingChange(
|
||||
"hideUnknownStatusInExplorer",
|
||||
value,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Default to compact view")
|
||||
.setDesc("Start the status pane in compact view by default")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.compactView || false)
|
||||
.onChange((value) =>
|
||||
this.callbacks.onSettingChange("compactView", value),
|
||||
),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Exclude unassigned notes from status pane")
|
||||
.setDesc(
|
||||
"Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults.",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.excludeUnknownStatus || false)
|
||||
.onChange((value) =>
|
||||
this.callbacks.onSettingChange(
|
||||
"excludeUnknownStatus",
|
||||
value,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the status tag configuration section
|
||||
*/
|
||||
private renderTagSettings(
|
||||
containerEl: HTMLElement,
|
||||
settings: NoteStatusSettings,
|
||||
): void {
|
||||
new Setting(containerEl).setName("Status tag").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Enable multiple statuses")
|
||||
.setDesc("Allow notes to have multiple statuses at the same time")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.useMultipleStatuses)
|
||||
.onChange((value) =>
|
||||
this.callbacks.onSettingChange(
|
||||
"useMultipleStatuses",
|
||||
value,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Status tag prefix")
|
||||
.setDesc(
|
||||
"The YAML frontmatter tag name used for status (default: obsidian-note-status)",
|
||||
)
|
||||
.addText((text) =>
|
||||
text.setValue(settings.tagPrefix).onChange((value) => {
|
||||
if (value.trim()) {
|
||||
this.callbacks.onSettingChange(
|
||||
"tagPrefix",
|
||||
value.trim(),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Strict status validation")
|
||||
.setDesc(
|
||||
"Only show statuses that are defined in templates or custom statuses. ⚠️ WARNING: When enabled, any unknown statuses will be automatically removed when modifying file statuses.",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.strictStatuses || false)
|
||||
.onChange((value) =>
|
||||
this.callbacks.onSettingChange("strictStatuses", value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the quick commands configuration section
|
||||
*/
|
||||
private renderQuickCommandsSettings(
|
||||
containerEl: HTMLElement,
|
||||
settings: NoteStatusSettings,
|
||||
): void {
|
||||
new Setting(containerEl)
|
||||
.setName("Quick status commands")
|
||||
.setDesc(
|
||||
"Select which statuses should have dedicated commands in the command palette. These can be assigned hotkeys for quick access.",
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
this.quickCommandsContainer = containerEl.createDiv({
|
||||
cls: "quick-commands-container",
|
||||
});
|
||||
this.populateQuickCommandsList(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the quick commands list
|
||||
*/
|
||||
private populateQuickCommandsList(settings: NoteStatusSettings): void {
|
||||
if (!this.quickCommandsContainer) return;
|
||||
|
||||
this.quickCommandsContainer.empty();
|
||||
// Get all available statuses from service
|
||||
const allStatuses = this.getAllAvailableStatuses(settings);
|
||||
const currentQuickCommands = settings.quickStatusCommands || [];
|
||||
|
||||
allStatuses.forEach((status) => {
|
||||
if (!this.quickCommandsContainer) return;
|
||||
const setting = new Setting(this.quickCommandsContainer)
|
||||
.setName(`${status.icon} ${status.name}`)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(currentQuickCommands.includes(status.name))
|
||||
.onChange(async (value) => {
|
||||
const updatedCommands: string[] = value
|
||||
? [
|
||||
...currentQuickCommands.filter(
|
||||
(cmd: string) =>
|
||||
cmd !== status.name,
|
||||
),
|
||||
status.name,
|
||||
]
|
||||
: currentQuickCommands.filter(
|
||||
(cmd: string) => cmd !== status.name,
|
||||
);
|
||||
|
||||
await this.callbacks.onSettingChange(
|
||||
"quickStatusCommands",
|
||||
updatedCommands,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (status.description) {
|
||||
setting.setDesc(status.description);
|
||||
}
|
||||
});
|
||||
|
||||
if (allStatuses.length === 0) {
|
||||
this.quickCommandsContainer.createDiv({
|
||||
text: "No statuses available. Enable templates or add custom statuses first.",
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Refresh the quick commands list when statuses change
|
||||
*/
|
||||
private refreshQuickCommandsList(settings: NoteStatusSettings): void {
|
||||
// Add small delay to ensure settings are updated
|
||||
setTimeout(() => {
|
||||
this.populateQuickCommandsList(settings);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available statuses from templates and custom statuses
|
||||
*/
|
||||
private getAllAvailableStatuses(
|
||||
settings: NoteStatusSettings,
|
||||
): Array<{ name: string; icon: string; description?: string }> {
|
||||
const statuses: Array<{
|
||||
name: string;
|
||||
icon: string;
|
||||
description?: string;
|
||||
}> = [];
|
||||
|
||||
// Add custom statuses
|
||||
statuses.push(...settings.customStatuses);
|
||||
|
||||
// Add template statuses if not using custom only
|
||||
if (!settings.useCustomStatusesOnly) {
|
||||
for (const templateId of settings.enabledTemplates) {
|
||||
const template = PREDEFINED_TEMPLATES.find(
|
||||
(t) => t.id === templateId,
|
||||
);
|
||||
if (template) {
|
||||
for (const status of template.statuses) {
|
||||
if (!statuses.find((s) => s.name === status.name)) {
|
||||
statuses.push(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statuses.filter((s) => s.name !== "unknown");
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the custom status management section
|
||||
*/
|
||||
private renderCustomStatusSettings(
|
||||
containerEl: HTMLElement,
|
||||
settings: NoteStatusSettings,
|
||||
): void {
|
||||
new Setting(containerEl).setName("Custom statuses").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Use only custom statuses")
|
||||
.setDesc(
|
||||
"Ignore template statuses and use only the custom statuses defined below",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.useCustomStatusesOnly || false)
|
||||
.onChange(async (value) => {
|
||||
await this.callbacks.onSettingChange(
|
||||
"useCustomStatusesOnly",
|
||||
value,
|
||||
);
|
||||
// Update quick commands list when custom-only mode changes
|
||||
this.refreshQuickCommandsList(settings);
|
||||
}),
|
||||
);
|
||||
|
||||
const statusList = containerEl.createDiv({ cls: "custom-status-list" });
|
||||
this.renderCustomStatuses(statusList, settings);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Add new status")
|
||||
.setDesc("Add a custom status with a name, icon, and color")
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Add Status")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
await this.callbacks.onCustomStatusAdd();
|
||||
this.refreshQuickCommandsList(settings);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list of custom statuses with edit controls
|
||||
*/
|
||||
renderCustomStatuses(
|
||||
statusList: HTMLElement,
|
||||
settings: NoteStatusSettings,
|
||||
): void {
|
||||
statusList.empty();
|
||||
|
||||
settings.customStatuses.forEach((status: Status, index: number) => {
|
||||
const setting = new Setting(statusList)
|
||||
.setName(status.name)
|
||||
.setClass("status-item");
|
||||
|
||||
setting.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Name")
|
||||
.setValue(status.name)
|
||||
.onChange(async (value) => {
|
||||
await this.callbacks.onCustomStatusChange(
|
||||
index,
|
||||
"name",
|
||||
value || "unnamed",
|
||||
);
|
||||
// Update quick commands list when status name changes
|
||||
this.refreshQuickCommandsList(settings);
|
||||
}),
|
||||
);
|
||||
|
||||
setting.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Icon")
|
||||
.setValue(status.icon)
|
||||
.onChange(async (value) => {
|
||||
await this.callbacks.onCustomStatusChange(
|
||||
index,
|
||||
"icon",
|
||||
value || "❓",
|
||||
);
|
||||
this.refreshQuickCommandsList(settings);
|
||||
}),
|
||||
);
|
||||
|
||||
setting.addColorPicker((colorPicker) =>
|
||||
colorPicker
|
||||
.setValue(settings.statusColors[status.name] || "#ffffff")
|
||||
.onChange((value) =>
|
||||
this.callbacks.onCustomStatusChange(
|
||||
index,
|
||||
"color",
|
||||
value,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
setting.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Description")
|
||||
.setValue(status.description || "")
|
||||
.onChange(async (value) => {
|
||||
await this.callbacks.onCustomStatusChange(
|
||||
index,
|
||||
"description",
|
||||
value,
|
||||
);
|
||||
this.refreshQuickCommandsList(settings);
|
||||
}),
|
||||
);
|
||||
|
||||
setting.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Remove")
|
||||
.setClass("status-remove-button")
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
await this.callbacks.onCustomStatusRemove(index);
|
||||
this.refreshQuickCommandsList(settings);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { NoteStatusSettings, Status } from "../../models/types";
|
||||
|
||||
/**
|
||||
* Callbacks interface for settings UI interactions
|
||||
*/
|
||||
export interface SettingsUICallbacks {
|
||||
/** Handle template enable/disable toggle */
|
||||
onTemplateToggle: (templateId: string, enabled: boolean) => Promise<void>;
|
||||
/** Handle general setting changes */
|
||||
onSettingChange: (
|
||||
key: keyof NoteStatusSettings,
|
||||
value: boolean | string | string[],
|
||||
) => Promise<void>;
|
||||
/** Handle custom status field changes */
|
||||
onCustomStatusChange: (
|
||||
index: number,
|
||||
field: keyof Status,
|
||||
value: string,
|
||||
) => Promise<void>;
|
||||
/** Handle custom status removal */
|
||||
onCustomStatusRemove: (index: number) => Promise<void>;
|
||||
/** Handle adding new custom status */
|
||||
onCustomStatusAdd: () => Promise<void>;
|
||||
}
|
||||
161
integrations/status-bar/status-bar.tsx
Normal file
161
integrations/status-bar/status-bar.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { createRoot, Root } from "react-dom/client";
|
||||
import eventBus from "core/eventBus";
|
||||
import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { StatusBarEnableButton } from "@/components/StatusBar/StatusBarEnableButton";
|
||||
import { StatusBar } from "@/components/StatusBar/StatusBar";
|
||||
import { NoteStatusService } from "@/core/noteStatusService";
|
||||
|
||||
export class StatusBarIntegration {
|
||||
private static instance: StatusBarIntegration | null = null;
|
||||
private root: Root | null = null;
|
||||
private plugin: Plugin;
|
||||
private statusBarContainer: HTMLElement;
|
||||
private noteStatusService: NoteStatusService | null = null;
|
||||
private currentLeaf: WorkspaceLeaf | null = null;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
if (StatusBarIntegration.instance) {
|
||||
throw new Error("The status bar instance is already created");
|
||||
}
|
||||
this.plugin = plugin;
|
||||
StatusBarIntegration.instance = this;
|
||||
}
|
||||
|
||||
async integrate() {
|
||||
this.statusBarContainer = this.plugin.addStatusBarItem();
|
||||
this.statusBarContainer.classList.add("mod-clickable");
|
||||
|
||||
eventBus.subscribe(
|
||||
"active-file-change",
|
||||
({ leaf }) => {
|
||||
if (this.isValidMarkdownLeaf(leaf)) {
|
||||
this.currentLeaf = leaf;
|
||||
this.handleActiveFileChange().catch(console.error);
|
||||
}
|
||||
},
|
||||
"statusBarIntegrationSubscription1",
|
||||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"plugin-settings-changed",
|
||||
({ key }) => {
|
||||
if (key === "showStatusBar") {
|
||||
this.render(); // INFO: Force a render to set disabled or enabled
|
||||
}
|
||||
if (key === "enabledTemplates") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
if (key === "autoHideStatusBar") {
|
||||
this.render();
|
||||
}
|
||||
if (key === "useMultipleStatuses") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
if (key === "tagPrefix") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
if (key === "useCustomStatusesOnly") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
if (key === "customStatuses") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
},
|
||||
"statusBarIntegrationSubscription2",
|
||||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
() => {
|
||||
this.handleActiveFileChange().catch(console.error);
|
||||
},
|
||||
"statusBarIntegrationSubscription3",
|
||||
);
|
||||
}
|
||||
|
||||
private async handleActiveFileChange() {
|
||||
this.extractStatusesFromLeaf(this.currentLeaf);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private extractStatusesFromLeaf(leaf: WorkspaceLeaf | null) {
|
||||
if (!this.isValidMarkdownLeaf(leaf)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const markdownView = leaf!.view as MarkdownView;
|
||||
if (!markdownView.file) {
|
||||
return {};
|
||||
}
|
||||
|
||||
this.noteStatusService = new NoteStatusService(markdownView.file);
|
||||
this.noteStatusService.populateStatuses();
|
||||
}
|
||||
|
||||
private isValidMarkdownLeaf(leaf: WorkspaceLeaf | null): boolean {
|
||||
return leaf !== null && leaf.view.getViewType() === "markdown";
|
||||
}
|
||||
|
||||
private openStatusModal() {
|
||||
if (!this.noteStatusService) {
|
||||
throw new Error(
|
||||
"open status modal failed bcse there is no noteStatusService available",
|
||||
);
|
||||
}
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: this.noteStatusService,
|
||||
});
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!this.root) {
|
||||
this.root = createRoot(this.statusBarContainer);
|
||||
this.statusBarContainer.style.padding = "unset";
|
||||
}
|
||||
if (!settingsService.settings.showStatusBar) {
|
||||
this.root.render(
|
||||
<StatusBarEnableButton
|
||||
onEnableClick={() =>
|
||||
settingsService.setValue("showStatusBar", true)
|
||||
}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
this.root.render(
|
||||
<StatusBar
|
||||
statuses={this.noteStatusService?.statuses || {}}
|
||||
hideIfNotStatuses={
|
||||
settingsService.settings.autoHideStatusBar
|
||||
}
|
||||
onStatusClick={() => this.openStatusModal()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
eventBus.unsubscribe(
|
||||
"active-file-change",
|
||||
"statusBarIntegrationSubscription1",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"plugin-settings-changed",
|
||||
"statusBarIntegrationSubscription2",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"plugin-settings-changed",
|
||||
"statusBarIntegrationSubscription3",
|
||||
);
|
||||
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
this.currentLeaf = null;
|
||||
this.noteStatusService = null;
|
||||
StatusBarIntegration.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default StatusBarIntegration;
|
||||
219
integrations/views/grouped-dashboard-view.tsx
Normal file
219
integrations/views/grouped-dashboard-view.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
|
||||
import { Root, createRoot } from "react-dom/client";
|
||||
import { GroupedStatusView as GroupedStatusViewComponent } from "@/components/GroupedStatusView/GroupedStatusView";
|
||||
import { BaseNoteStatusService } from "@/core/noteStatusService";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
export const VIEW_TYPE_GROUPED_DASHBOARD = "grouped-dashboard-view";
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface StatusItem {
|
||||
name: string;
|
||||
color: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface FilesByStatus {
|
||||
[statusName: string]: FileItem[];
|
||||
}
|
||||
|
||||
interface GroupedByStatus {
|
||||
[frontmatterTag: string]: FilesByStatus;
|
||||
}
|
||||
|
||||
export class GroupedDashboardView extends ItemView {
|
||||
root: Root | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_GROUPED_DASHBOARD;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Grouped Status Dashboard";
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return "list-tree";
|
||||
}
|
||||
|
||||
private convertTFileToFileItem = (file: TFile): FileItem => ({
|
||||
id: file.path,
|
||||
name: file.basename,
|
||||
path: file.path,
|
||||
});
|
||||
|
||||
private convertStatusToStatusItem = (status: NoteStatus): StatusItem => ({
|
||||
name: status.name,
|
||||
color: status.color || "",
|
||||
icon: status.icon,
|
||||
});
|
||||
|
||||
private getAllFiles = (): FileItem[] => {
|
||||
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
return tFiles.map(this.convertTFileToFileItem);
|
||||
};
|
||||
|
||||
private processFiles = (files: FileItem[]): GroupedByStatus => {
|
||||
const result: GroupedByStatus = {};
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
// Create maps for both scoped and legacy status lookup
|
||||
const statusMap = new Map(
|
||||
availableStatuses.map((s) => {
|
||||
const key = s.templateId ? `${s.templateId}:${s.name}` : s.name;
|
||||
return [key, s];
|
||||
}),
|
||||
);
|
||||
const legacyStatusMap = new Map(
|
||||
availableStatuses.map((s) => [s.name, s]),
|
||||
);
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
result[key] = {};
|
||||
availableStatuses.forEach((status) => {
|
||||
const statusKey = status.templateId
|
||||
? `${status.templateId}:${status.name}`
|
||||
: status.name;
|
||||
result[key][statusKey] = [];
|
||||
});
|
||||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
// Find the TFile to get metadata
|
||||
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
|
||||
file.path,
|
||||
) as TFile;
|
||||
if (!tFile) return;
|
||||
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(tFile);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
// Try to find status by exact match first, then by legacy name
|
||||
let resolvedStatus = statusMap.get(statusStr);
|
||||
if (!resolvedStatus) {
|
||||
resolvedStatus = legacyStatusMap.get(statusStr);
|
||||
}
|
||||
|
||||
if (resolvedStatus) {
|
||||
const statusKey = resolvedStatus.templateId
|
||||
? `${resolvedStatus.templateId}:${resolvedStatus.name}`
|
||||
: resolvedStatus.name;
|
||||
if (!result[key][statusKey]) {
|
||||
result[key][statusKey] = [];
|
||||
}
|
||||
result[key][statusKey].push(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
private getAvailableStatuses = (): StatusItem[] => {
|
||||
const statuses = BaseNoteStatusService.getAllAvailableStatuses();
|
||||
return statuses.map(this.convertStatusToStatusItem);
|
||||
};
|
||||
|
||||
private getAvailableStatusesWithTemplateInfo = () => {
|
||||
return BaseNoteStatusService.getAllAvailableStatuses();
|
||||
};
|
||||
|
||||
private handleFileClick = (file: FileItem) => {
|
||||
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
|
||||
file.path,
|
||||
) as TFile;
|
||||
if (tFile) {
|
||||
const leaf = BaseNoteStatusService.app.workspace.getLeaf();
|
||||
leaf.openFile(tFile);
|
||||
}
|
||||
};
|
||||
|
||||
private subscribeToEvents = (onDataChange: () => void) => {
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
onDataChange,
|
||||
"grouped-dashboard-view-subscription",
|
||||
);
|
||||
eventBus.subscribe(
|
||||
"plugin-settings-changed",
|
||||
({ key }) => {
|
||||
if (
|
||||
key === "tagPrefix" ||
|
||||
key === "enabledTemplates" ||
|
||||
key === "useCustomStatusesOnly" ||
|
||||
key === "customStatuses" ||
|
||||
key === "useMultipleStatuses" ||
|
||||
key === "strictStatuses" ||
|
||||
key === "excludeUnknownStatus"
|
||||
) {
|
||||
onDataChange();
|
||||
}
|
||||
},
|
||||
"grouped-dashboard-view-settings-subscription",
|
||||
);
|
||||
const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on(
|
||||
"active-leaf-change",
|
||||
onDataChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
"grouped-dashboard-view-subscription",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"plugin-settings-changed",
|
||||
"grouped-dashboard-view-settings-subscription",
|
||||
);
|
||||
BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile);
|
||||
};
|
||||
};
|
||||
|
||||
async onOpen() {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
container.addClass("grouped-dashboard-view-container");
|
||||
|
||||
this.root = createRoot(container);
|
||||
this.root.render(
|
||||
<GroupedStatusViewComponent
|
||||
getAllFiles={this.getAllFiles}
|
||||
processFiles={this.processFiles}
|
||||
onFileClick={this.handleFileClick}
|
||||
subscribeToEvents={this.subscribeToEvents}
|
||||
getAvailableStatuses={this.getAvailableStatuses}
|
||||
getAvailableStatusesWithTemplateInfo={
|
||||
this.getAvailableStatusesWithTemplateInfo
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
}
|
||||
204
integrations/views/grouped-status-view.tsx
Normal file
204
integrations/views/grouped-status-view.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
|
||||
import { Root, createRoot } from "react-dom/client";
|
||||
import {
|
||||
FileItem,
|
||||
GroupedByStatus,
|
||||
GroupedStatusView as GroupedStatusViewComponent,
|
||||
StatusItem,
|
||||
} from "@/components/GroupedStatusView/GroupedStatusView";
|
||||
import { BaseNoteStatusService } from "@/core/noteStatusService";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
export const VIEW_TYPE_EXAMPLE = "grouped-status-view";
|
||||
|
||||
export class GroupedStatusView extends ItemView {
|
||||
root: Root | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_EXAMPLE;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Grouped Status View";
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return "list-tree";
|
||||
}
|
||||
|
||||
private convertTFileToFileItem = (file: TFile): FileItem => ({
|
||||
id: file.path,
|
||||
name: file.basename,
|
||||
path: file.path,
|
||||
});
|
||||
|
||||
private convertStatusToStatusItem = (status: NoteStatus): StatusItem => ({
|
||||
name: status.name,
|
||||
color: status.color || "white",
|
||||
icon: status.icon,
|
||||
});
|
||||
|
||||
private getAllFiles = (): FileItem[] => {
|
||||
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
return tFiles.map(this.convertTFileToFileItem);
|
||||
};
|
||||
|
||||
private processFiles = (files: FileItem[]): GroupedByStatus => {
|
||||
const result: GroupedByStatus = {};
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
// Create maps for both scoped and legacy status lookup
|
||||
const statusMap = new Map(
|
||||
availableStatuses.map((s) => {
|
||||
const key = s.templateId ? `${s.templateId}:${s.name}` : s.name;
|
||||
return [key, s];
|
||||
}),
|
||||
);
|
||||
const legacyStatusMap = new Map(
|
||||
availableStatuses.map((s) => [s.name, s]),
|
||||
);
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
result[key] = {};
|
||||
availableStatuses.forEach((status) => {
|
||||
const statusKey = status.templateId
|
||||
? `${status.templateId}:${status.name}`
|
||||
: status.name;
|
||||
result[key][statusKey] = [];
|
||||
});
|
||||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
// Find the TFile to get metadata
|
||||
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
|
||||
file.path,
|
||||
) as TFile;
|
||||
if (!tFile) return;
|
||||
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(tFile);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
// Try to find status by exact match first, then by legacy name
|
||||
let resolvedStatus = statusMap.get(statusStr);
|
||||
if (!resolvedStatus) {
|
||||
resolvedStatus = legacyStatusMap.get(statusStr);
|
||||
}
|
||||
|
||||
if (resolvedStatus) {
|
||||
const statusKey = resolvedStatus.templateId
|
||||
? `${resolvedStatus.templateId}:${resolvedStatus.name}`
|
||||
: resolvedStatus.name;
|
||||
if (!result[key][statusKey]) {
|
||||
result[key][statusKey] = [];
|
||||
}
|
||||
result[key][statusKey].push(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
private getAvailableStatuses = (): StatusItem[] => {
|
||||
const statuses = BaseNoteStatusService.getAllAvailableStatuses();
|
||||
return statuses.map(this.convertStatusToStatusItem);
|
||||
};
|
||||
|
||||
private getAvailableStatusesWithTemplateInfo = () => {
|
||||
return BaseNoteStatusService.getAllAvailableStatuses();
|
||||
};
|
||||
|
||||
private handleFileClick = (file: FileItem) => {
|
||||
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
|
||||
file.path,
|
||||
) as TFile;
|
||||
if (tFile) {
|
||||
const leaf = BaseNoteStatusService.app.workspace.getLeaf();
|
||||
leaf.openFile(tFile);
|
||||
}
|
||||
};
|
||||
|
||||
private subscribeToEvents = (onDataChange: () => void) => {
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
onDataChange,
|
||||
"grouped-status-view-subscription",
|
||||
);
|
||||
eventBus.subscribe(
|
||||
"plugin-settings-changed",
|
||||
({ key }) => {
|
||||
if (
|
||||
key === "tagPrefix" ||
|
||||
key === "enabledTemplates" ||
|
||||
key === "useCustomStatusesOnly" ||
|
||||
key === "customStatuses" ||
|
||||
key === "useMultipleStatuses" ||
|
||||
key === "strictStatuses" ||
|
||||
key === "excludeUnknownStatus"
|
||||
) {
|
||||
onDataChange();
|
||||
}
|
||||
},
|
||||
"grouped-status-view-settings-subscription",
|
||||
);
|
||||
const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on(
|
||||
"active-leaf-change",
|
||||
onDataChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
"grouped-status-view-subscription",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"plugin-settings-changed",
|
||||
"grouped-status-view-settings-subscription",
|
||||
);
|
||||
BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile);
|
||||
};
|
||||
};
|
||||
|
||||
async onOpen() {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
container.addClass("grouped-status-view-container");
|
||||
|
||||
this.root = createRoot(container);
|
||||
this.root.render(
|
||||
<GroupedStatusViewComponent
|
||||
getAllFiles={this.getAllFiles}
|
||||
processFiles={this.processFiles}
|
||||
onFileClick={this.handleFileClick}
|
||||
subscribeToEvents={this.subscribeToEvents}
|
||||
getAvailableStatuses={this.getAvailableStatuses}
|
||||
getAvailableStatusesWithTemplateInfo={
|
||||
this.getAvailableStatusesWithTemplateInfo
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
}
|
||||
371
integrations/views/status-dashboard-view.tsx
Normal file
371
integrations/views/status-dashboard-view.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import { ItemView, WorkspaceLeaf, Notice, App, TFile } from "obsidian";
|
||||
import { Root, createRoot } from "react-dom/client";
|
||||
import { StatusDashboard } from "@/components/StatusDashboard/StatusDashboard";
|
||||
import { DashboardAction } from "@/components/StatusDashboard/QuickActionsPanel";
|
||||
import {
|
||||
BaseNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import { VaultStats } from "@/components/StatusDashboard/useVaultStats";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
interface AppWithCommands extends App {
|
||||
commands: {
|
||||
executeCommandById(commandId: string): boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface CurrentNoteInfo {
|
||||
file: TFile | null;
|
||||
statuses: Record<string, NoteStatus[]>;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
export const VIEW_TYPE_STATUS_DASHBOARD = "status-dashboard-view";
|
||||
|
||||
export class StatusDashboardView extends ItemView {
|
||||
root: Root | null = null;
|
||||
private vaultStats: VaultStats = {
|
||||
totalNotes: 0,
|
||||
notesWithStatus: 0,
|
||||
statusDistribution: {},
|
||||
tagDistribution: {},
|
||||
recentChanges: [],
|
||||
};
|
||||
private currentNote: CurrentNoteInfo = {
|
||||
file: null,
|
||||
statuses: {},
|
||||
lastModified: 0,
|
||||
};
|
||||
private isLoading: boolean = true;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_STATUS_DASHBOARD;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Status Dashboard";
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return "bar-chart-2";
|
||||
}
|
||||
|
||||
private calculateVaultStats = (): VaultStats => {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
|
||||
let notesWithStatus = 0;
|
||||
const statusDistribution: Record<string, number> = {};
|
||||
const tagDistribution: Record<string, number> = {};
|
||||
|
||||
// Initialize distribution using full scoped identifiers
|
||||
availableStatuses.forEach((status) => {
|
||||
const scopedIdentifier = status.templateId
|
||||
? `${status.templateId}:${status.name}`
|
||||
: status.name;
|
||||
statusDistribution[scopedIdentifier] = 0;
|
||||
});
|
||||
|
||||
statusMetadataKeys.forEach((tag) => {
|
||||
tagDistribution[tag] = 0;
|
||||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
const cachedMetadata = this.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
let hasAnyStatus = false;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
hasAnyStatus = true;
|
||||
tagDistribution[key]++;
|
||||
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
|
||||
// Determine the scoped identifier to use
|
||||
let scopedIdentifier: string;
|
||||
|
||||
if (statusStr.includes(":")) {
|
||||
// Already scoped
|
||||
scopedIdentifier = statusStr;
|
||||
} else {
|
||||
// Legacy status - find first template that has this status
|
||||
const firstTemplateWithStatus =
|
||||
availableStatuses.find(
|
||||
(s) => s.name === statusStr && s.templateId,
|
||||
);
|
||||
scopedIdentifier = firstTemplateWithStatus
|
||||
? `${firstTemplateWithStatus.templateId}:${statusStr}`
|
||||
: statusStr; // Fallback to unscoped if no template found
|
||||
}
|
||||
|
||||
// Initialize status if not already present
|
||||
if (
|
||||
!statusDistribution.hasOwnProperty(scopedIdentifier)
|
||||
) {
|
||||
statusDistribution[scopedIdentifier] = 0;
|
||||
}
|
||||
statusDistribution[scopedIdentifier]++;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (hasAnyStatus) {
|
||||
notesWithStatus++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalNotes: files.length,
|
||||
notesWithStatus,
|
||||
statusDistribution,
|
||||
tagDistribution,
|
||||
recentChanges: [],
|
||||
};
|
||||
};
|
||||
|
||||
private updateCurrentNote = () => {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
|
||||
if (!activeFile) {
|
||||
this.currentNote = { file: null, statuses: {}, lastModified: 0 };
|
||||
this.renderDashboard();
|
||||
return;
|
||||
}
|
||||
|
||||
const noteStatusService = new NoteStatusService(activeFile);
|
||||
noteStatusService.populateStatuses();
|
||||
|
||||
this.currentNote = {
|
||||
file: activeFile,
|
||||
statuses: noteStatusService.statuses,
|
||||
lastModified: activeFile.stat.mtime,
|
||||
};
|
||||
this.renderDashboard();
|
||||
};
|
||||
|
||||
private loadData = () => {
|
||||
this.isLoading = true;
|
||||
this.renderDashboard();
|
||||
|
||||
try {
|
||||
this.vaultStats = this.calculateVaultStats();
|
||||
this.updateCurrentNote();
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.renderDashboard();
|
||||
}
|
||||
};
|
||||
|
||||
private handleAction = (action: DashboardAction, value?: string) => {
|
||||
const appWithCommands = this.app as AppWithCommands;
|
||||
|
||||
switch (action) {
|
||||
case "refresh":
|
||||
this.loadData();
|
||||
break;
|
||||
case "open-grouped-view":
|
||||
this.openGroupedView();
|
||||
break;
|
||||
case "find-unassigned":
|
||||
this.findUnassignedNotes();
|
||||
break;
|
||||
case "change-status":
|
||||
appWithCommands.commands.executeCommandById(
|
||||
"note-status:change-status",
|
||||
);
|
||||
break;
|
||||
case "cycle-status":
|
||||
appWithCommands.commands.executeCommandById(
|
||||
"note-status:cycle-status",
|
||||
);
|
||||
break;
|
||||
case "clear-status":
|
||||
appWithCommands.commands.executeCommandById(
|
||||
"note-status:clear-status",
|
||||
);
|
||||
break;
|
||||
case "copy-status":
|
||||
appWithCommands.commands.executeCommandById(
|
||||
"note-status:copy-status",
|
||||
);
|
||||
break;
|
||||
case "paste-status":
|
||||
appWithCommands.commands.executeCommandById(
|
||||
"note-status:paste-status",
|
||||
);
|
||||
break;
|
||||
case "search-by-status":
|
||||
appWithCommands.commands.executeCommandById(
|
||||
"note-status:search-by-status",
|
||||
);
|
||||
break;
|
||||
case "toggle-multiple-mode":
|
||||
appWithCommands.commands.executeCommandById(
|
||||
"note-status:toggle-multiple-statuses",
|
||||
);
|
||||
break;
|
||||
case "set-quick-status":
|
||||
if (value) {
|
||||
const commandId = `note-status:set-status-${value}`;
|
||||
const result =
|
||||
appWithCommands.commands.executeCommandById(commandId);
|
||||
if (!result) {
|
||||
new Notice(
|
||||
`Quick status command failed: ${value}. Make sure the status exists and multiple statuses mode is disabled.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "search-by-specific-status":
|
||||
if (value) {
|
||||
this.searchBySpecificStatus(value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private openGroupedView() {
|
||||
const leaf = this.app.workspace.getLeaf();
|
||||
leaf.setViewState({ type: "grouped-status-view", active: true });
|
||||
}
|
||||
|
||||
private findUnassignedNotes() {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const filesWithoutStatus = files.filter((file) => {
|
||||
const cachedMetadata = this.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
return (
|
||||
!frontmatter || !frontmatter[settingsService.settings.tagPrefix]
|
||||
);
|
||||
});
|
||||
|
||||
if (filesWithoutStatus.length === 0) {
|
||||
new Notice("All notes have status assigned!");
|
||||
return;
|
||||
}
|
||||
|
||||
new Notice(
|
||||
`Found ${filesWithoutStatus.length} notes without status. Opening search...`,
|
||||
);
|
||||
|
||||
// Create a search query to find files without the status tag
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
const query = `-[${tagPrefix}:]`;
|
||||
|
||||
// @ts-ignore
|
||||
this.app.internalPlugins.plugins[
|
||||
"global-search"
|
||||
].instance.openGlobalSearch(query);
|
||||
}
|
||||
|
||||
private searchBySpecificStatus(statusName: string) {
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
const query = `[${tagPrefix}:"${statusName}"]`;
|
||||
|
||||
// @ts-ignore
|
||||
this.app.internalPlugins.plugins[
|
||||
"global-search"
|
||||
].instance.openGlobalSearch(query);
|
||||
}
|
||||
|
||||
private renderDashboard() {
|
||||
if (!this.root) return;
|
||||
|
||||
this.root.render(
|
||||
<StatusDashboard
|
||||
onAction={this.handleAction}
|
||||
settings={settingsService.settings}
|
||||
vaultStats={this.vaultStats}
|
||||
currentNote={this.currentNote}
|
||||
isLoading={this.isLoading}
|
||||
availableStatuses={BaseNoteStatusService.getAllAvailableStatuses()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
container.addClass("status-dashboard-view-container");
|
||||
|
||||
this.root = createRoot(container);
|
||||
|
||||
// Set up event listeners
|
||||
const handleFileChange = () => {
|
||||
this.updateCurrentNote();
|
||||
};
|
||||
|
||||
const handleVaultChange = () => {
|
||||
this.loadData();
|
||||
};
|
||||
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
handleVaultChange,
|
||||
"status-dashboard-vault-subscription",
|
||||
);
|
||||
eventBus.subscribe(
|
||||
"active-file-change",
|
||||
handleFileChange,
|
||||
"status-dashboard-file-subscription",
|
||||
);
|
||||
eventBus.subscribe(
|
||||
"plugin-settings-changed",
|
||||
({ key }) => {
|
||||
if (
|
||||
key === "tagPrefix" ||
|
||||
key === "enabledTemplates" ||
|
||||
key === "useCustomStatusesOnly" ||
|
||||
key === "customStatuses" ||
|
||||
key === "useMultipleStatuses" ||
|
||||
key === "strictStatuses" ||
|
||||
key === "excludeUnknownStatus"
|
||||
) {
|
||||
handleVaultChange();
|
||||
handleFileChange();
|
||||
}
|
||||
},
|
||||
"status-dashboard-settings-subscription",
|
||||
);
|
||||
|
||||
this.app.workspace.on("active-leaf-change", handleFileChange);
|
||||
|
||||
// Initial load
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
// Clean up event listeners
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
"status-dashboard-vault-subscription",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"active-file-change",
|
||||
"status-dashboard-file-subscription",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"plugin-settings-changed",
|
||||
"status-dashboard-settings-subscription",
|
||||
);
|
||||
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { WorkspaceIntegration } from "./workspace-integration";
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { App, TFile, WorkspaceLeaf } from "obsidian";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { ToolbarIntegration } from "../editor/toolbar-integration";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
/**
|
||||
* Gestiona la integración con el workspace de Obsidian
|
||||
*/
|
||||
export class WorkspaceIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private toolbarIntegration: ToolbarIntegration;
|
||||
private lastActiveFile: TFile | null = null;
|
||||
private fileOpenEventRef: (file: TFile) => void;
|
||||
private activeLeafChangeEventRef: (file: WorkspaceLeaf) => void;
|
||||
private layoutChangeEventRef: () => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
toolbarIntegration: ToolbarIntegration,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.toolbarIntegration = toolbarIntegration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza la configuración
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra eventos del workspace
|
||||
*/
|
||||
public registerWorkspaceEvents(): void {
|
||||
this.fileOpenEventRef = (file: TFile) => {
|
||||
if (file instanceof TFile) {
|
||||
this.handleFileOpen(file);
|
||||
}
|
||||
};
|
||||
|
||||
this.activeLeafChangeEventRef = (leaf: WorkspaceLeaf) => {
|
||||
this.handleActiveLeafChange(leaf);
|
||||
};
|
||||
|
||||
this.layoutChangeEventRef = () => {
|
||||
this.handleLayoutChange();
|
||||
};
|
||||
|
||||
this.app.workspace.on("file-open", this.fileOpenEventRef);
|
||||
this.app.workspace.on(
|
||||
"active-leaf-change",
|
||||
this.activeLeafChangeEventRef,
|
||||
);
|
||||
this.app.workspace.on("layout-change", this.layoutChangeEventRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja apertura de archivo
|
||||
*/
|
||||
private handleFileOpen(file: TFile): void {
|
||||
// Añade el botón de la barra de herramientas
|
||||
this.toolbarIntegration.addToolbarButtonToActiveLeaf();
|
||||
|
||||
// Actualiza estado
|
||||
this.propagateNoteStatusChange(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja cambio de hoja activa
|
||||
*/
|
||||
private handleActiveLeafChange(leaf: WorkspaceLeaf): void {
|
||||
// Añade el botón de la barra de herramientas
|
||||
this.toolbarIntegration.addToolbarButtonToActiveLeaf();
|
||||
|
||||
//const activeFile = this.app.workspace.getActiveFile();
|
||||
// Solo actualiza si el archivo realmente cambió
|
||||
// if (this.lastActiveFile?.path !== activeFile?.path) {
|
||||
// this.lastActiveFile = activeFile;
|
||||
// this.propagateNoteStatusChange();
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja y propaga el cambio de layout
|
||||
*/
|
||||
private handleLayoutChange(): void {
|
||||
window.dispatchEvent(new CustomEvent("note-status:update-pane"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica y propaga el estado de la nota activa
|
||||
*/
|
||||
private propagateNoteStatusChange(file: TFile): void {
|
||||
try {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
let fileStatuses: string[] = [];
|
||||
if (activeFile && activeFile.extension === "md") {
|
||||
fileStatuses = this.statusService.getFileStatuses(activeFile);
|
||||
}
|
||||
// Dispara evento para que otros componentes se actualicen
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("note-status:status-changed", {
|
||||
detail: { statuses: fileStatuses, file: file },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error checking note status:", error);
|
||||
}
|
||||
}
|
||||
public unload(): void {
|
||||
if (this.fileOpenEventRef) {
|
||||
this.app.workspace.off("file-open", this.fileOpenEventRef);
|
||||
}
|
||||
if (this.activeLeafChangeEventRef) {
|
||||
this.app.workspace.off(
|
||||
"active-leaf-change",
|
||||
this.activeLeafChangeEventRef,
|
||||
);
|
||||
}
|
||||
if (this.layoutChangeEventRef) {
|
||||
this.app.workspace.off("layout-change", this.layoutChangeEventRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
267
main.ts
267
main.ts
|
|
@ -1,267 +0,0 @@
|
|||
import { Plugin, Notice } from "obsidian";
|
||||
import { DEFAULT_SETTINGS } from "./constants/defaults";
|
||||
import { NoteStatusSettings } from "./models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { StyleService } from "services/style-service";
|
||||
|
||||
// Importar integraciones
|
||||
import { ExplorerIntegration } from "./integrations/explorer";
|
||||
import { EditorIntegration, ToolbarIntegration } from "./integrations/editor";
|
||||
import { MetadataIntegration } from "./integrations/metadata-cache";
|
||||
import { WorkspaceIntegration } from "./integrations/workspace";
|
||||
import { FileContextMenuIntegration } from "integrations/context-menu/file-context-menu-integration";
|
||||
import { NoteStatusSettingTab } from "integrations/settings/settings-tab";
|
||||
import { CommandIntegration } from "integrations/commands/command-integration";
|
||||
|
||||
// Importar vistas
|
||||
import { StatusPaneViewController } from "./views/status-pane-view";
|
||||
|
||||
// Importar componentes UI
|
||||
import { StatusBar } from "components/status-bar";
|
||||
import { StatusDropdown } from "components/status-dropdown";
|
||||
import { StatusContextMenu } from "integrations/context-menu/status-context-menu";
|
||||
|
||||
export default class NoteStatus extends Plugin {
|
||||
settings: NoteStatusSettings;
|
||||
|
||||
// Servicios
|
||||
statusService: StatusService;
|
||||
styleService: StyleService;
|
||||
|
||||
// Componentes UI
|
||||
statusBar: StatusBar;
|
||||
statusDropdown: StatusDropdown;
|
||||
|
||||
// Integraciones
|
||||
explorerIntegration: ExplorerIntegration;
|
||||
fileContextMenuIntegration: FileContextMenuIntegration;
|
||||
editorIntegration: EditorIntegration;
|
||||
toolbarIntegration: ToolbarIntegration;
|
||||
metadataIntegration: MetadataIntegration;
|
||||
workspaceIntegration: WorkspaceIntegration;
|
||||
commandIntegration: CommandIntegration;
|
||||
|
||||
statusPane: StatusPaneViewController;
|
||||
|
||||
private boundHandleStatusChanged: (event: CustomEvent) => void;
|
||||
|
||||
async onload() {
|
||||
try {
|
||||
await this.loadSettings();
|
||||
this.initializeServices();
|
||||
this.registerViews();
|
||||
this.initializeUI();
|
||||
this.initializeIntegrations();
|
||||
this.setupCustomEvents();
|
||||
} catch (error) {
|
||||
console.error("Error loading Note Status plugin:", error);
|
||||
new Notice(
|
||||
"Error loading Note Status plugin. Check console for details.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSettings() {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
await this.loadData(),
|
||||
);
|
||||
}
|
||||
|
||||
private initializeServices() {
|
||||
this.statusService = new StatusService(this.app, this.settings);
|
||||
this.styleService = new StyleService(this.settings);
|
||||
}
|
||||
|
||||
private registerViews() {
|
||||
// Register status pane view
|
||||
this.registerView("status-pane", (leaf) => {
|
||||
this.statusPane = new StatusPaneViewController(leaf, this);
|
||||
return this.statusPane;
|
||||
});
|
||||
|
||||
// Add ribbon icon
|
||||
this.addRibbonIcon("tag", "Open status pane", () => {
|
||||
this.openStatusPane();
|
||||
});
|
||||
|
||||
// Añadir pestaña de configuración
|
||||
this.addSettingTab(
|
||||
new NoteStatusSettingTab(this.app, this, this.statusService),
|
||||
);
|
||||
}
|
||||
|
||||
private initializeIntegrations() {
|
||||
this.explorerIntegration = new ExplorerIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
);
|
||||
this.toolbarIntegration = new ToolbarIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.statusDropdown,
|
||||
);
|
||||
const statusContextMenu = new StatusContextMenu(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.statusDropdown,
|
||||
this.explorerIntegration,
|
||||
);
|
||||
this.fileContextMenuIntegration = new FileContextMenuIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.explorerIntegration,
|
||||
statusContextMenu,
|
||||
);
|
||||
|
||||
this.editorIntegration = new EditorIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.statusDropdown,
|
||||
);
|
||||
this.commandIntegration = new CommandIntegration(
|
||||
this.app,
|
||||
this,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.statusDropdown,
|
||||
);
|
||||
this.metadataIntegration = new MetadataIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.explorerIntegration,
|
||||
);
|
||||
|
||||
this.workspaceIntegration = new WorkspaceIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.toolbarIntegration,
|
||||
);
|
||||
|
||||
// Registrar eventos en cada integración
|
||||
this.fileContextMenuIntegration.registerFileContextMenuEvents();
|
||||
this.editorIntegration.registerEditorMenus();
|
||||
this.commandIntegration.registerCommands();
|
||||
this.metadataIntegration.registerMetadataEvents();
|
||||
this.workspaceIntegration.registerWorkspaceEvents();
|
||||
}
|
||||
|
||||
private setupCustomEvents() {
|
||||
this.boundHandleStatusChanged = this.handleStatusChanged.bind(this);
|
||||
window.addEventListener(
|
||||
"note-status:status-changed",
|
||||
this.boundHandleStatusChanged,
|
||||
);
|
||||
}
|
||||
|
||||
private initializeUI() {
|
||||
this.statusDropdown = new StatusDropdown(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
);
|
||||
|
||||
// Inicializar barra de estado
|
||||
this.statusBar = new StatusBar(
|
||||
this.addStatusBarItem(),
|
||||
this.settings,
|
||||
this.statusService,
|
||||
);
|
||||
|
||||
// Inicializar iconos del explorador (con retraso para evitar ralentizar el inicio)
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
setTimeout(() => {
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStatusChanged(event: CustomEvent) {
|
||||
const { statuses, file } = event.detail;
|
||||
|
||||
// Actualizar barra de estado
|
||||
this.statusBar.update(statuses);
|
||||
// Actualizar toolbar
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile?.path === file) {
|
||||
this.toolbarIntegration.updateStatusDisplay(statuses);
|
||||
}
|
||||
// Actualizar dropdown
|
||||
this.statusDropdown.update(statuses);
|
||||
// Actualizar status pane si está abierto
|
||||
const statusPaneLeaf =
|
||||
this.app.workspace.getLeavesOfType("status-pane")[0];
|
||||
if (
|
||||
statusPaneLeaf?.view &&
|
||||
statusPaneLeaf.view instanceof StatusPaneViewController
|
||||
) {
|
||||
(statusPaneLeaf.view as StatusPaneViewController).update();
|
||||
}
|
||||
// Actualizar explorador si es necesario
|
||||
if (this.settings.showStatusIconsInExplorer && file) {
|
||||
const fileObj = this.app.vault.getFileByPath(file);
|
||||
if (fileObj) {
|
||||
this.explorerIntegration.updateFileExplorerIcons(fileObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async openStatusPane() {
|
||||
await StatusPaneViewController.open(this.app);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
|
||||
// Actualizar servicios
|
||||
this.statusService.updateSettings(this.settings);
|
||||
this.styleService.updateSettings(this.settings);
|
||||
|
||||
// Actualizar integraciones
|
||||
this.explorerIntegration.updateSettings(this.settings);
|
||||
this.fileContextMenuIntegration.updateSettings(this.settings);
|
||||
this.editorIntegration.updateSettings(this.settings);
|
||||
this.metadataIntegration?.updateSettings(this.settings);
|
||||
this.commandIntegration?.updateSettings(this.settings);
|
||||
this.toolbarIntegration.updateSettings(this.settings);
|
||||
this.workspaceIntegration.updateSettings(this.settings);
|
||||
this.statusPane?.updateSettings(this.settings);
|
||||
// Actualizar componentes UI
|
||||
this.statusBar.updateSettings(this.settings);
|
||||
this.statusDropdown.updateSettings(this.settings);
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Clean up event listeners
|
||||
if (this.boundHandleStatusChanged) {
|
||||
window.removeEventListener(
|
||||
"note-status:status-changed",
|
||||
this.boundHandleStatusChanged,
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up integrations
|
||||
this.explorerIntegration?.unload();
|
||||
this.toolbarIntegration?.unload();
|
||||
this.fileContextMenuIntegration?.unload();
|
||||
this.workspaceIntegration?.unload();
|
||||
this.metadataIntegration?.unload();
|
||||
this.editorIntegration?.unload();
|
||||
this.commandIntegration?.unload();
|
||||
|
||||
// Clean up services
|
||||
this.styleService?.unload();
|
||||
|
||||
// Clean up UI components
|
||||
this.statusBar?.unload();
|
||||
this.statusDropdown?.unload();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue