Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
getGitRoot,
refreshDefaultBranch,
} from "../workspaces/utils/git";
import { assignRandomColor } from "./utils/colors";
import { getDefaultProjectColor } from "./utils/colors";
import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github";

type Project = SelectProject;
Expand Down Expand Up @@ -70,7 +70,7 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project {
.values({
mainRepoPath,
name,
color: assignRandomColor(),
color: getDefaultProjectColor(),
defaultBranch,
})
.returning()
Expand Down Expand Up @@ -481,7 +481,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.values({
mainRepoPath: clonePath,
name,
color: assignRandomColor(),
color: getDefaultProjectColor(),
defaultBranch,
})
.returning()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors";
import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors";

export function assignRandomColor(): string {
return PROJECT_COLOR_VALUES[
Math.floor(Math.random() * PROJECT_COLOR_VALUES.length)
];
/**
* Returns the default color for new projects.
* Projects start with no custom color (gray border).
*/
export function getDefaultProjectColor(): string {
return PROJECT_COLOR_DEFAULT;
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { assignRandomColor } from "./colors";
export { getDefaultProjectColor } from "./colors";
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import {
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@superset/ui/context-menu";
import {
Expand All @@ -15,15 +18,21 @@ import { toast } from "@superset/ui/sonner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
import { cn } from "@superset/ui/utils";
import { HiChevronRight, HiMiniPlus, HiOutlineBolt } from "react-icons/hi2";
import { LuFolderOpen, LuSettings, LuX } from "react-icons/lu";
import { LuFolderOpen, LuPalette, LuSettings, LuX } from "react-icons/lu";
import { trpc } from "renderer/lib/trpc";
import { useUpdateProject } from "renderer/react-query/projects/useUpdateProject";
import { useOpenSettings } from "renderer/stores/app-state";
import {
PROJECT_COLOR_DEFAULT,
PROJECT_COLORS,
} from "shared/constants/project-colors";
import { STROKE_WIDTH } from "../constants";
import { ProjectThumbnail } from "./ProjectThumbnail";

interface ProjectHeaderProps {
projectId: string;
projectName: string;
projectColor: string;
githubOwner: string | null;
mainRepoPath: string;
/** Whether the project section is collapsed (workspaces hidden) */
Expand All @@ -42,6 +51,7 @@ interface ProjectHeaderProps {
export function ProjectHeader({
projectId,
projectName,
projectColor,
githubOwner,
mainRepoPath,
isCollapsed,
Expand Down Expand Up @@ -87,6 +97,48 @@ export function ProjectHeader({
openSettings("project");
};

const updateProject = useUpdateProject({
onError: (error) => toast.error(`Failed to update color: ${error.message}`),
});

const handleColorChange = (color: string) => {
updateProject.mutate({ id: projectId, patch: { color } });
};

// Color picker submenu used in both collapsed and expanded context menus
const colorPickerSubmenu = (
<ContextMenuSub>
<ContextMenuSubTrigger>
<LuPalette className="size-4 mr-2" strokeWidth={STROKE_WIDTH} />
Set Color
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-36">
{PROJECT_COLORS.map((color) => {
const isDefault = color.value === PROJECT_COLOR_DEFAULT;
return (
<ContextMenuItem
key={color.value}
onSelect={() => handleColorChange(color.value)}
className="flex items-center gap-2"
>
<span
className={cn(
"size-3 rounded-full border",
isDefault ? "border-border bg-muted" : "border-border/50",
)}
style={isDefault ? undefined : { backgroundColor: color.value }}
/>
<span>{color.name}</span>
{projectColor === color.value && (
<span className="ml-auto text-xs text-muted-foreground">✓</span>
)}
</ContextMenuItem>
);
})}
</ContextMenuSubContent>
</ContextMenuSub>
);

// Collapsed sidebar: show just the thumbnail with tooltip and context menu
if (isSidebarCollapsed) {
return (
Expand All @@ -105,6 +157,7 @@ export function ProjectHeader({
<ProjectThumbnail
projectId={projectId}
projectName={projectName}
projectColor={projectColor}
githubOwner={githubOwner}
/>
</button>
Expand All @@ -126,6 +179,7 @@ export function ProjectHeader({
<LuSettings className="size-4 mr-2" strokeWidth={STROKE_WIDTH} />
Project Settings
</ContextMenuItem>
{colorPickerSubmenu}
<ContextMenuSeparator />
<ContextMenuItem
onSelect={handleCloseProject}
Expand Down Expand Up @@ -158,6 +212,7 @@ export function ProjectHeader({
<ProjectThumbnail
projectId={projectId}
projectName={projectName}
projectColor={projectColor}
githubOwner={githubOwner}
/>
<span className="truncate">{projectName}</span>
Expand Down Expand Up @@ -244,6 +299,7 @@ export function ProjectHeader({
<LuSettings className="size-4 mr-2" strokeWidth={STROKE_WIDTH} />
Project Settings
</ContextMenuItem>
{colorPickerSubmenu}
<ContextMenuSeparator />
<ContextMenuItem
onSelect={handleCloseProject}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface Workspace {
interface ProjectSectionProps {
projectId: string;
projectName: string;
projectColor: string;
githubOwner: string | null;
mainRepoPath: string;
workspaces: Workspace[];
Expand All @@ -34,6 +35,7 @@ interface ProjectSectionProps {
export function ProjectSection({
projectId,
projectName,
projectColor,
githubOwner,
mainRepoPath,
workspaces,
Expand Down Expand Up @@ -71,6 +73,7 @@ export function ProjectSection({
<ProjectHeader
projectId={projectId}
projectName={projectName}
projectColor={projectColor}
githubOwner={githubOwner}
mainRepoPath={mainRepoPath}
isCollapsed={isCollapsed}
Expand Down Expand Up @@ -122,6 +125,7 @@ export function ProjectSection({
<ProjectHeader
projectId={projectId}
projectName={projectName}
projectColor={projectColor}
githubOwner={githubOwner}
mainRepoPath={mainRepoPath}
isCollapsed={isCollapsed}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { cn } from "@superset/ui/utils";
import { useState } from "react";
import { trpc } from "renderer/lib/trpc";
import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors";

interface ProjectThumbnailProps {
projectId: string;
projectName: string;
projectColor: string;
githubOwner: string | null;
className?: string;
}
Expand All @@ -13,35 +15,63 @@ function getGitHubAvatarUrl(owner: string): string {
return `https://github.com/${owner}.png?size=64`;
}

/**
* Converts a hex color to rgba with the specified alpha.
*/
function hexToRgba(hex: string, alpha: number): string {
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

/**
* Checks if a color value is a custom hex color (not the "default" value).
*/
function isCustomColor(color: string): boolean {
return color !== PROJECT_COLOR_DEFAULT && color.startsWith("#");
}
Comment on lines +18 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add input validation to prevent invalid CSS values.

The hexToRgba function doesn't validate the hex color format. If an invalid hex string is passed (e.g., "#gg0000" or "#12"), Number.parseInt will return NaN, resulting in invalid CSS like "rgba(NaN, 0, 0, 0.6)". While isCustomColor checks for the # prefix, it doesn't validate the full hex format.

🔎 Proposed fix with validation
+const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/;
+
 /**
  * Converts a hex color to rgba with the specified alpha.
  */
 function hexToRgba(hex: string, alpha: number): string {
+	if (!HEX_COLOR_REGEX.test(hex)) {
+		console.warn("[ProjectThumbnail/hexToRgba] Invalid hex color:", hex);
+		return "rgba(128, 128, 128, 0.6)"; // fallback to gray
+	}
 	const r = Number.parseInt(hex.slice(1, 3), 16);
 	const g = Number.parseInt(hex.slice(3, 5), 16);
 	const b = Number.parseInt(hex.slice(5, 7), 16);
 	return `rgba(${r}, ${g}, ${b}, ${alpha})`;
 }

 /**
  * Checks if a color value is a custom hex color (not the "default" value).
  */
 function isCustomColor(color: string): boolean {
-	return color !== PROJECT_COLOR_DEFAULT && color.startsWith("#");
+	return color !== PROJECT_COLOR_DEFAULT && HEX_COLOR_REGEX.test(color);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Converts a hex color to rgba with the specified alpha.
*/
function hexToRgba(hex: string, alpha: number): string {
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Checks if a color value is a custom hex color (not the "default" value).
*/
function isCustomColor(color: string): boolean {
return color !== PROJECT_COLOR_DEFAULT && color.startsWith("#");
}
const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/;
/**
* Converts a hex color to rgba with the specified alpha.
*/
function hexToRgba(hex: string, alpha: number): string {
if (!HEX_COLOR_REGEX.test(hex)) {
console.warn("[ProjectThumbnail/hexToRgba] Invalid hex color:", hex);
return "rgba(128, 128, 128, 0.6)"; // fallback to gray
}
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Checks if a color value is a custom hex color (not the "default" value).
*/
function isCustomColor(color: string): boolean {
return color !== PROJECT_COLOR_DEFAULT && HEX_COLOR_REGEX.test(color);
}
🤖 Prompt for AI Agents
In
@apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx
around lines 18 - 33, The hexToRgba function currently assumes a valid 6-digit
hex and can emit invalid CSS; validate the input in hexToRgba by checking it
matches a full 6-digit hex pattern (e.g. /^#([0-9A-Fa-f]{6})$/) before parsing,
and if invalid return a safe fallback CSS value (like a transparent rgba or a
configured default) or throw a clear error; also tighten isCustomColor to only
treat values as custom when they both differ from PROJECT_COLOR_DEFAULT and
match the same 6-digit hex pattern so downstream code never receives malformed
colors.


export function ProjectThumbnail({
projectId,
projectName,
projectColor,
githubOwner,
className,
}: ProjectThumbnailProps) {
const [imageError, setImageError] = useState(false);

// Always fetch to ensure we get the latest - the backend caches it
const { data: avatarData } = trpc.projects.getGitHubAvatar.useQuery(
{ id: projectId },
{
staleTime: 1000 * 60 * 5, // Consider stale after 5 minutes
staleTime: 1000 * 60 * 5,
refetchOnWindowFocus: false,
},
);

// Prefer fetched data, fall back to prop
const owner = avatarData?.owner ?? githubOwner;
const firstLetter = projectName.charAt(0).toUpperCase();
const hasCustomColor = isCustomColor(projectColor);

// Border: gray by default, custom color with slight transparency when set
const borderClasses = cn(
"border-[1.5px]",
hasCustomColor ? undefined : "border-border",
);
const borderStyle = hasCustomColor
? { borderColor: hexToRgba(projectColor, 0.6) }
: undefined;

// Show avatar if we have an owner and no image loading error
// Show GitHub avatar if available
if (owner && !imageError) {
return (
<div
className={cn(
"relative size-6 rounded overflow-hidden flex-shrink-0 bg-muted",
borderClasses,
className,
)}
style={borderStyle}
>
<img
src={getGitHubAvatarUrl(owner)}
Expand All @@ -53,14 +83,16 @@ export function ProjectThumbnail({
);
}

// Fallback: show first letter with subtle background
// Fallback: show first letter
return (
<div
className={cn(
"size-6 rounded flex items-center justify-center flex-shrink-0",
"bg-muted text-muted-foreground text-xs font-medium",
borderClasses,
className,
)}
style={borderStyle}
>
{firstLetter}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function WorkspaceSidebar({
key={group.project.id}
projectId={group.project.id}
projectName={group.project.name}
projectColor={group.project.color}
githubOwner={group.project.githubOwner}
mainRepoPath={group.project.mainRepoPath}
workspaces={group.workspaces}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/shared/constants/project-colors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
/** Special value representing "no custom color" - uses default gray border */
export const PROJECT_COLOR_DEFAULT = "default";

export const PROJECT_COLORS: { name: string; value: string }[] = [
{ name: "Default", value: PROJECT_COLOR_DEFAULT },
{ name: "Blue", value: "#3b82f6" },
{ name: "Green", value: "#22c55e" },
{ name: "Yellow", value: "#eab308" },
Expand Down
Loading