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,
+}