Merge pull request #41 from LINUXexpert-org/folder-colours

Give a folder a colour from its right-click menu
This commit is contained in:
LINUXexpert.org
2026-08-25 12:57:37 -07:00
committed by GitHub
5 changed files with 63 additions and 5 deletions
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { canDropFolder, descendantIds, movable } from "../folderMove"; import { canDropFolder, descendantIds, folderColor, movable } from "../folderMove";
import type { Id, Mailbox } from "@/jmap/types"; import type { Id, Mailbox } from "@/jmap/types";
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null): Mailbox => const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null): Mailbox =>
@@ -67,3 +67,17 @@ describe("canDropFolder", () => {
expect(canDropFolder(tree, "gone", "work")).toBe(false); expect(canDropFolder(tree, "gone", "work")).toBe(false);
}); });
}); });
describe("folderColor", () => {
it("returns the colour chosen for that folder, and null for the rest", () => {
const colors = { work: "#7c3aed" };
expect(folderColor(colors, "work")).toBe("#7c3aed");
expect(folderColor(colors, "news")).toBeNull();
expect(folderColor({}, "work")).toBeNull();
});
it("is keyed by id, so a renamed folder keeps its colour", () => {
// The id is stable across a rename; the name and path are not.
expect(folderColor({ mb1: "#0f766e" }, "mb1")).toBe("#0f766e");
});
});
+5
View File
@@ -45,3 +45,8 @@ export function canDropFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, tar
if (!mailboxes[targetId]) return false; if (!mailboxes[targetId]) return false;
return !descendantIds(mailboxes, draggedId).has(targetId); return !descendantIds(mailboxes, draggedId).has(targetId);
} }
/** The colour chosen for a folder, if any. Ids are used, so a rename keeps it. */
export function folderColor(colors: Record<string, string>, id: Id): string | null {
return colors[id] ?? null;
}
+6
View File
@@ -62,6 +62,11 @@ export interface Settings {
fontSize: "small" | "medium" | "large"; fontSize: "small" | "medium" | "large";
templates: Template[]; templates: Template[];
labels: Array<{ keyword: string; name: string; color: string }>; labels: Array<{ keyword: string; name: string; color: string }>;
/**
* Folder colours, by mailbox id. Local to this browser, like every other
* colour here: JMAP has nowhere on a Mailbox to keep one.
*/
folderColors: Record<string, string>;
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
showHiddenFolders: boolean; showHiddenFolders: boolean;
trustedImageSenders: string[]; trustedImageSenders: string[];
@@ -116,6 +121,7 @@ export const DEFAULT_SETTINGS: Settings = {
fontSize: "medium", fontSize: "medium",
templates: [], templates: [],
labels: [], labels: [],
folderColors: {},
sidebarCollapsed: false, sidebarCollapsed: false,
showHiddenFolders: false, showHiddenFolders: false,
trustedImageSenders: [], trustedImageSenders: [],
+5
View File
@@ -324,6 +324,11 @@ img { max-width: 100%; }
.nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; } .nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; }
.nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; } .nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; }
.nav-item.folder-row.dragging { opacity: .45; } .nav-item.folder-row.dragging { opacity: .45; }
/* A folder colour tints its icon; the label keeps the sidebar's contrast. */
.folder-row .folder-icon { display: inline-flex; align-items: center; }
/* .nav-item svg sets colour on the svg itself, so inheriting from the span is
not enough -- the icon has to be targeted directly to win the cascade. */
.folder-row .folder-icon[style*="--folder-color"] svg { color: var(--folder-color); }
/* The Folders heading doubles as the way back to the top level while dragging. */ /* The Folders heading doubles as the way back to the top level while dragging. */
.nav-section.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; border-radius: var(--radius-sm); color: var(--accent-soft-fg); } .nav-section.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; border-radius: var(--radius-sm); color: var(--accent-soft-fg); }
.nav-item svg { flex: 0 0 auto; color: var(--fg-muted); } .nav-item svg { flex: 0 0 auto; color: var(--fg-muted); }
+32 -4
View File
@@ -1,16 +1,17 @@
import { useMemo, useState, type DragEvent, type ReactNode } from "react"; import { useMemo, useState, type DragEvent, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2 } from "lucide-react"; import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { isScheduledMailbox } from "@/store/scheduled"; import { isScheduledMailbox } from "@/store/scheduled";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import type { Id, Mailbox } from "@/jmap/types"; import type { Id, Mailbox } from "@/jmap/types";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { CALENDAR_COLORS } from "@/ui/misc";
import { confirmDialog, promptDialog } from "@/ui/dialog"; import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { ShareDialog } from "../settings/ShareDialog"; import { ShareDialog } from "../settings/ShareDialog";
import { loadRaw, saveJson } from "@/lib/storage"; import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, movable } from "@/lib/folderMove"; import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
const ROLE_ICONS: Record<string, ReactNode> = { const ROLE_ICONS: Record<string, ReactNode> = {
inbox: <Inbox size={20} />, inbox: <Inbox size={20} />,
@@ -199,6 +200,10 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
// Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold). // Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold).
const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" && !scheduled ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts" && !scheduled; const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" && !scheduled ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts" && !scheduled;
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? <Clock size={20} /> : <Folder size={20} />; const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? <Clock size={20} /> : <Folder size={20} />;
// A chosen colour tints the icon only; the label keeps the tree's own
// contrast, which a dozen arbitrary colours would not reliably give it.
// Subscribed, not read once: picking a colour has to repaint the row.
const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id));
const onDragOver = (e: DragEvent) => { const onDragOver = (e: DragEvent) => {
const folder = e.dataTransfer.types.includes(FOLDER_MIME); const folder = e.dataTransfer.types.includes(FOLDER_MIME);
@@ -266,7 +271,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
> >
{hasChildren ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null} {hasChildren ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
</span> </span>
{icon} <span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
<span className="nav-label">{label}</span> <span className="nav-label">{label}</span>
{count > 0 && <span className="nav-count" title={hiddenUnread ? `${own} here, ${hiddenUnread} in subfolders` : undefined}>{count > 9999 ? "9999+" : count}</span>} {count > 0 && <span className="nav-count" title={hiddenUnread ? `${own} here, ${hiddenUnread} in subfolders` : undefined}>{count > 9999 ? "9999+" : count}</span>}
{count > 0 && <span className="nav-dot" />} {count > 0 && <span className="nav-dot" />}
@@ -287,6 +292,8 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) { function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const colors = useSettings((s) => s.settings.folderColors);
const update = useSettings((s) => s.update);
const hasChildren = useMail((s) => Object.values(s.mailboxes).some((x) => (x.parentId ?? null) === m.id)); const hasChildren = useMail((s) => Object.values(s.mailboxes).some((x) => (x.parentId ?? null) === m.id));
const subUnread = useMail((s) => { const subUnread = useMail((s) => {
const all = Object.values(s.mailboxes); const all = Object.values(s.mailboxes);
@@ -326,6 +333,13 @@ function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox;
if (ok) await useMail.getState().emptyMailbox(m.id); if (ok) await useMail.getState().emptyMailbox(m.id);
}; };
const isSpecial = Boolean(m.role) && m.role !== "subscribed"; const isSpecial = Boolean(m.role) && m.role !== "subscribed";
const color = folderColor(colors, m.id);
const setColor = (c: string | null) => {
const next = { ...colors };
if (c) next[m.id] = c;
else delete next[m.id];
update({ folderColors: next });
};
return ( return (
<> <>
<MenuItem icon={<CheckCheck size={16} />} label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} /> <MenuItem icon={<CheckCheck size={16} />} label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
@@ -343,6 +357,20 @@ function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox;
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} /> <MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={onShare} /> <MenuItem icon={<Share2 size={16} />} label="Share…" onClick={onShare} />
<MenuSep /> <MenuSep />
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
{CALENDAR_COLORS.map((c) => (
<button
key={c}
type="button"
style={{ background: c, width: 26, height: 26, outline: color?.toLowerCase() === c ? "2px solid var(--fg)" : undefined, outlineOffset: 1 }}
aria-label={c}
onClick={() => setColor(c)}
/>
))}
</div>
{color && <MenuItem icon={<X size={16} />} label="Use the default colour" onClick={() => setColor(null)} />}
<MenuSep />
{m.role === "trash" && <MenuItem icon={<Eraser size={16} />} label="Empty folder" onClick={() => void empty()} danger />} {m.role === "trash" && <MenuItem icon={<Eraser size={16} />} label="Empty folder" onClick={() => void empty()} danger />}
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} /> <MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
</> </>