feat(ui): add dynamic window title and sprite-based project/file icons (#529)

* feat(ui): add dynamic titles and sprite-based project/file icons

* feat(files): add viewer syntax fallback and tab file icons

* fix(files): restore file viewer highlighting and add diff file icons

* feat(git): add file icons and async file-viewer syntax fallback

* fix(files): force codemirror token colors in file viewer

* feat(files): add shiki view mode for file viewer

* fix(files): force codemirror parse after programmatic content updates

* feat(files): support markdown frontmatter preview

* feat(chat): use pierre diffs for tool previews

* feat(chat): add configurable beautiful-mermaid rendering

* feat(perf): virtualize chat rendering and add react-scan toggle

* feat(build): enable React Compiler in Vite React apps

* fix(chat): reduce rerenders from tooltips and streamed activity

* fix(ui): make MessageList React Compiler safe

* chore(ui): batch commit remaining pending ui updates

* fix: polish chat and diff preview rendering

- Keep Mermaid action buttons fixed while diagram content scrolls
- Align Diff All Files headers and match Git-style path truncation
- Default chat tool diffs to unified view with lightweight indicators disabled

* fix: preserve file tree expansion and delay git action label collapse

* fix: refine project icon controls in settings

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
shekohex
2026-02-27 20:03:42 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d6b8f28e6f
commit 1d8ff97c95
1134 changed files with 14091 additions and 2005 deletions
+6
View File
@@ -489,6 +489,12 @@ export interface ProjectEntry {
path: string;
label?: string;
icon?: string | null;
iconImage?: {
mime: string;
updatedAt: number;
source: 'custom' | 'auto';
} | null;
iconBackground?: string | null;
color?: string | null;
addedAt?: number;
lastOpenedAt?: number;
@@ -225,10 +225,47 @@ export function createFlexokiCodeMirrorTheme(theme: Theme): Extension {
{ tag: [t.heading, t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], class: 'cm-keyword' },
]);
const directSyntax = HighlightStyle.define([
{ tag: [t.comment, t.docComment, t.meta, t.documentMeta], color: theme.colors.syntax.base.comment },
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.definitionKeyword, t.modifier], color: theme.colors.syntax.base.keyword },
{
tag: [
t.operatorKeyword,
t.operator,
t.derefOperator,
t.updateOperator,
t.definitionOperator,
t.typeOperator,
t.controlOperator,
t.logicOperator,
t.bitwiseOperator,
t.arithmeticOperator,
t.compareOperator,
],
color: theme.colors.syntax.base.operator,
},
{ tag: [t.string, t.regexp, t.attributeValue, t.special(t.string), t.monospace], color: theme.colors.syntax.base.string },
{ tag: t.escape, color: tokens.stringEscape || theme.colors.syntax.base.string },
{ tag: [t.number, t.bool, t.atom, t.null, t.self], color: theme.colors.syntax.base.number },
{ tag: [t.function(t.variableName), t.function(t.definition(t.variableName)), t.function(t.propertyName), t.standard(t.variableName), t.special(t.variableName)], color: theme.colors.syntax.base.function },
{ tag: t.definition(t.variableName), color: tokens.variableGlobal || theme.colors.syntax.base.variable },
{ tag: [t.variableName, t.local(t.variableName), t.constant(t.variableName), t.literal], color: theme.colors.syntax.base.variable },
{ tag: t.propertyName, color: tokens.variableProperty || theme.colors.syntax.base.variable },
{ tag: t.attributeName, color: tokens.variableOther || theme.colors.syntax.base.variable },
{ tag: [t.className, t.typeName, t.namespace], color: theme.colors.syntax.base.type },
{ tag: [t.tagName, t.labelName, t.annotation, t.macroName], color: tokens.tag || theme.colors.syntax.base.keyword },
{ tag: t.link, color: tokens.url || theme.colors.syntax.base.function, textDecoration: 'underline' },
{
tag: [t.punctuation, t.separator, t.bracket, t.paren, t.brace, t.squareBracket, t.angleBracket],
color: tokens.punctuation || theme.colors.syntax.base.comment,
},
]);
return [
ui,
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
syntaxHighlighting(classHighlighter),
syntaxHighlighting(syntax),
syntaxHighlighting(directSyntax),
];
}
@@ -109,6 +109,14 @@ function codeBlockLanguageResolver(info: string): Language | LanguageDescription
const normalizeFileName = (filePath: string) => filePath.split('/').pop()?.toLowerCase() ?? '';
const matchLanguageDescriptionForFile = (filePath: string): LanguageDescription | null => {
const filename = normalizeFileName(filePath);
if (!filename) {
return null;
}
return LanguageDescription.matchFilename(languages, filename);
};
const markdownHighlight = () => syntaxHighlighting(HighlightStyle.define([
{ tag: [t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], fontWeight: '600' },
{ tag: t.strong, fontWeight: '600' },
@@ -250,3 +258,20 @@ export function languageByExtension(filePath: string): Extension | null {
return null;
}
}
export async function loadLanguageByExtension(filePath: string): Promise<Extension | null> {
const description = matchLanguageDescriptionForFile(filePath);
if (!description) {
return null;
}
if (description.support) {
return description.support;
}
try {
return await description.load();
} catch {
return null;
}
}
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { FILE_TYPE_ICON_IDS } from '@/lib/fileTypeIconIds';
import spriteUrl from '../assets/icons/file-types/sprite.svg';
type ThemeVariant = 'light' | 'dark';
const fileNameIconMap: Record<string, string> = {
dockerfile: 'docker',
makefile: 'makefile',
gnumakefile: 'makefile',
'cmakelists.txt': 'cmake',
'package-lock.json': 'npm',
'yarn.lock': 'yarn',
'pnpm-lock.yaml': 'pnpm',
'bun.lock': 'bun',
'bun.lockb': 'bun',
'tsconfig.json': 'tsconfig',
'jsconfig.json': 'jsconfig',
'.gitignore': 'git',
'.gitattributes': 'git',
'.gitmodules': 'git',
'.editorconfig': 'editorconfig',
'.npmrc': 'npm',
'.yarnrc': 'yarn',
'.prettierrc': 'prettier',
'.eslintrc': 'eslint',
'.babelrc': 'babel',
};
const languageIconMap: Record<string, string> = {
javascript: 'javascript',
jsx: 'react',
typescript: 'typescript',
tsx: 'react_ts',
html: 'html',
handlebars: 'handlebars',
twig: 'twig',
liquid: 'liquid',
css: 'css',
scss: 'sass',
sass: 'sass',
less: 'less',
stylus: 'stylus',
json: 'json',
yaml: 'yaml',
toml: 'toml',
xml: 'xml',
ini: 'settings',
properties: 'settings',
bash: 'console',
powershell: 'powershell',
batch: 'console',
python: 'python',
ruby: 'ruby',
erb: 'ruby',
php: 'php',
java: 'java',
kotlin: 'kotlin',
scala: 'scala',
groovy: 'groovy',
c: 'c',
cpp: 'cpp',
objectivec: 'objective-c',
csharp: 'csharp',
fsharp: 'fsharp',
go: 'go',
rust: 'rust',
swift: 'swift',
dart: 'dart',
lua: 'lua',
perl: 'perl',
r: 'r',
julia: 'julia',
haskell: 'haskell',
elixir: 'elixir',
erlang: 'erlang',
clojure: 'clojure',
lisp: 'lisp',
scheme: 'scheme',
ocaml: 'ocaml',
reason: 'reason',
nim: 'nim',
zig: 'zig',
v: 'vlang',
crystal: 'crystal',
d: 'd',
sql: 'database',
graphql: 'graphql',
solidity: 'solidity',
nasm: 'assembly',
nix: 'nix',
hcl: 'terraform',
puppet: 'puppet',
latex: 'tex',
bibtex: 'bibliography',
markdown: 'markdown',
asciidoc: 'asciidoc',
text: 'document',
vim: 'vim',
makefile: 'makefile',
cmake: 'cmake',
diff: 'diff',
prisma: 'prisma',
protobuf: 'proto',
thrift: 'document',
wasm: 'webassembly',
glsl: 'shader',
hlsl: 'shader',
cg: 'shader',
apacheconf: 'settings',
nginx: 'nginx',
};
const extensionIconMap: Record<string, string> = {
yml: 'yaml',
mdx: 'mdx',
md: 'markdown',
lock: 'lock',
env: 'settings',
zip: 'zip',
tgz: 'zip',
gz: 'zip',
rar: 'zip',
'7z': 'zip',
png: 'image',
jpg: 'image',
jpeg: 'image',
gif: 'image',
svg: 'svg',
webp: 'image',
avif: 'image',
bmp: 'image',
ico: 'favicon',
mp3: 'audio',
wav: 'audio',
flac: 'audio',
ogg: 'audio',
mp4: 'video',
mov: 'video',
avi: 'video',
mkv: 'video',
webm: 'video',
pdf: 'pdf',
doc: 'word',
docx: 'word',
ppt: 'powerpoint',
pptx: 'powerpoint',
};
const fallbackIconName = 'document';
const selectVariantIconName = (iconName: string, variant: ThemeVariant): string => {
if (!iconName) {
return variant === 'light' ? `${fallbackIconName}_light` : fallbackIconName;
}
if (variant === 'light') {
const lightName = iconName.endsWith('_light') ? iconName : `${iconName}_light`;
return FILE_TYPE_ICON_IDS.has(lightName) ? lightName : iconName;
}
return iconName;
};
const isNonEmptyString = (value: unknown): value is string => {
return typeof value === 'string' && value.trim().length > 0;
};
const resolveIconName = (filePath: string, extension?: string): string => {
const normalizedPath = filePath.replace(/\\/g, '/');
const fileName = normalizedPath.split('/').pop()?.toLowerCase() || '';
if (fileNameIconMap[fileName]) {
return fileNameIconMap[fileName];
}
if (fileName.startsWith('.env')) {
return 'settings';
}
const language = getLanguageFromExtension(filePath);
if (language && languageIconMap[language]) {
return languageIconMap[language];
}
const normalizedExtension = isNonEmptyString(extension)
? extension.toLowerCase()
: fileName.includes('.')
? fileName.split('.').pop()?.toLowerCase() || ''
: '';
if (normalizedExtension && extensionIconMap[normalizedExtension]) {
return extensionIconMap[normalizedExtension];
}
if (normalizedExtension && FILE_TYPE_ICON_IDS.has(normalizedExtension)) {
return normalizedExtension;
}
return fallbackIconName;
};
export const getFileTypeIconHref = (
filePath: string,
options?: { extension?: string; themeVariant?: ThemeVariant }
): string => {
const resolvedBaseIconName = resolveIconName(filePath, options?.extension);
const baseIconName = FILE_TYPE_ICON_IDS.has(resolvedBaseIconName) ? resolvedBaseIconName : fallbackIconName;
const iconName = selectVariantIconName(baseIconName, options?.themeVariant || 'dark');
return `${spriteUrl}#${iconName}`;
};
export const getFileTypeIconUrl = getFileTypeIconHref;
+36
View File
@@ -115,6 +115,19 @@ const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs']
return result;
};
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
const normalizeIconBackground = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
};
const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefined => {
if (!Array.isArray(value)) {
return undefined;
@@ -150,9 +163,32 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
if (typeof candidate.icon === 'string' && candidate.icon.trim().length > 0) {
project.icon = candidate.icon.trim();
}
if (candidate.iconImage === null) {
(project as unknown as Record<string, unknown>).iconImage = null;
} else if (candidate.iconImage && typeof candidate.iconImage === 'object') {
const iconImage = candidate.iconImage as Record<string, unknown>;
const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : '';
const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt)
? Math.max(0, Math.round(iconImage.updatedAt))
: 0;
const source = iconImage.source === 'custom' || iconImage.source === 'auto'
? iconImage.source
: null;
if (mime && updatedAt > 0 && source) {
(project as unknown as Record<string, unknown>).iconImage = { mime, updatedAt, source };
}
}
if (typeof candidate.color === 'string' && candidate.color.trim().length > 0) {
project.color = candidate.color.trim();
}
if (candidate.iconBackground === null) {
(project as unknown as Record<string, unknown>).iconBackground = null;
} else {
const iconBackground = normalizeIconBackground(candidate.iconBackground);
if (iconBackground) {
(project as unknown as Record<string, unknown>).iconBackground = iconBackground;
}
}
if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) {
project.addedAt = candidate.addedAt;
}
+9
View File
@@ -20,6 +20,7 @@ import {
RiHeartLine,
type RemixiconComponentType,
} from '@remixicon/react';
import type { ProjectEntry } from '@/lib/api/types';
export const PROJECT_ICONS: Array<{ key: string; Icon: RemixiconComponentType; label: string }> = [
{ key: 'code', Icon: RiCodeBoxLine, label: 'Code' },
@@ -62,3 +63,11 @@ export const PROJECT_COLORS: Array<{ key: string; label: string; cssVar: string
export const PROJECT_COLOR_MAP: Record<string, string> = Object.fromEntries(
PROJECT_COLORS.map((c) => [c.key, c.cssVar])
);
export const getProjectIconImageUrl = (project: Pick<ProjectEntry, 'id' | 'iconImage'>): string | null => {
if (!project.iconImage || typeof project.iconImage.updatedAt !== 'number' || project.iconImage.updatedAt <= 0) {
return null;
}
return `/api/projects/${encodeURIComponent(project.id)}/icon?v=${project.iconImage.updatedAt}`;
};