diff --git a/src/main/i18n/locales/en_US/ui.json b/src/main/i18n/locales/en_US/ui.json index babc4f1b..81e80d89 100644 --- a/src/main/i18n/locales/en_US/ui.json +++ b/src/main/i18n/locales/en_US/ui.json @@ -110,7 +110,16 @@ } }, "folder": { - "untitled": "Untitled folder" + "untitled": "Untitled folder", + "iconPicker": { + "searchPlaceholder": "Search icons...", + "emptyResults": "No icons found", + "filters": { + "all": "All", + "material": "Material", + "lucide": "Lucide" + } + } }, "button": { "back": "Back", diff --git a/src/main/i18n/locales/ru_RU/ui.json b/src/main/i18n/locales/ru_RU/ui.json index baec2216..533ad96c 100644 --- a/src/main/i18n/locales/ru_RU/ui.json +++ b/src/main/i18n/locales/ru_RU/ui.json @@ -110,7 +110,16 @@ } }, "folder": { - "untitled": "Папка без названия" + "untitled": "Папка без названия", + "iconPicker": { + "searchPlaceholder": "Поиск иконок...", + "emptyResults": "Иконки не найдены", + "filters": { + "all": "Все", + "material": "Material", + "lucide": "Lucide" + } + } }, "button": { "back": "Назад", diff --git a/src/renderer/components/sidebar/folders/custom-icons/CustomIcons.vue b/src/renderer/components/sidebar/folders/custom-icons/CustomIcons.vue index 85a91b3e..c2f51ee3 100644 --- a/src/renderer/components/sidebar/folders/custom-icons/CustomIcons.vue +++ b/src/renderer/components/sidebar/folders/custom-icons/CustomIcons.vue @@ -1,7 +1,12 @@ diff --git a/src/renderer/components/ui/folder-icon/__tests__/icons.test.ts b/src/renderer/components/ui/folder-icon/__tests__/icons.test.ts new file mode 100644 index 00000000..d95ed8a4 --- /dev/null +++ b/src/renderer/components/ui/folder-icon/__tests__/icons.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { + createFolderIconValue, + getFilteredFolderIcons, + groupFolderIcons, + materialIconInnerSvgClass, + parseFolderIconValue, + resolveFolderIcon, +} from '../icons' + +describe('parseFolderIconValue', () => { + it('treats legacy values without a prefix as material icons', () => { + expect(parseFolderIconValue('typescript')).toEqual({ + name: 'typescript', + source: 'material', + }) + }) + + it('parses prefixed material icons', () => { + expect(parseFolderIconValue('material:typescript')).toEqual({ + name: 'typescript', + source: 'material', + }) + }) + + it('parses prefixed lucide icons', () => { + expect(parseFolderIconValue('lucide:folder-open')).toEqual({ + name: 'folder-open', + source: 'lucide', + }) + }) + + it('returns null for unsupported values', () => { + expect(parseFolderIconValue('unknown:folder')).toBeNull() + expect(parseFolderIconValue('lucide:')).toBeNull() + expect(parseFolderIconValue(null)).toBeNull() + }) +}) + +describe('createFolderIconValue', () => { + it('serializes material icons with a source prefix', () => { + expect(createFolderIconValue('material', 'typescript')).toBe( + 'material:typescript', + ) + }) + + it('serializes lucide icons with a source prefix', () => { + expect(createFolderIconValue('lucide', 'folder-open')).toBe( + 'lucide:folder-open', + ) + }) +}) + +describe('resolveFolderIcon', () => { + it('resolves legacy material values', () => { + expect(resolveFolderIcon('typescript')).toMatchObject({ + name: 'typescript', + source: 'material', + value: 'material:typescript', + }) + }) + + it('resolves lucide icons', () => { + const icon = resolveFolderIcon('lucide:folder-open') + + expect(icon).toMatchObject({ + name: 'folder-open', + source: 'lucide', + value: 'lucide:folder-open', + }) + expect(icon?.component).toBeTypeOf('function') + }) + + it('returns null when an icon cannot be found', () => { + expect(resolveFolderIcon('lucide:not-a-real-icon')).toBeNull() + }) +}) + +describe('folder icon filtering', () => { + it('returns icons from both sources for the all filter', () => { + const groupedIcons = groupFolderIcons(getFilteredFolderIcons('', 'all')) + + expect(groupedIcons.map(group => group.source)).toEqual([ + 'material', + 'lucide', + ]) + expect(groupedIcons[0]?.items.length).toBeGreaterThan(0) + expect(groupedIcons[1]?.items.length).toBeGreaterThan(0) + }) + + it('returns only material icons for the material filter', () => { + const groupedIcons = groupFolderIcons( + getFilteredFolderIcons('', 'material'), + ) + + expect(groupedIcons).toHaveLength(1) + expect(groupedIcons[0]?.source).toBe('material') + }) + + it('returns only lucide icons for the lucide filter', () => { + const groupedIcons = groupFolderIcons(getFilteredFolderIcons('', 'lucide')) + + expect(groupedIcons).toHaveLength(1) + expect(groupedIcons[0]?.source).toBe('lucide') + }) + + it('searches inside the active icon source', () => { + const lucideIcons = getFilteredFolderIcons('folder-open', 'lucide') + const materialIcons = getFilteredFolderIcons('folder-open', 'material') + + expect(lucideIcons.some(icon => icon.name === 'folder-open')).toBe(true) + expect(materialIcons).toEqual([]) + }) +}) + +describe('material icon rendering', () => { + it('includes sizing classes for nested svg content', () => { + expect(materialIconInnerSvgClass).toContain('[&_svg]:size-full') + expect(materialIconInnerSvgClass).toContain('[&_svg]:block') + }) +}) diff --git a/src/renderer/components/ui/folder-icon/icons.ts b/src/renderer/components/ui/folder-icon/icons.ts index dd28e3d9..69d4e363 100644 --- a/src/renderer/components/ui/folder-icon/icons.ts +++ b/src/renderer/components/ui/folder-icon/icons.ts @@ -1,20 +1,210 @@ +import type { Component } from 'vue' +import { icons as lucideIcons } from 'lucide-vue-next' + const files = import.meta.glob('@/assets/svg/icons/**.svg', { - as: 'raw', eager: true, + import: 'default', + query: '?raw', }) const re = /\/([^/]+)\.svg$/ +const materialIconInnerSvgClass + = '[&_svg]:block [&_svg]:size-full overflow-hidden' + +type FolderIconSource = 'material' | 'lucide' +type FolderIconFilter = 'all' | FolderIconSource + +interface ParsedFolderIconValue { + name: string + source: FolderIconSource +} + +interface FolderIconOption { + component?: Component + name: string + searchValue: string + source: FolderIconSource + svg?: string + value: string +} + +interface FolderIconGroup { + items: FolderIconOption[] + source: FolderIconSource +} + +const iconSources: FolderIconSource[] = ['material', 'lucide'] + +function toKebabCase(value: string) { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') + .toLowerCase() +} + +function createFolderIconValue(source: FolderIconSource, name: string) { + return `${source}:${name}` +} + +const materialIconsSet: Record = {} + +const materialIcons: FolderIconOption[] = Object.entries(files) + .flatMap(([path, raw]) => { + const name = path.match(re)?.[1] + + if (!name) + return [] + + const svg = raw as string -const iconsSet: Record = {} + materialIconsSet[name] = svg -const icons = Object.entries(files).map(([k, v]) => { - const name = k.match(re)?.[1] - if (name) { - iconsSet[name] = v as string + return [ + { + name, + searchValue: name.toLowerCase(), + source: 'material' as const, + svg, + value: createFolderIconValue('material', name), + }, + ] + }) + .sort((left, right) => left.name.localeCompare(right.name)) + +const lucideIconsSet: Record = {} + +const allLucideIcons: FolderIconOption[] = Object.entries(lucideIcons) + .flatMap(([componentName, component]) => { + const name = toKebabCase(componentName) + + if (lucideIconsSet[name]) + return [] + + lucideIconsSet[name] = component as Component + + return [ + { + component: component as Component, + name, + searchValue: `${name} ${componentName.toLowerCase()}`, + source: 'lucide' as const, + value: createFolderIconValue('lucide', name), + }, + ] + }) + .sort((left, right) => left.name.localeCompare(right.name)) + +const allFolderIcons: FolderIconOption[] = [ + ...materialIcons, + ...allLucideIcons, +] + +function parseFolderIconValue( + value?: string | null, +): ParsedFolderIconValue | null { + if (!value) + return null + + const [rawSource, ...rest] = value.split(':') + + if (rest.length === 0) { + return { + name: value, + source: 'material', + } } + + const name = rest.join(':').trim() + + if (!name) + return null + + if (rawSource !== 'material' && rawSource !== 'lucide') + return null + return { name, - source: v, + source: rawSource, } -}) +} + +function resolveFolderIcon(value?: string | null): FolderIconOption | null { + const parsedValue = parseFolderIconValue(value) + + if (!parsedValue) + return null + + if (parsedValue.source === 'material') { + const svg = materialIconsSet[parsedValue.name] + + if (!svg) + return null + + return { + name: parsedValue.name, + searchValue: parsedValue.name.toLowerCase(), + source: parsedValue.source, + svg, + value: createFolderIconValue(parsedValue.source, parsedValue.name), + } + } + + const component = lucideIconsSet[parsedValue.name] + + if (!component) + return null + + return { + component, + name: parsedValue.name, + searchValue: parsedValue.name, + source: parsedValue.source, + value: createFolderIconValue(parsedValue.source, parsedValue.name), + } +} + +function getFilteredFolderIcons(search = '', filter: FolderIconFilter = 'all') { + const normalizedSearch = search.trim().toLowerCase() + + return allFolderIcons.filter((icon) => { + if (filter !== 'all' && icon.source !== filter) + return false + + if (!normalizedSearch) + return true + + return icon.searchValue.includes(normalizedSearch) + }) +} + +function groupFolderIcons(icons: FolderIconOption[]): FolderIconGroup[] { + return iconSources.flatMap((source) => { + const items = icons.filter(icon => icon.source === source) + + if (items.length === 0) + return [] + + return [{ items, source }] + }) +} + +export { + allFolderIcons, + allLucideIcons, + createFolderIconValue, + getFilteredFolderIcons, + groupFolderIcons, + materialIcons as icons, + materialIconsSet as iconsSet, + materialIconInnerSvgClass, + materialIcons, + materialIconsSet, + parseFolderIconValue, + resolveFolderIcon, +} -export { icons, iconsSet } +export type { + FolderIconFilter, + FolderIconGroup, + FolderIconOption, + FolderIconSource, +}