Files
openchamber/packages/ui/src/components/session/SessionFolderItem.tsx
T
Bohdan Triapitsyn 14357257ae perf(ui): migrate icons to SVG sprite system
Replace @remixicon/react with a shared Icon component that renders
via <use href> references to a single hidden SVG sprite. This reduces
DOM node count by replacing inline SVGs with lightweight references.

- Create Icon component with sprite injection (packages/ui/src/components/icon/)
- Migrate all 164 files from @remixicon/react to Icon component
- Auto-generate sprite data from remixicon bundle (scripts/generate-icon-sprite.mjs)
- Add bun run icons:generate to package.json
- Move @remixicon/react to devDependencies
- Add icon usage instructions to theme-system skill
2026-05-13 13:26:35 +03:00

339 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { cn } from '@/lib/utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
interface SessionFolderItemProps<TSessionNode> {
folder: SessionFolder;
sessions: TSessionNode[];
/** Sub-folders that belong directly to this folder */
subFolderItems?: React.ReactNode;
isCollapsed: boolean;
onToggle: () => void;
onRename: (name: string) => void;
onDelete: () => void;
renderSessionNode: (
node: TSessionNode,
depth?: number,
groupDir?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
) => React.ReactNode;
groupDirectory?: string | null;
projectId?: string | null;
mobileVariant?: boolean;
alwaysShowActions?: boolean;
isRenaming?: boolean;
renameDraft?: string;
onRenameDraftChange?: (value: string) => void;
onRenameSave?: () => void;
onRenameCancel?: () => void;
/** Ref callback from useDroppable attach to folder header to make it a drop zone */
droppableRef?: (node: HTMLElement | null) => void;
/** Whether a draggable session is currently hovering over this folder */
isDropTarget?: boolean;
/** Create a new session scoped to this folder */
onNewSession?: () => void;
/** Create a new sub-folder inside this folder */
onNewSubFolder?: () => void;
/** Visual indent depth (0 = root folder, 1 = sub-folder) */
depth?: number;
/** Hide folder action buttons (rename/delete/new) */
hideActions?: boolean;
/** Whether folder belongs to archived section */
archivedBucket?: boolean;
}
const SessionFolderItemBase = <TSessionNode,>({
folder,
sessions,
subFolderItems,
isCollapsed,
onToggle,
onRename,
onDelete,
renderSessionNode,
groupDirectory,
projectId,
mobileVariant = false,
alwaysShowActions = mobileVariant,
isRenaming = false,
renameDraft = '',
onRenameDraftChange,
onRenameSave,
onRenameCancel,
droppableRef,
isDropTarget = false,
onNewSession,
onNewSubFolder,
depth = 0,
hideActions = false,
archivedBucket = false,
}: SessionFolderItemProps<TSessionNode>) => {
const { t } = useI18n();
const [localRenaming, setLocalRenaming] = React.useState(false);
const [localDraft, setLocalDraft] = React.useState('');
const inputRef = React.useRef<HTMLInputElement | null>(null);
const renaming = isRenaming || localRenaming;
const draft = isRenaming ? renameDraft : localDraft;
const handleStartRename = React.useCallback(() => {
setLocalDraft(folder.name);
setLocalRenaming(true);
}, [folder.name]);
const handleSaveRename = React.useCallback(() => {
const trimmed = draft.trim();
if (trimmed && trimmed !== folder.name) {
onRename(trimmed);
}
if (isRenaming && onRenameSave) {
onRenameSave();
}
setLocalRenaming(false);
setLocalDraft('');
}, [draft, folder.name, isRenaming, onRename, onRenameSave]);
const handleCancelRename = React.useCallback(() => {
if (isRenaming && onRenameCancel) {
onRenameCancel();
}
setLocalRenaming(false);
setLocalDraft('');
}, [isRenaming, onRenameCancel]);
const handleDraftChange = React.useCallback(
(value: string) => {
if (isRenaming && onRenameDraftChange) {
onRenameDraftChange(value);
} else {
setLocalDraft(value);
}
},
[isRenaming, onRenameDraftChange],
);
// Auto-focus rename when externally triggered
React.useEffect(() => {
if (!isRenaming) return;
const focusInput = () => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.select();
};
const frameId = requestAnimationFrame(focusInput);
const timeoutId = window.setTimeout(focusInput, 0);
return () => {
cancelAnimationFrame(frameId);
window.clearTimeout(timeoutId);
};
}, [isRenaming]);
const folderIconName = isCollapsed ? 'folder' : 'folder-open';
const isSubFolder = depth > 0;
return (
<div className={cn('oc-folder', isSubFolder && 'ml-3')}>
{/* Folder header also acts as a drop zone when droppableRef is provided */}
<div
ref={droppableRef}
className={cn(
'group/folder relative flex items-center justify-between gap-1.5 py-1 min-w-0 rounded-md',
'cursor-pointer',
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
)}
onClick={renaming ? undefined : onToggle}
role={renaming ? undefined : 'button'}
tabIndex={renaming ? undefined : 0}
onKeyDown={
renaming
? undefined
: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggle();
}
}
}
aria-label={isCollapsed
? t('sessions.sidebar.folderItem.expandAria', { folderName: folder.name })
: t('sessions.sidebar.folderItem.collapseAria', { folderName: folder.name })}
>
<div className={cn(
'min-w-0 flex items-center gap-1.5 pl-1.5 flex-1 transition-[padding]',
archivedBucket
? (alwaysShowActions ? 'pr-7' : 'group-hover/folder:pr-7 group-focus-within/folder:pr-7')
: '',
)}>
<Icon name={folderIconName} className={cn('h-3.5 w-3.5 flex-shrink-0', isDropTarget ? 'text-primary' : 'text-muted-foreground')} />
{renaming ? (
<form
className="flex min-w-0 flex-1 items-center gap-1"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
handleSaveRename();
}}
>
<input
ref={inputRef}
value={draft}
onChange={(event) => handleDraftChange(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
autoFocus
placeholder={t('sessions.sidebar.folderItem.namePlaceholder')}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
handleCancelRename();
return;
}
if (event.key === ' ' || event.key === 'Enter') {
event.stopPropagation();
}
}}
/>
<button
type="submit"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
<Icon name="check" className="size-4" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleCancelRename();
}}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Icon name="close" className="size-4" />
</button>
</form>
) : (
<div className="min-w-0 flex items-center gap-1.5 flex-1">
<span className={cn('typography-ui-label font-semibold truncate', isDropTarget ? 'text-primary' : 'text-muted-foreground')}>
{folder.name}
</span>
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
{sessions.length}
</span>
{isCollapsed ? (
<Icon name="arrow-right-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)}
</div>
)}
</div>
{/* Action buttons */}
{!renaming && (!hideActions || archivedBucket) ? (
<div className="flex items-center gap-0.5 px-0.5">
<div
className={cn(
'flex items-center gap-0.5 transition-opacity',
alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100',
archivedBucket && 'absolute right-0.5 top-1/2 z-10 -translate-y-1/2 px-0',
)}
>
{!archivedBucket && onNewSession ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onNewSession();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('sessions.sidebar.folderItem.newSessionAria', { folderName: folder.name })}
title={t('sessions.sidebar.project.actions.newSession')}
>
<Icon name="add" className="h-3.5 w-3.5" />
</button>
) : null}
{/* Only allow sub-folders at depth 0 (one level deep max) */}
{!archivedBucket && onNewSubFolder && depth === 0 ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onNewSubFolder();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('sessions.sidebar.folderItem.newSubfolderAria', { folderName: folder.name })}
title={t('sessions.sidebar.folderItem.newSubfolder')}
>
<Icon name="folder-add" className="h-3.5 w-3.5" />
</button>
) : null}
{!archivedBucket ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleStartRename();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('sessions.sidebar.folderItem.renameAria', { folderName: folder.name })}
>
<Icon name="pencil-ai" className="h-3.5 w-3.5" />
</button>
) : null}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={archivedBucket
? t('sessions.sidebar.folderItem.deleteArchivedInFolderAria', { folderName: folder.name })
: t('sessions.sidebar.folderItem.deleteFolderAria', { folderName: folder.name })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
</div>
</div>
) : null}
</div>
{/* Folder body */}
{!isCollapsed ? (
<div className="pb-1 pl-2">
{/* Sub-folders first */}
{subFolderItems}
{/* Then sessions */}
{sessions.length > 0 ? (
sessions.map((node) =>
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket),
)
) : !subFolderItems ? (
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
{t('sessions.sidebar.folderItem.emptyFolder')}
</div>
) : null}
</div>
) : null}
</div>
);
};
export const SessionFolderItem = React.memo(SessionFolderItemBase) as <TSessionNode>(
props: SessionFolderItemProps<TSessionNode>,
) => React.ReactElement;