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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d6b8f28e6f
commit
1d8ff97c95
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user