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
@@ -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' }}
|
||||
|
||||
Reference in New Issue
Block a user