feat: Add nesting of tags in a tree

- levels are joined by forward slashes in the keywords
- behaviour is opt-in for now
- long paths are shortened if there is not enough display room

Closes #687.
This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:38 +02:00
parent ce97a54aaa
commit ca0ba818b7
22 changed files with 1084 additions and 95 deletions
+26 -21
View File
@@ -38,6 +38,8 @@ import {
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { TagOptionLabel } from "./tag-option-label";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
interface Position {
@@ -155,6 +157,7 @@ export function EmailContextMenu({
const _tColor = useTranslations("email_viewer.color_tag");
const tEmailViewer = useTranslations("email_viewer");
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { tagNameCandidates } = useKeywordFormat();
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
@@ -170,7 +173,7 @@ export function EmailContextMenu({
// Build color options from keyword definitions in settings
const colorOptions = emailKeywords.map((kw) => ({
name: kw.label,
candidates: tagNameCandidates(kw.id),
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
}));
@@ -381,26 +384,28 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
<div className="max-w-[18rem]">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<TagOptionLabel candidates={option.candidates} />
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
</div>
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
+8 -2
View File
@@ -16,6 +16,7 @@ import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTags } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
interface EmailListItemProps {
email: Email;
@@ -40,6 +41,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { tagName } = useKeywordFormat();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const { identities } = useAuthStore();
@@ -232,7 +234,11 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
<span
key={kd.id}
className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')}
title={tagName(kd.id)}
/>
))}
<span className={cn(
'text-xs tabular-nums',
@@ -290,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
)} title={tagName(kd.id)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
+16 -8
View File
@@ -12,6 +12,8 @@ import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { TagOptionLabel } from "./tag-option-label";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
import { generateEmailSource } from "@/lib/email-source";
@@ -667,6 +669,7 @@ export function EmailViewer({
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { tagName, tagNameCandidates } = useKeywordFormat();
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -711,7 +714,7 @@ export function EmailViewer({
// Color options for email tags (from user-defined keyword settings)
const colorOptions = emailKeywords.map((kw) => ({
name: kw.label,
candidates: tagNameCandidates(kw.id),
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
}));
@@ -3012,9 +3015,10 @@ export function EmailViewer({
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
</span>
<TagOptionLabel
candidates={tagNameCandidates(currentColors[0])}
className="max-w-40 text-xs font-medium text-foreground"
/>
)}
</>
) : (
@@ -3038,7 +3042,7 @@ export function EmailViewer({
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
<TagOptionLabel candidates={option.candidates} />
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
@@ -3273,7 +3277,7 @@ export function EmailViewer({
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
<TagOptionLabel candidates={option.candidates} />
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
@@ -3555,7 +3559,7 @@ export function EmailViewer({
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
<TagOptionLabel candidates={option.candidates} />
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
@@ -3634,7 +3638,11 @@ export function EmailViewer({
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
return (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
<span
key={tagId}
className={cn("w-2.5 h-2.5 rounded-full shrink-0", dotClass)}
title={tagName(tagId)}
/>
);
})}
</span>
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { cn } from "@/lib/utils";
import { useShortenedText } from "@/hooks/use-shortened-text";
/**
* A tag name inside one of the tag pickers, shortened to what that picker has
* room for.
*
* The pickers differ in width - a narrow popover, a context submenu, a
* full-width mobile sheet - so each row measures itself instead of sharing one
* cap. `candidates` runs longest first (see `keywordRenderings`); the full name
* stays reachable through the tooltip.
*/
export function TagOptionLabel({
candidates,
className,
}: {
candidates: string[];
className?: string;
}) {
const [labelRef, shortenedLabel] = useShortenedText(candidates);
return (
<span ref={labelRef} className={cn("min-w-0 truncate", className)} title={candidates[0]}>
{shortenedLabel}
</span>
);
}
+16 -6
View File
@@ -11,6 +11,7 @@ import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
import { useAccountStore } from "@/stores/account-store";
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item";
@@ -90,7 +91,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const { tagName } = useKeywordFormat();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
@@ -282,7 +284,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
<span
key={kd.id}
className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')}
title={tagName(kd.id)}
/>
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
@@ -351,7 +357,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
)} title={tagName(kd.id)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
@@ -499,7 +505,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const { tagName } = useKeywordFormat();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
@@ -746,7 +753,10 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
<span
className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')}
title={tagName(keywordDef.id)}
/>
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
@@ -827,7 +837,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
)} title={tagName(keywordDef.id)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
+3 -1
View File
@@ -18,6 +18,7 @@ import type {
import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
interface FilterRuleModalProps {
rule?: FilterRule;
@@ -89,6 +90,7 @@ export function FilterRuleModal({
const t = useTranslations("settings.filters");
const isEdit = !!rule;
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { tagName } = useKeywordFormat();
const [name, setName] = useState(rule?.name || "");
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
@@ -477,7 +479,7 @@ export function FilterRuleModal({
>
<option value="">{t("label_placeholder")}</option>
{emailKeywords.map((kw) => (
<option key={kw.id} value={kw.id}>{kw.label}</option>
<option key={kw.id} value={kw.id}>{tagName(kw.id)}</option>
))}
</select>
)}
+116 -35
View File
@@ -38,6 +38,9 @@ import {
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { buildKeywordTree, hasChildKeywords, type KeywordNode } from "@/lib/keyword-nesting";
import { useShortenedText } from "@/hooks/use-shortened-text";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { isEditableEventTarget } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -51,7 +54,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop";
import { useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { useVacationStore } from "@/stores/vacation-store";
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
@@ -241,6 +244,9 @@ function SidebarRowCounts({
interface SidebarRowProps {
icon: ReactNode;
label: string;
/** Progressively shorter renderings of `label`, longest first. The widest one
* that fits the row is shown; without this the full label is used. */
labelCandidates?: string[];
depth?: number;
isSelected?: boolean;
isVirtual?: boolean;
@@ -266,6 +272,7 @@ interface SidebarRowProps {
function SidebarRow({
icon,
label,
labelCandidates,
depth = 0,
isSelected = false,
isVirtual = false,
@@ -288,6 +295,7 @@ function SidebarRow({
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
return (
<div
@@ -353,7 +361,7 @@ function SidebarRow({
</span>
{!isCollapsed && (
<>
<span className="flex-1 truncate">{label}</span>
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
<SidebarRowCounts
unread={unread}
total={total}
@@ -559,42 +567,54 @@ const TAG_ICON_COLOR: Record<string, string> = {
};
function TagItem({
kw,
isSelected,
node,
selectedKeyword,
expandedTags,
isCollapsed,
onTagSelect,
totalCount,
unreadCount,
onToggleExpand,
tagCounts,
colorful,
}: {
kw: KeywordDefinition;
isSelected: boolean;
node: KeywordNode;
selectedKeyword: string | null;
expandedTags: Set<string>;
isCollapsed: boolean;
onTagSelect?: (keywordId: string | null) => void;
totalCount: number;
unreadCount: number;
onToggleExpand: (keywordId: string) => void;
tagCounts: Record<string, { total: number; unread: number }>;
colorful: boolean;
}) {
const t = useTranslations('notifications');
const palette = KEYWORD_PALETTE[kw.color];
const { tagNameCandidates } = useKeywordFormat();
const palette = KEYWORD_PALETTE[node.color];
const hasChildren = node.children.length > 0;
const isExpanded = expandedTags.has(node.id);
const isSelected = selectedKeyword === node.id;
// Nested rows are placed by their indentation, so they show their own name.
// A root spells out its path, which matters when an intermediate tag is
// missing from this client's settings and the row would otherwise read as a
// bare leaf name. Toasts have the room for the whole thing.
const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label];
const label = labelCandidates[0];
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget } = useTagDrop({
tagId: kw.id,
onSuccess: (count, _tagLabel) => {
tagId: node.id,
onSuccess: (count) => {
if (count === 1) {
toast.success(t('email_tagged'), kw.label);
toast.success(t('email_tagged'), label);
} else {
toast.success(t('emails_tagged', { count }), kw.label);
toast.success(t('emails_tagged', { count }), label);
}
},
onError: () => {
toast.error(t('tag_failed'), kw.label);
toast.error(t('tag_failed'), label);
},
});
const tagIcon = colorful ? (
<Tag
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[kw.color] || "text-muted-foreground")}
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[node.color] || "text-muted-foreground")}
fill="currentColor"
/>
) : (
@@ -602,18 +622,38 @@ function TagItem({
);
return (
<SidebarRow
icon={tagIcon}
label={kw.label}
depth={0}
isSelected={isSelected}
unread={unreadCount}
total={totalCount}
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
/>
<>
<SidebarRow
icon={tagIcon}
label={label}
labelCandidates={labelCandidates}
depth={node.depth}
isSelected={isSelected}
unread={tagCounts[node.id]?.unread ?? 0}
total={tagCounts[node.id]?.total ?? 0}
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
hasChildren={hasChildren}
isExpanded={isExpanded}
onExpandToggle={() => onToggleExpand(node.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
/>
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
<TagItem
key={child.id}
node={child}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
onToggleExpand={onToggleExpand}
tagCounts={tagCounts}
colorful={colorful}
/>
))}
</>
);
}
@@ -737,6 +777,7 @@ export function Sidebar({
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
const [foldersExpanded, setFoldersExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarFoldersExpanded');
@@ -779,6 +820,7 @@ export function Sidebar({
return new Set();
});
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const nestedTags = useSettingsStore(s => s.nestedTags);
const isEmbedded = useIsEmbedded();
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
// own AccountSwitcher would be a redundant second account UI in the same
@@ -842,6 +884,37 @@ export function Sidebar({
});
};
useEffect(() => {
const stored = localStorage.getItem('expandedTags');
if (stored) {
try {
const parsed = JSON.parse(stored);
setExpandedTags(new Set(parsed));
} catch (e) {
debug.error('Failed to parse expanded tags:', e);
}
} else {
setExpandedTags(
new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id))
);
}
}, [emailKeywords]);
const handleToggleTagExpand = (keywordId: string) => {
setExpandedTags((prev) => {
const next = new Set(prev);
if (next.has(keywordId)) {
next.delete(keywordId);
} else {
next.add(keywordId);
}
try {
localStorage.setItem('expandedTags', JSON.stringify(Array.from(next)));
} catch { /* storage full or unavailable */ }
return next;
});
};
// When the app renders its own virtual "Scheduled" folder (for delayed
// sends, driven by EmailSubmission), hide the server-provided scheduled
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
@@ -852,6 +925,13 @@ export function Sidebar({
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
// With nesting off every tag is its own root, so the same rows render through
// one path whether or not the ids describe a hierarchy.
const tagTree: KeywordNode[] = nestedTags
? buildKeywordTree(emailKeywords)
: emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
// Multi-account mode (Pro shell): render every connected account as its
// own collapsible group. The active account's tree comes from the
// `mailboxes` prop (which is the live email-store value); other accounts
@@ -1265,15 +1345,16 @@ export function Sidebar({
/>
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
<>
{emailKeywords.map((kw) => (
{tagTree.map((node) => (
<TagItem
key={kw.id}
kw={kw}
isSelected={selectedKeyword === kw.id}
key={node.id}
node={node}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
totalCount={tagCounts[kw.id]?.total ?? 0}
unreadCount={tagCounts[kw.id]?.unread ?? 0}
onToggleExpand={handleToggleTagExpand}
tagCounts={tagCounts}
colorful={colorfulSidebarIcons}
/>
))}
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { KeywordSettings } from '../keyword-settings';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
// Mock SettingsSection to just render children
vi.mock('../settings-section', () => ({
// Mock SettingsSection to just render children, keeping the real controls
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('KeywordSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
});
it('renders all default keywords', () => {
@@ -139,4 +140,74 @@ describe('KeywordSettings', () => {
expect(added.id).toBe('my-custom-tag');
expect(added.label).toBe('My Custom Tag!');
});
it('offers no parent picker while nesting is off', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument();
});
it('nests a new tag under the selected parent', () => {
useSettingsStore.setState({
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' });
});
it('shows nested tags by their full path', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
expect(screen.getByText('$label:work/clients')).toBeInTheDocument();
});
it('rejects a path that would exceed the keyword length limit', () => {
const deepId = 'a'.repeat(240);
useSettingsStore.setState({
emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } });
expect(screen.getByText('too_long')).toBeInTheDocument();
expect(screen.getByText('add').closest('button')).toBeDisabled();
});
it('locks the name and the delete action of a tag that has nested tags', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByTitle('has_children_delete')).toBeDisabled();
fireEvent.click(screen.getAllByTitle('edit')[0]);
expect(screen.getByDisplayValue('Work')).toBeDisabled();
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
});
});
+121 -15
View File
@@ -2,12 +2,30 @@
import React, { useState } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import {
useSettingsStore,
KEYWORD_PALETTE,
DEFAULT_KEYWORDS,
type KeywordDefinition,
} from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { SettingsSection } from "./settings-section";
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { KEYWORD_PREFIX } from "@/lib/thread-utils";
import {
buildKeywordTree,
composeKeywordId,
getParentKeywordId,
hasChildKeywords,
isKeywordDescendant,
keywordLevels,
type KeywordNode,
MAX_KEYWORD_ID_LENGTH,
} from "@/lib/keyword-nesting";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
import { useShortenedText } from "@/hooks/use-shortened-text";
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
@@ -39,6 +57,8 @@ function KeywordColorPicker({
function KeywordRow({
keyword,
keywords,
nestedTags,
onEdit,
onDelete,
onDragStart,
@@ -49,6 +69,8 @@ function KeywordRow({
isDragging,
}: {
keyword: KeywordDefinition;
keywords: KeywordDefinition[];
nestedTags: boolean;
onEdit: () => void;
onDelete: () => void;
onDragStart: () => void;
@@ -60,6 +82,13 @@ function KeywordRow({
}) {
const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color];
const hasChildren = hasChildKeywords(keyword.id, keywords);
const nameCandidates = keywordRenderings(formatKeywordLabels(keyword.id, keywords, nestedTags));
const [nameRef, shortenedName] = useShortenedText(nameCandidates);
// Measured with the prefix attached, since that is what occupies the column.
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
.map((rendering) => KEYWORD_PREFIX + rendering);
const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates);
return (
<div
@@ -76,8 +105,20 @@ function KeywordRow({
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
<span
ref={nameRef}
className="flex-1 min-w-0 text-sm font-medium truncate"
title={formatKeyword(keyword.id, keywords, nestedTags)}
>
{shortenedName}
</span>
<span
ref={keywordRef}
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
title={KEYWORD_PREFIX + keyword.id}
>
{shortenedKeyword}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
@@ -90,8 +131,9 @@ function KeywordRow({
<button
type="button"
onClick={onDelete}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t("delete")}
disabled={hasChildren}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
title={hasChildren ? t("has_children_delete") : t("delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@@ -102,37 +144,74 @@ function KeywordRow({
function KeywordEditForm({
initial,
keywords,
existingIds,
nestedTags,
onSave,
onCancel,
}: {
initial?: KeywordDefinition;
keywords: KeywordDefinition[];
existingIds: string[];
nestedTags: boolean;
onSave: (keyword: KeywordDefinition) => void;
onCancel: () => void;
}) {
const t = useTranslations("settings.keywords");
const [label, setLabel] = useState(initial?.label || "");
const [color, setColor] = useState(initial?.color || "blue");
const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : "");
const isEditing = !!initial;
const normalizedId = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
// Renaming or re-parenting a tag rewrites the keyword on every message below
// it, and this client only knows about the tags in its own settings - the
// server may hold nested keywords created elsewhere. Freeze the identity of a
// tag that has children and allow the color to change.
const isLocked = !!initial && hasChildKeywords(initial.id, keywords);
const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label);
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH;
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
// Every tag is a candidate parent except the one being edited and anything
// already below it, which would detach the branch from its own root.
const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }];
const collectParentOptions = (nodes: KeywordNode[]) => {
for (const node of nodes) {
if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue;
parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) });
collectParentOptions(node.children);
}
};
collectParentOptions(buildKeywordTree(keywords));
const handleSave = () => {
if (!isValid) return;
if (isLocked && initial) {
onSave({ ...initial, color });
return;
}
onSave({ id: normalizedId, label: label.trim(), color });
};
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
{nestedTags && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("parent_field")}
</label>
<Select
value={parentId}
onChange={setParentId}
options={parentOptions}
disabled={isLocked}
ariaLabel={t("parent_field")}
className="w-full"
/>
</div>
)}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("label_field")}
@@ -141,15 +220,29 @@ function KeywordEditForm({
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isLocked}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
placeholder={t("label_placeholder")}
autoFocus
maxLength={30}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
{nestedTags && normalizedId.length > 0 && (
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
{KEYWORD_PREFIX + normalizedId}
</p>
)}
{isLocked && (
<p className="text-xs text-muted-foreground mt-1">{t("has_children_locked")}</p>
)}
{isDuplicate && (
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
)}
{isTooLong && (
<p className="text-xs text-destructive mt-1">
{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}
</p>
)}
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
@@ -182,7 +275,7 @@ function KeywordEditForm({
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
@@ -266,6 +359,13 @@ export function KeywordSettings() {
return (
<SettingsSection title={t("title")} description={t("description")}>
<SettingItem label={t("nesting.label")} description={t("nesting.description")}>
<ToggleSwitch
checked={nestedTags}
onChange={(checked) => updateSetting("nestedTags", checked)}
/>
</SettingItem>
<div className="space-y-2">
{isMigrating && (
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
@@ -278,7 +378,9 @@ export function KeywordSettings() {
<KeywordEditForm
key={keyword.id}
initial={keyword}
keywords={emailKeywords}
existingIds={existingIds.filter((id) => id !== keyword.id)}
nestedTags={nestedTags}
onSave={handleEdit}
onCancel={() => setEditingId(null)}
/>
@@ -286,6 +388,8 @@ export function KeywordSettings() {
<KeywordRow
key={keyword.id}
keyword={keyword}
keywords={emailKeywords}
nestedTags={nestedTags}
onEdit={() => {
setEditingId(keyword.id);
setIsAdding(false);
@@ -303,7 +407,9 @@ export function KeywordSettings() {
{isAdding ? (
<KeywordEditForm
keywords={emailKeywords}
existingIds={existingIds}
nestedTags={nestedTags}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
+11 -2
View File
@@ -111,15 +111,24 @@ interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
disabled?: boolean;
className?: string;
ariaLabel?: string;
}
export function Select({ value, onChange, options }: SelectProps) {
export function Select({ value, onChange, options, disabled, className, ariaLabel }: SelectProps) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-label={ariaLabel}
dir="auto"
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
className={cn(
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
className
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>