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
+128 -73
View File
@@ -43,8 +43,8 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirectoryAccess } from '@/lib/desktop';
import { useLongPress } from '@/hooks/useLongPress';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -62,6 +62,75 @@ const NAV_RAIL_TEXT_FADE_MS = 180;
const PROJECT_TEXT_FADE_IN_DELAY_MS = 24;
const ACTION_TEXT_FADE_IN_DELAY_MS = 60;
type NavRailActionButtonProps = {
onClick: () => void;
ariaLabel: string;
icon: React.ReactNode;
tooltipLabel: string;
shortcutHint?: string;
showExpandedShortcutHint?: boolean;
buttonClassName: string;
showExpandedContent: boolean;
actionTextVisible: boolean;
};
const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
onClick,
ariaLabel,
icon,
tooltipLabel,
shortcutHint,
showExpandedShortcutHint = true,
buttonClassName,
showExpandedContent,
actionTextVisible,
}) => {
const btn = (
<button
type="button"
onClick={onClick}
className={buttonClassName}
aria-label={ariaLabel}
>
{showExpandedContent && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg bg-transparent transition-colors group-hover:bg-[var(--interactive-hover)]/50"
/>
)}
<span className="relative z-10 flex size-8 basis-8 shrink-0 grow-0 items-center justify-center">
{icon}
</span>
<span
aria-hidden={!actionTextVisible}
className={cn(
'relative z-10 min-w-0 flex items-center justify-between gap-1 overflow-hidden transition-opacity duration-[180ms] ease-in-out',
showExpandedContent ? 'flex-1' : 'w-0 flex-none',
actionTextVisible ? 'opacity-100' : 'opacity-0',
)}
>
<span className="truncate text-left text-[13px]">{tooltipLabel}</span>
{shortcutHint && showExpandedShortcutHint && (
<span className="shrink-0 text-[10px] text-[var(--surface-mutedForeground)] opacity-70">
{shortcutHint}
</span>
)}
</span>
</button>
);
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
{!showExpandedContent && (
<TooltipContent side="right" sideOffset={8}>
<p>{shortcutHint ? `${tooltipLabel} (${shortcutHint})` : tooltipLabel}</p>
</TooltipContent>
)}
</Tooltip>
);
};
/** Tinted background for project tiles — uses project color at low opacity, or neutral fallback */
const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({
colorVar,
@@ -143,11 +212,17 @@ const ProjectTile: React.FC<{
onClose: () => void;
}> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => {
const [menuOpen, setMenuOpen] = React.useState(false);
const [iconImageFailed, setIconImageFailed] = React.useState(false);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectIconImageUrl = !iconImageFailed ? getProjectIconImageUrl(project) : null;
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
const showStreamingDots = hasStreaming;
const showAttentionDots = !hasStreaming && hasUnread;
React.useEffect(() => {
setIconImageFailed(false);
}, [project.id, project.iconImage?.updatedAt]);
const longPressHandlers = useLongPress({
onLongPress: () => setMenuOpen(true),
onTap: onClick,
@@ -157,7 +232,20 @@ const ProjectTile: React.FC<{
<TileBackground colorVar={projectColorVar}>
<span className="relative h-full w-full leading-none">
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
{ProjectIcon ? (
{projectIconImageUrl ? (
<span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img
src={projectIconImageUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setIconImageFailed(true)}
/>
</span>
) : ProjectIcon ? (
<ProjectIcon
className="h-4 w-4 shrink-0"
style={projectColorVar ? { color: projectColorVar } : { color: 'var(--surface-foreground)' }}
@@ -392,6 +480,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
path: string;
icon?: string | null;
color?: string | null;
iconBackground?: string | null;
} | null>(null);
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
@@ -480,8 +569,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
sessionEvents.requestDirectoryDialog();
return;
}
import('@/lib/desktop')
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
requestDirectoryAccess('')
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
@@ -504,19 +592,20 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
(projectId: string) => {
const project = projects.find((p) => p.id === projectId);
if (!project) return;
setEditingProject({
id: project.id,
name: formatLabel(project),
path: project.path,
icon: project.icon,
color: project.color,
});
setEditingProject({
id: project.id,
name: formatLabel(project),
path: project.path,
icon: project.icon,
color: project.color,
iconBackground: project.iconBackground,
});
},
[projects, formatLabel],
);
const handleSaveProjectEdit = React.useCallback(
(data: { label: string; icon: string | null; color: string | null }) => {
(data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
if (!editingProject) return;
updateProjectMeta(editingProject.id, data);
setEditingProject(null);
@@ -581,60 +670,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
const navRailActionIconClass = 'h-4.5 w-4.5 shrink-0';
const ActionButton: React.FC<{
onClick: () => void;
ariaLabel: string;
icon: React.ReactNode;
tooltipLabel: string;
shortcutHint?: string;
showExpandedShortcutHint?: boolean;
}> = ({ onClick, ariaLabel, icon, tooltipLabel, shortcutHint, showExpandedShortcutHint = true }) => {
const btn = (
<button
type="button"
onClick={onClick}
className={navRailActionButtonClass}
aria-label={ariaLabel}
>
{showExpandedContent && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg bg-transparent transition-colors group-hover:bg-[var(--interactive-hover)]/50"
/>
)}
<span className="relative z-10 flex size-8 basis-8 shrink-0 grow-0 items-center justify-center">
{icon}
</span>
<span
aria-hidden={!actionTextVisible}
className={cn(
'relative z-10 min-w-0 flex items-center justify-between gap-1 overflow-hidden transition-opacity duration-[180ms] ease-in-out',
showExpandedContent ? 'flex-1' : 'w-0 flex-none',
actionTextVisible ? 'opacity-100' : 'opacity-0',
)}
>
<span className="truncate text-left text-[13px]">{tooltipLabel}</span>
{shortcutHint && showExpandedShortcutHint && (
<span className="shrink-0 text-[10px] text-[var(--surface-mutedForeground)] opacity-70">
{shortcutHint}
</span>
)}
</span>
</button>
);
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
{!showExpandedContent && (
<TooltipContent side="right" sideOffset={8}>
<p>{shortcutHint ? `${tooltipLabel} (${shortcutHint})` : tooltipLabel}</p>
</TooltipContent>
)}
</Tooltip>
);
};
return (
<>
<nav
@@ -687,11 +722,14 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
{/* Add project button */}
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
<ActionButton
<NavRailActionButton
onClick={handleAddProject}
ariaLabel="Add project"
icon={<RiFolderAddLine className={navRailActionIconClass} />}
tooltipLabel="Add project"
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
</div>
</div>
@@ -702,46 +740,58 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
showExpandedContent ? 'items-stretch px-1' : 'items-center',
)}>
{(updateAvailable || updateDownloaded) && (
<ActionButton
<NavRailActionButton
onClick={() => setUpdateDialogOpen(true)}
ariaLabel="Update available"
icon={<RiDownloadLine className={navRailActionIconClass} />}
tooltipLabel="Update available"
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
<ActionButton
<NavRailActionButton
onClick={() => setAboutDialogOpen(true)}
ariaLabel="About"
icon={<RiInformationLine className={navRailActionIconClass} />}
tooltipLabel="About OpenChamber"
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
{!mobile && (
<ActionButton
<NavRailActionButton
onClick={toggleHelpDialog}
ariaLabel="Keyboard shortcuts"
icon={<RiQuestionLine className={navRailActionIconClass} />}
tooltipLabel="Shortcuts"
shortcutHint={shortcutLabel('open_help')}
showExpandedShortcutHint={false}
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
<ActionButton
<NavRailActionButton
onClick={() => setSettingsDialogOpen(true)}
ariaLabel="Settings"
icon={<RiSettings3Line className={navRailActionIconClass} />}
tooltipLabel="Settings"
shortcutHint={shortcutLabel('open_settings')}
showExpandedShortcutHint={false}
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
{/* Toggle expand/collapse (desktop only) */}
{!mobile && (
<ActionButton
<NavRailActionButton
onClick={toggleNavRail}
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
icon={expanded
@@ -751,6 +801,9 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
tooltipLabel={expanded ? 'Collapse' : 'Expand'}
shortcutHint={shortcutLabel('toggle_nav_rail')}
showExpandedShortcutHint={false}
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
</div>
@@ -763,10 +816,12 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
onOpenChange={(open) => {
if (!open) setEditingProject(null);
}}
projectId={editingProject.id}
projectName={editingProject.name}
projectPath={editingProject.path}
initialIcon={editingProject.icon}
initialColor={editingProject.color}
initialIconBackground={editingProject.iconBackground}
onSave={handleSaveProjectEdit}
/>
)}
@@ -8,48 +8,148 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useProjectsStore } from '@/stores/useProjectsStore';
interface ProjectEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
projectName: string;
projectPath: string;
initialIcon?: string | null;
initialColor?: string | null;
onSave: (data: { label: string; icon: string | null; color: string | null }) => void;
initialIconBackground?: string | null;
onSave: (data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => void;
}
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
const normalizeIconBackground = (value: string | null): string | null => {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
};
export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
open,
onOpenChange,
projectId,
projectName,
projectPath,
initialIcon = null,
initialColor = null,
initialIconBackground = null,
onSave,
}) => {
const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon);
const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon);
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
const currentIconImage = useProjectsStore((state) => state.projects.find((project) => project.id === projectId)?.iconImage ?? null);
const [name, setName] = React.useState(projectName);
const [icon, setIcon] = React.useState<string | null>(initialIcon);
const [color, setColor] = React.useState<string | null>(initialColor);
const [iconBackground, setIconBackground] = React.useState<string | null>(normalizeIconBackground(initialIconBackground));
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false);
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
if (open) {
setName(projectName);
setIcon(initialIcon);
setColor(initialColor);
setIconBackground(normalizeIconBackground(initialIconBackground));
}
}, [open, projectName, initialIcon, initialColor]);
}, [open, projectName, initialIcon, initialColor, initialIconBackground]);
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) return;
onSave({ label: trimmed, icon, color });
onSave({ label: trimmed, icon, color, iconBackground: normalizeIconBackground(iconBackground) });
onOpenChange(false);
};
const currentColorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
const hasImageIcon = Boolean(currentIconImage);
const hasCustomIcon = currentIconImage?.source === 'custom';
const iconPreviewUrl = hasImageIcon && !previewImageFailed
? getProjectIconImageUrl({ id: projectId, iconImage: currentIconImage ?? null })
: null;
React.useEffect(() => {
setPreviewImageFailed(false);
}, [projectId, currentIconImage?.updatedAt]);
const handleUploadIcon = React.useCallback(async (file: File | null) => {
if (!projectId || !file || isUploadingIcon) {
return;
}
setIsUploadingIcon(true);
void uploadProjectIcon(projectId, file)
.then((result) => {
if (!result.ok) {
toast.error(result.error || 'Failed to upload project icon');
return;
}
toast.success('Project icon updated');
})
.finally(() => {
setIsUploadingIcon(false);
});
}, [isUploadingIcon, projectId, uploadProjectIcon]);
const handleRemoveCustomIcon = React.useCallback(async () => {
if (!projectId || !hasCustomIcon || isRemovingCustomIcon) {
return;
}
setIsRemovingCustomIcon(true);
void removeProjectIcon(projectId)
.then((result) => {
if (!result.ok) {
toast.error(result.error || 'Failed to remove project icon');
return;
}
toast.success('Custom project icon removed');
})
.finally(() => {
setIsRemovingCustomIcon(false);
});
}, [hasCustomIcon, isRemovingCustomIcon, projectId, removeProjectIcon]);
const handleDiscoverIcon = React.useCallback(async () => {
if (!projectId || isDiscoveringIcon) {
return;
}
setIsDiscoveringIcon(true);
void discoverProjectIcon(projectId)
.then((result) => {
if (!result.ok) {
toast.error(result.error || 'Failed to discover project icon');
return;
}
if (result.skipped) {
toast.success('Custom icon already set for this project');
return;
}
toast.success('Project icon discovered');
})
.finally(() => {
setIsDiscoveringIcon(false);
});
}, [discoverProjectIcon, isDiscoveringIcon, projectId]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -124,6 +224,17 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
<label className="typography-ui-label font-medium text-foreground">
Icon
</label>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,.png,.jpg,.jpeg,.svg"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
void handleUploadIcon(file);
event.currentTarget.value = '';
}}
/>
<div className="flex gap-2 flex-wrap">
{/* No icon option */}
<button
@@ -162,7 +273,69 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
);
})}
</div>
{hasImageIcon && iconPreviewUrl && (
<div className="flex items-center gap-2 pt-1">
<span className="typography-meta text-muted-foreground">Preview</span>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-[var(--surface-elevated)] p-1">
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
>
<img
src={iconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
</span>
</span>
</div>
)}
<div className="flex flex-wrap items-center gap-2 pt-1">
{!hasCustomIcon && (
<>
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={isUploadingIcon}>
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
</Button>
<Button size="sm" variant="outline" onClick={() => void handleDiscoverIcon()} disabled={isDiscoveringIcon}>
{isDiscoveringIcon ? 'Discovering...' : 'Discover Favicon'}
</Button>
</>
)}
{hasCustomIcon && (
<Button size="sm" variant="outline" onClick={() => void handleRemoveCustomIcon()} disabled={isRemovingCustomIcon}>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Custom Icon'}
</Button>
)}
</div>
</div>
{hasImageIcon && (
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon Background
</label>
<div className="flex flex-wrap items-center gap-2">
<input
type="color"
value={iconBackground ?? '#000000'}
onChange={(event) => setIconBackground(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent p-1"
aria-label="Project icon background color"
/>
<Input
value={iconBackground ?? ''}
onChange={(event) => setIconBackground(event.target.value)}
placeholder="#000000"
className="h-8 w-[8.5rem]"
/>
<Button size="sm" variant="outline" onClick={() => setIconBackground(null)}>
Clear
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
@@ -1,13 +1,10 @@
import React from 'react';
import {
RiCloseLine,
RiCodeLine,
RiDeleteBinLine,
RiEditLine,
RiFileAddLine,
RiFileCopyLine,
RiFileImageLine,
RiFileTextLine,
RiFolder3Fill,
RiFolderAddLine,
RiFolderOpenFill,
@@ -49,6 +46,7 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { copyTextToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
type FileNode = {
name: string;
@@ -98,64 +96,8 @@ const shouldIgnorePath = (path: string): boolean => {
return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/');
};
// --- File icons (matching FilesView) ---
const CODE_EXTENSIONS = new Set([
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts',
'html', 'htm', 'xhtml', 'css', 'scss', 'sass', 'less', 'styl', 'stylus',
'vue', 'svelte', 'astro',
'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd',
'py', 'pyw', 'pyx', 'pxd', 'pxi',
'rb', 'erb', 'rake', 'gemspec',
'php', 'phtml', 'php3', 'php4', 'php5', 'phps',
'java', 'kt', 'kts', 'scala', 'sc', 'groovy', 'gradle',
'c', 'h', 'cpp', 'cc', 'cxx', 'hpp', 'hxx', 'hh', 'm', 'mm',
'cs', 'fs', 'fsx', 'fsi',
'go', 'rs', 'swift', 'dart', 'lua',
'pl', 'pm', 'pod', 'r', 'R', 'rmd', 'jl',
'hs', 'lhs', 'ex', 'exs', 'erl', 'hrl',
'clj', 'cljs', 'cljc', 'edn',
'lisp', 'cl', 'el', 'scm', 'ss', 'rkt',
'ml', 'mli', 're', 'rei', 'nim', 'zig', 'v', 'cr',
'sql', 'psql', 'plsql', 'graphql', 'gql', 'sol',
'asm', 's', 'S', 'mk', 'nix', 'tf', 'tfvars', 'pp', 'ansible',
]);
const DATA_EXTENSIONS = new Set([
'json', 'jsonc', 'json5', 'jsonl', 'ndjson', 'geojson',
'yaml', 'yml', 'toml',
'xml', 'xsl', 'xslt', 'xsd', 'dtd', 'plist',
'ini', 'cfg', 'conf', 'config', 'env', 'properties',
'csv', 'tsv', 'lock',
]);
const IMAGE_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'icns',
'bmp', 'tiff', 'tif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef',
'heic', 'heif', 'avif', 'jxl',
]);
const DOCUMENT_EXTENSIONS = new Set([
'md', 'mdx', 'markdown', 'mdown', 'mkd',
'txt', 'text', 'rtf', 'doc', 'docx', 'odt', 'pdf',
'rst', 'adoc', 'asciidoc', 'org', 'tex', 'latex', 'bib',
]);
const getFileIcon = (extension?: string): React.ReactNode => {
const ext = extension?.toLowerCase();
if (ext && CODE_EXTENSIONS.has(ext)) {
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-[var(--status-info)]" />;
}
if (ext && DATA_EXTENSIONS.has(ext)) {
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-[var(--status-warning)]" />;
}
if (ext && IMAGE_EXTENSIONS.has(ext)) {
return <RiFileImageLine className="h-4 w-4 flex-shrink-0 text-[var(--status-success)]" />;
}
if (ext && DOCUMENT_EXTENSIONS.has(ext)) {
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
}
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
const getFileIcon = (filePath: string, extension?: string): React.ReactNode => {
return <FileTypeIcon filePath={filePath} extension={extension} />;
};
// --- Git status indicators (matching FilesView) ---
@@ -254,7 +196,7 @@ const FileRow: React.FC<FileRowProps> = ({
<RiFolder3Fill className="h-4 w-4 flex-shrink-0 text-primary/60" />
)
) : (
getFileIcon(node.extension)
getFileIcon(node.path, node.extension)
)}
<span className="min-w-0 flex-1 truncate typography-meta" title={node.path}>
{node.name}
@@ -430,39 +372,37 @@ export const SidebarFilesTree: React.FC = () => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.add(normalizedDir);
try {
const respectGitignore = !showGitignored;
let entries: Array<{ name: string; path: string; isDirectory: boolean }>;
if (runtime.isDesktop) {
const result = await files.listDirectory(normalizedDir, { respectGitignore });
entries = result.entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
}));
} else {
const result = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore });
entries = result.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
}));
}
const respectGitignore = !showGitignored;
const listPromise = runtime.isDesktop
? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
})))
: opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore }).then((result) => result.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
})));
const mapped = mapDirectoryEntries(normalizedDir, entries);
await listPromise
.then((entries) => {
const mapped = mapDirectoryEntries(normalizedDir, entries);
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir);
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
} catch {
setChildrenByDir((prev) => ({
...prev,
[normalizedDir]: prev[normalizedDir] ?? [],
}));
} finally {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
}
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir);
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
})
.catch(() => {
setChildrenByDir((prev) => ({
...prev,
[normalizedDir]: prev[normalizedDir] ?? [],
}));
})
.finally(() => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
});
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
const refreshRoot = React.useCallback(async () => {
@@ -484,6 +424,18 @@ export const SidebarFilesTree: React.FC = () => {
void loadDirectory(root);
}, [loadDirectory, root, showHidden, showGitignored]);
React.useEffect(() => {
if (!root || expandedPaths.length === 0) return;
for (const expandedPath of expandedPaths) {
const normalized = normalizePath(expandedPath);
if (!normalized || normalized === root) continue;
if (!normalized.startsWith(`${root}/`)) continue;
if (loadedDirsRef.current.has(normalized) || inFlightDirsRef.current.has(normalized)) continue;
void loadDirectory(normalized);
}
}, [expandedPaths, loadDirectory, root]);
// --- Fuzzy search scoring (matching FilesView) ---
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
@@ -656,39 +608,81 @@ export const SidebarFilesTree: React.FC = () => {
if (!dialogData || !activeDialog) return;
setIsDialogSubmitting(true);
try {
if (activeDialog === 'createFile') {
if (!dialogInputValue.trim()) throw new Error('Filename is required');
const parentPath = dialogData.path;
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
const done = () => setIsDialogSubmitting(false);
const closeDialog = () => setActiveDialog(null);
if (!files.writeFile) throw new Error('Write not supported');
const result = await files.writeFile(newPath, '');
if (result.success) {
toast.success('File created');
await refreshRoot();
}
} else if (activeDialog === 'createFolder') {
if (!dialogInputValue.trim()) throw new Error('Folder name is required');
const parentPath = dialogData.path;
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
if (activeDialog === 'createFile') {
if (!dialogInputValue.trim()) {
toast.error('Filename is required');
done();
return;
}
if (!files.writeFile) {
toast.error('Write not supported');
done();
return;
}
const result = await files.createDirectory(newPath);
if (result.success) {
toast.success('Folder created');
await refreshRoot();
}
} else if (activeDialog === 'rename') {
if (!dialogInputValue.trim()) throw new Error('Name is required');
const oldPath = dialogData.path;
const parentDir = oldPath.split('/').slice(0, -1).join('/');
const prefix = parentDir ? `${parentDir}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
const parentPath = dialogData.path;
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
if (files.rename) {
const result = await files.rename(oldPath, newPath);
await files.writeFile(newPath, '')
.then(async (result) => {
if (result.success) {
toast.success('File created');
await refreshRoot();
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.finally(done);
return;
}
if (activeDialog === 'createFolder') {
if (!dialogInputValue.trim()) {
toast.error('Folder name is required');
done();
return;
}
const parentPath = dialogData.path;
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
await files.createDirectory(newPath)
.then(async (result) => {
if (result.success) {
toast.success('Folder created');
await refreshRoot();
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.finally(done);
return;
}
if (activeDialog === 'rename') {
if (!dialogInputValue.trim()) {
toast.error('Name is required');
done();
return;
}
if (!files.rename) {
toast.error('Rename not supported');
done();
return;
}
const oldPath = dialogData.path;
const parentDir = oldPath.split('/').slice(0, -1).join('/');
const prefix = parentDir ? `${parentDir}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
await files.rename(oldPath, newPath)
.then(async (result) => {
if (result.success) {
toast.success('Renamed successfully');
await refreshRoot();
@@ -699,12 +693,22 @@ export const SidebarFilesTree: React.FC = () => {
setSelectedPath(root, null);
}
}
} else {
toast.error('Rename not supported');
}
} else if (activeDialog === 'delete') {
if (files.delete) {
const result = await files.delete(dialogData.path);
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.finally(done);
return;
}
if (activeDialog === 'delete') {
if (!files.delete) {
toast.error('Delete not supported');
done();
return;
}
await files.delete(dialogData.path)
.then(async (result) => {
if (result.success) {
toast.success('Deleted successfully');
await refreshRoot();
@@ -715,21 +719,19 @@ export const SidebarFilesTree: React.FC = () => {
setSelectedPath(root, null);
}
}
} else {
toast.error('Delete not supported');
}
}
setActiveDialog(null);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Operation failed');
} finally {
setIsDialogSubmitting(false);
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.finally(done);
return;
}
done();
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath]);
// --- Tree rendering (matching FilesView with indent guides) ---
const renderTree = React.useCallback((dirPath: string, depth: number): React.ReactNode => {
function renderTree(dirPath: string, depth: number): React.ReactNode {
const nodes = childrenByDir[dirPath] ?? [];
return nodes.map((node, index) => {
@@ -770,7 +772,7 @@ export const SidebarFilesTree: React.FC = () => {
</li>
);
});
}, [childrenByDir, expandedPaths, handleOpenFile, selectedPath, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, canReveal, contextMenuPath, getFileStatus, getFolderBadge, handleRevealPath]);
}
const hasTree = Boolean(root && childrenByDir[root]);
@@ -848,7 +850,7 @@ export const SidebarFilesTree: React.FC = () => {
)}
title={node.path}
>
{getFileIcon(node.extension)}
{getFileIcon(node.path, node.extension)}
<span
className="min-w-0 flex-1 truncate typography-meta"
style={{ direction: 'rtl', textAlign: 'left' }}