Merge pull request #79 from devonthesofa/feature/file-explorer-rainbow

[feature] Add advanced file explorer status visualizations
This commit is contained in:
Aleix Soler 2025-11-21 15:34:55 +01:00 committed by GitHub
commit edacb2a9fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 396 additions and 12 deletions

View file

@ -2,6 +2,11 @@ import { StatusIconPreview } from "@/components/atoms/StatusIconPreview";
import { GroupedStatuses } from "@/types/noteStatus";
import React, { FC, memo } from "react";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import {
getPrimaryStatus,
getUnknownStatusColor,
resolveStatusColor,
} from "@/utils/statusColor";
type Props = {
statuses: GroupedStatuses;
@ -35,15 +40,12 @@ export const FileExplorerIcon: FC<Props> = memo(
const useStatusColors = iconColorMode === "status";
const getStatusColor = (color?: string) =>
(color && color.trim()) || "var(--text-accent)";
if (totalStatuses === 0) {
if (hideUnknownStatus) return null;
const icon = unknownStatusConfig?.icon || "❓";
const color = useStatusColors
? unknownStatusConfig?.color?.trim() || "#8b949e"
? unknownStatusConfig?.color?.trim() || getUnknownStatusColor()
: undefined;
return (
@ -64,10 +66,10 @@ export const FileExplorerIcon: FC<Props> = memo(
);
}
const primaryStatus = statusEntries[0]?.[1]?.[0];
const primaryStatus = getPrimaryStatus(statuses);
if (!primaryStatus) return null;
const iconColor = useStatusColors
? getStatusColor(primaryStatus.color)
? resolveStatusColor(primaryStatus)
: undefined;
return (

View file

@ -225,6 +225,63 @@ export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
/>
</SettingItem>
<SettingItem
name="Color filename text using status color"
description="Apply the active status color to the filename text inside the file explorer."
>
<input
type="checkbox"
checked={settings.fileExplorerColorFileName || false}
onChange={handleChange("fileExplorerColorFileName")}
/>
</SettingItem>
<SettingItem
name="Color the file explorer block based on status"
description="Tint the entire file explorer list item with the active status color."
>
<input
type="checkbox"
checked={settings.fileExplorerColorBlock || false}
onChange={handleChange("fileExplorerColorBlock")}
/>
</SettingItem>
<h4>Additional status visualization in file explorer</h4>
<SettingItem
name="Show colored left border"
description="Render a subtle colored strip on the left edge of the file explorer item."
>
<input
type="checkbox"
checked={settings.fileExplorerLeftBorder || false}
onChange={handleChange("fileExplorerLeftBorder")}
/>
</SettingItem>
<SettingItem
name="Show colored status dot"
description="Append a compact colored dot next to the filename as an extra status indicator."
>
<input
type="checkbox"
checked={settings.fileExplorerStatusDot || false}
onChange={handleChange("fileExplorerStatusDot")}
/>
</SettingItem>
<SettingItem
name="Underline filename with status color"
description="Adds a colored underline below the filename without changing the text color."
>
<input
type="checkbox"
checked={settings.fileExplorerUnderlineFileName || false}
onChange={handleChange("fileExplorerUnderlineFileName")}
/>
</SettingItem>
<SettingItem
name="Status icon frame"
description="Choose whether to display a frame around the status icon inside the file explorer."

View file

@ -22,6 +22,11 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
customStatuses: [],
showStatusIconsInExplorer: true,
hideUnknownStatusInExplorer: true, // Default to hide unknown status
fileExplorerColorFileName: false,
fileExplorerColorBlock: false,
fileExplorerLeftBorder: false,
fileExplorerStatusDot: false,
fileExplorerUnderlineFileName: false,
enabledTemplates: DEFAULT_ENABLED_TEMPLATES,
useCustomStatusesOnly: false,
useMultipleStatuses: true,

View file

@ -7,6 +7,11 @@ import {
import { NoteStatusService } from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
import { GroupedStatuses } from "@/types/noteStatus";
import {
getPrimaryStatus,
getUnknownStatusColor,
resolveStatusColor,
} from "@/utils/statusColor";
import { Plugin, TFile, View } from "obsidian";
import { createRoot } from "react-dom/client";
import { StatusesInfoPopup } from "../popups/statusesInfoPopupIntegration";
@ -20,6 +25,15 @@ export class FileExplorerIntegration implements IElementProcessor {
"file-explorer-integration-subscription1";
private readonly FILE_EXPLORER_TYPE = "file-explorer";
private readonly CONTAINER_SELECTOR = ".nav-files-container";
private readonly FILE_NAME_COLORIZED_ATTR = "noteStatusColorized";
private readonly FILE_NAME_ORIGINAL_COLOR_ATTR = "noteStatusOriginalColor";
private readonly FILE_BLOCK_CLASS = "note-status-colored-block";
private readonly FILE_BLOCK_COLOR_VAR = "--note-status-block-color";
private readonly FILE_BORDER_CLASS = "note-status-border-left";
private readonly FILE_BORDER_COLOR_VAR = "--note-status-border-color";
private readonly FILE_UNDERLINE_CLASS = "note-status-underline";
private readonly FILE_UNDERLINE_COLOR_VAR = "--note-status-underline-color";
private readonly STATUS_DOT_CLASS = "note-status-dot-badge";
constructor(plugin: Plugin) {
this.plugin = plugin;
@ -58,6 +72,11 @@ export class FileExplorerIntegration implements IElementProcessor {
key === "fileExplorerIconPosition" ||
key === "fileExplorerIconFrame" ||
key === "fileExplorerIconColorMode" ||
key === "fileExplorerColorFileName" ||
key === "fileExplorerColorBlock" ||
key === "fileExplorerLeftBorder" ||
key === "fileExplorerStatusDot" ||
key === "fileExplorerUnderlineFileName" ||
key === "unknownStatusIcon" ||
key === "unknownStatusLucideIcon" ||
key === "unknownStatusColor" ||
@ -101,8 +120,22 @@ export class FileExplorerIntegration implements IElementProcessor {
}
const noteStatusService = this.getFileNoteStatusService(dataPath);
const statuses = noteStatusService?.statuses ?? null;
const primaryStatus = getPrimaryStatus(statuses);
const hasStatus = Boolean(primaryStatus);
const fallbackColor = getUnknownStatusColor();
const statusColor = primaryStatus
? resolveStatusColor(primaryStatus, fallbackColor)
: undefined;
const textElement = textEl as HTMLElement;
const navItem = this.getNavItemElement(textElement);
this.applyFileNameColor(textElement, statusColor, hasStatus);
this.applyFileNameUnderline(textElement, statusColor, hasStatus);
this.applyStatusDot(textElement, statusColor, hasStatus);
this.applyFileBlockColor(navItem, statusColor, hasStatus);
this.applyLeftBorder(navItem, statusColor, hasStatus);
// Only render icons for markdown files
if (noteStatusService) {
this.render(textEl, noteStatusService.statuses);
}
@ -110,11 +143,10 @@ export class FileExplorerIntegration implements IElementProcessor {
}
private getUnknownStatusConfig() {
const settings = settingsService.settings;
return {
icon: settings.unknownStatusIcon || "❓",
lucideIcon: settings.unknownStatusLucideIcon || "",
color: settings.unknownStatusColor || "#8b949e",
icon: settingsService.settings.unknownStatusIcon || "❓",
lucideIcon: settingsService.settings.unknownStatusLucideIcon || "",
color: getUnknownStatusColor(),
};
}
@ -270,4 +302,181 @@ export class FileExplorerIntegration implements IElementProcessor {
this.CONTAINER_SELECTOR,
);
}
private applyFileNameColor(
element?: HTMLElement | null,
color?: string,
hasStatus?: boolean,
): void {
if (!element) {
return;
}
if (
!settingsService.settings.fileExplorerColorFileName ||
!hasStatus ||
!color
) {
this.clearFileNameColor(element);
return;
}
if (!element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR]) {
element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR] =
element.style.color || "";
}
element.dataset[this.FILE_NAME_COLORIZED_ATTR] = "true";
element.style.color = color;
}
private applyFileBlockColor(
navItem?: HTMLElement | null,
color?: string,
hasStatus?: boolean,
): void {
if (!navItem) {
return;
}
if (
!settingsService.settings.fileExplorerColorBlock ||
!hasStatus ||
!color
) {
this.clearFileBlockColor(navItem);
return;
}
navItem.classList.add(this.FILE_BLOCK_CLASS);
navItem.style.setProperty(this.FILE_BLOCK_COLOR_VAR, color);
}
private applyLeftBorder(
navItem?: HTMLElement | null,
color?: string,
hasStatus?: boolean,
): void {
if (!navItem) {
return;
}
if (
!settingsService.settings.fileExplorerLeftBorder ||
!hasStatus ||
!color
) {
this.clearFileLeftBorder(navItem);
return;
}
navItem.classList.add(this.FILE_BORDER_CLASS);
navItem.style.setProperty(this.FILE_BORDER_COLOR_VAR, color);
}
private applyFileNameUnderline(
element?: HTMLElement | null,
color?: string,
hasStatus?: boolean,
): void {
if (!element) {
return;
}
if (
!settingsService.settings.fileExplorerUnderlineFileName ||
!hasStatus ||
!color
) {
this.clearFileNameUnderline(element);
return;
}
element.classList.add(this.FILE_UNDERLINE_CLASS);
element.style.setProperty(this.FILE_UNDERLINE_COLOR_VAR, color);
}
private applyStatusDot(
element?: HTMLElement | null,
color?: string,
hasStatus?: boolean,
): void {
if (!element) {
return;
}
const existingDot = element.querySelector(
`.${this.STATUS_DOT_CLASS}`,
) as HTMLElement | null;
if (
!settingsService.settings.fileExplorerStatusDot ||
!hasStatus ||
!color
) {
if (existingDot) {
existingDot.remove();
}
return;
}
const dot =
existingDot ||
createSpan({
cls: this.STATUS_DOT_CLASS,
});
dot.setAttribute("aria-hidden", "true");
dot.style.backgroundColor = color;
if (!existingDot) {
element.appendChild(dot);
}
}
private getNavItemElement(
element?: HTMLElement | null,
): HTMLElement | null {
if (!element) {
return null;
}
return element.closest(".nav-file, .nav-folder") as HTMLElement | null;
}
private clearFileNameColor(element: HTMLElement): void {
if (!element.dataset[this.FILE_NAME_COLORIZED_ATTR]) {
return;
}
const previousColor =
element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR] || "";
if (previousColor) {
element.style.color = previousColor;
} else {
element.style.removeProperty("color");
}
delete element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR];
delete element.dataset[this.FILE_NAME_COLORIZED_ATTR];
}
private clearFileBlockColor(navItem?: HTMLElement | null): void {
if (!navItem) {
return;
}
navItem.classList.remove(this.FILE_BLOCK_CLASS);
navItem.style.removeProperty(this.FILE_BLOCK_COLOR_VAR);
}
private clearFileLeftBorder(navItem?: HTMLElement | null): void {
if (!navItem) {
return;
}
navItem.classList.remove(this.FILE_BORDER_CLASS);
navItem.style.removeProperty(this.FILE_BORDER_COLOR_VAR);
}
private clearFileNameUnderline(element: HTMLElement): void {
element.classList.remove(this.FILE_UNDERLINE_CLASS);
element.style.removeProperty(this.FILE_UNDERLINE_COLOR_VAR);
}
}

File diff suppressed because one or more lines are too long

View file

@ -11,6 +11,62 @@
right: 0;
}
.note-status-colored-block {
position: relative;
border-radius: var(--radius-s);
z-index: 0;
}
.note-status-colored-block::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background-color: var(--note-status-block-color, transparent);
opacity: 0.18;
pointer-events: none;
z-index: -1;
transition:
background-color var(--anim-duration-fast) ease,
opacity var(--anim-duration-fast) ease;
}
.note-status-border-left {
position: relative;
}
.note-status-border-left::after {
content: "";
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 3px;
border-radius: var(--radius-s);
background-color: var(--note-status-border-color, transparent);
opacity: 0.35;
pointer-events: none;
}
.note-status-underline {
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--note-status-underline-color, currentColor);
text-underline-offset: 3px;
}
.note-status-dot-badge {
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: var(--size-2-1);
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 1px var(--background-modifier-border);
flex-shrink: 0;
}
.status-wrapper {
position: relative;
display: inline-block;

View file

@ -23,6 +23,11 @@ export type PluginSettings = {
statusBarShowTemplateName: "always" | "never" | "auto"; // How to show template names in status bar
showStatusIconsInExplorer: boolean;
hideUnknownStatusInExplorer: boolean;
fileExplorerColorFileName: boolean; // Whether to color the file explorer filename text using the current status color
fileExplorerColorBlock: boolean; // Whether to tint the entire explorer list item background using the status color
fileExplorerLeftBorder: boolean; // Whether to display a colored border on the explorer item
fileExplorerStatusDot: boolean; // Whether to append a small colored dot next to the filename
fileExplorerUnderlineFileName: boolean; // Whether to underline the filename using the status color
enabledTemplates: string[]; // IDs of enabled templates
useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates
useMultipleStatuses: boolean; // Whether to allow multiple statuses per note

50
utils/statusColor.ts Normal file
View file

@ -0,0 +1,50 @@
import settingsService from "@/core/settingsService";
import { GroupedStatuses, NoteStatus } from "@/types/noteStatus";
export const DEFAULT_STATUS_ACCENT_COLOR = "var(--text-accent)";
const DEFAULT_UNKNOWN_STATUS_COLOR = "#8b949e";
export function getPrimaryStatus(
statuses?: GroupedStatuses | null,
): NoteStatus | null {
if (!statuses) {
return null;
}
for (const list of Object.values(statuses)) {
if (list.length) {
return list[0];
}
}
return null;
}
export function getUnknownStatusColor(): string {
return (
settingsService.settings.unknownStatusColor?.trim() ||
DEFAULT_UNKNOWN_STATUS_COLOR
);
}
export function resolveStatusColor(
status?: NoteStatus | null,
fallbackColor?: string,
defaultColor: string | null = DEFAULT_STATUS_ACCENT_COLOR,
): string | undefined {
const statusColor = status?.color?.trim();
if (statusColor) {
return statusColor;
}
const fallback = fallbackColor?.trim();
if (fallback) {
return fallback;
}
if (defaultColor === null) {
return undefined;
}
return defaultColor || undefined;
}