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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ interface ColorSelectorProps {
selectedColor?: string | null;
onSelectColor: (color: string) => void;
variant?: ColorSelectorVariant;
/** Prepend a "Default" (no color) swatch; selects PROJECT_COLOR_DEFAULT. */
includeDefault?: boolean;
/** Disable all swatches (e.g. while a selection is persisting). */
disabled?: boolean;
className?: string;
}

Expand Down Expand Up @@ -43,19 +47,25 @@ export function ColorSelector({
selectedColor,
onSelectColor,
variant = "inline",
includeDefault = false,
disabled = false,
className,
}: ColorSelectorProps) {
const selectedValue = selectedColor ?? PROJECT_COLOR_DEFAULT;
const colors = includeDefault
? [{ name: "Default", value: PROJECT_COLOR_DEFAULT }, ...PROJECT_COLORS]
: [...PROJECT_COLORS];

if (variant === "menu") {
return (
<>
{PROJECT_COLORS.map((color) => {
{colors.map((color) => {
const isSelected = selectedValue === color.value;

return (
<ContextMenuItem
key={color.value}
disabled={disabled}
onSelect={() => onSelectColor(color.value)}
className="flex items-center gap-2"
>
Expand All @@ -73,7 +83,7 @@ export function ColorSelector({

return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
{PROJECT_COLORS.map((color) => {
{colors.map((color) => {
const isSelected = selectedValue === color.value;

return (
Expand All @@ -83,10 +93,12 @@ export function ColorSelector({
title={color.name}
aria-label={`Set color to ${color.name}`}
aria-pressed={isSelected}
disabled={disabled}
onClick={() => onSelectColor(color.value)}
className={cn(
"flex size-7 items-center justify-center rounded-full border-2 transition-transform hover:scale-110",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100",
isSelected ? "scale-110 border-foreground" : "border-transparent",
)}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export { resolveProjectIconUrl } from "./resolveProjectIconUrl";
export {
PROJECT_ICON_NONE,
resolveProjectIconUrl,
} from "./resolveProjectIconUrl";
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ describe("resolveProjectIconUrl", () => {
test("returns null when there is neither a custom icon nor a repo owner", () => {
expect(resolveProjectIconUrl({ icon: null, repoOwner: null })).toBeNull();
});

test("the 'none' sentinel suppresses the GitHub avatar fallback", () => {
expect(
resolveProjectIconUrl({ icon: "none", repoOwner: "acme" }),
).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
/**
* Sentinel stored in the host `icon` column meaning "explicitly no icon":
* suppresses the GitHub avatar fallback so surfaces render the letter tile
* (v1's hide_image equivalent). Null means "default" (avatar when linked).
*/
export const PROJECT_ICON_NONE = "none";

/**
* Resolve the icon URL to display for a project: a custom local-first icon
* (data-URI, set in project settings) wins; otherwise fall back to the linked
* GitHub owner's avatar; otherwise none (callers render a placeholder).
* (data-URI, set in project settings) wins; the "none" sentinel suppresses
* every image so callers render the letter placeholder; otherwise fall back
* to the linked GitHub owner's avatar; otherwise none.
*
* This is the single source of truth for project-icon display — every surface
* (sidebar, settings, pickers, workspace lists) must go through it so a custom
Expand All @@ -11,6 +19,7 @@ export function resolveProjectIconUrl(project: {
icon: string | null;
repoOwner: string | null;
}): string | null {
if (project.icon === PROJECT_ICON_NONE) return null;
if (project.icon) return project.icon;
if (project.repoOwner) {
return `https://github.com/${project.repoOwner}.png?size=64`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface HostProjectRow {
worktreeBaseDir: string | null;
/** Custom icon data-URI, or null to fall back to the GitHub avatar. */
icon: string | null;
/** Accent color as a `#rrggbb` hex, or null for the default. */
color: string | null;
createdAt: number;
updatedAt: number;
}
Expand All @@ -34,6 +36,8 @@ export interface HostProjectItem {
repoUrl: string | null;
/** Custom icon data-URI, or null to fall back to the GitHub avatar. */
icon: string | null;
/** Accent color as a `#rrggbb` hex, or null for the default. */
color: string | null;
/** Hosts that serve this project. */
hostIds: string[];
/** False when no serving host answered live (snapshot data only). */
Expand Down Expand Up @@ -137,6 +141,7 @@ export function normalizeHostProjectRow(
repoUrl: row.repoUrl ?? null,
worktreeBaseDir: row.worktreeBaseDir ?? null,
icon: row.icon ?? null,
color: row.color ?? null,
createdAt: row.createdAt ?? 0,
updatedAt: row.updatedAt ?? row.createdAt ?? 0,
};
Expand Down Expand Up @@ -241,6 +246,7 @@ export function applyProjectChangedEvent(
repoUrl: snapshot.repoUrl,
worktreeBaseDir: snapshot.worktreeBaseDir,
icon: snapshot.icon,
color: snapshot.color ?? null,
createdAt: snapshot.createdAt,
updatedAt: snapshot.updatedAt,
};
Expand Down Expand Up @@ -282,6 +288,7 @@ export function mergeHostProjects({
repoName: row.repoName,
repoUrl: row.repoUrl,
icon: row.icon,
color: row.color,
hostIds: [result.target.machineId],
hostReachable: result.reachable,
createdAt: row.createdAt,
Expand All @@ -306,6 +313,7 @@ export function mergeHostProjects({
existing.repoOwner = row.repoOwner;
existing.repoName = row.repoName;
existing.icon = row.icon;
existing.color = row.color;
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/lib/v1-migration/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export interface V1ProjectRow {
name: string;
mainRepoPath: string;
githubOwner: string | null;
/** v1 accent color: a `#rrggbb` hex or the "default" sentinel. */
color: string;
/** v1 "hide the GitHub avatar" flag — carries as the "none" icon. */
hideImage: boolean | null;
worktreeBaseDir: string | null;
branchPrefixMode: BranchPrefixMode | null;
branchPrefixCustom: string | null;
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/renderer/lib/v1-migration/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export interface V1ProjectLike {
name: string;
mainRepoPath: string;
githubOwner: string | null;
/** v1 accent color: a `#rrggbb` hex or the "default" sentinel. */
color?: string | null;
/** v1 "hide the GitHub avatar" flag — carries as the "none" icon. */
hideImage?: boolean | null;
}

export type ProjectFindByPathResult = Awaited<
Expand Down Expand Up @@ -126,6 +130,7 @@ export async function importV1Project({
allowRelocate,
},
});
await carryV1ProjectAppearance(hostClient, targetCandidate.id, project);
return {
kind: "imported",
v2ProjectId: targetCandidate.id,
Expand All @@ -148,10 +153,51 @@ export async function importV1Project({
name: project.name,
mode: { kind: "importLocal", repoPath: project.mainRepoPath },
});
await carryV1ProjectAppearance(hostClient, result.projectId, project);
return {
kind: "imported",
v2ProjectId: result.projectId,
mainWorkspaceId: result.mainWorkspaceId,
repoPath: result.repoPath,
};
}

/**
* Best-effort: copy v1 appearance onto the imported v2 project — the accent
* color (v1 stores either a hex or the "default" sentinel; only real hexes
* carry over) and the hide-avatar flag (v2's "none" icon sentinel). Never
* fails the import (appearance is cosmetic, and older hosts lack setColor).
*/
async function carryV1ProjectAppearance(
hostClient: HostServiceClient,
v2ProjectId: string,
project: Pick<V1ProjectLike, "color" | "hideImage">,
): Promise<void> {
const { color } = project;
if (color && /^#[0-9a-fA-F]{6}$/.test(color)) {
try {
await hostClient.project.setColor.mutate({
projectId: v2ProjectId,
color,
});
} catch (err) {
console.error("[v1-migration] carrying project color failed", {
v2ProjectId,
err,
});
}
}
if (project.hideImage) {
try {
await hostClient.project.setIcon.mutate({
projectId: v2ProjectId,
icon: "none",
});
} catch (err) {
console.error("[v1-migration] carrying hide-image flag failed", {
v2ProjectId,
err,
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ class FakeHost {
}
: null,
},
setColor: {
mutate: async (args: unknown) => {
this.mutations.push({ kind: "project.setColor", args });
},
},
setIcon: {
mutate: async (args: unknown) => {
this.mutations.push({ kind: "project.setIcon", args });
},
},
setWorktreeBaseDir: {
mutate: async (args: unknown) => {
this.mutations.push({ kind: "project.setWorktreeBaseDir", args });
Expand Down Expand Up @@ -225,11 +235,18 @@ class FakeIpc implements V1MigrationIpc {
}
}

const project = (id: string, repoPath: string): V1ProjectRow => ({
const project = (
id: string,
repoPath: string,
color = "default",
hideImage: boolean | null = null,
): V1ProjectRow => ({
id,
name: id,
mainRepoPath: repoPath,
githubOwner: null,
color,
hideImage,
worktreeBaseDir: null,
branchPrefixMode: null,
branchPrefixCustom: null,
Expand Down Expand Up @@ -274,6 +291,33 @@ describe("runV1Migration scenarios", () => {
expect(second.gateComplete).toBe(true);
});

test("custom v1 color and hide-image carry over; defaults do not", async () => {
const ipc = new FakeIpc();
const host = new FakeHost();
ipc.projects = [
project("colored", "/repo/a", "#ef4444"),
project("plain", "/repo/b"), // "default" sentinel, no hideImage
project("hidden", "/repo/c", "default", true),
];

const summary = await run(ipc, host);
expect(summary.projects.migrated).toBe(3);
expect(host.mutations.filter((m) => m.kind === "project.setColor")).toEqual(
[
{
kind: "project.setColor",
args: { projectId: "v2p-1", color: "#ef4444" },
},
],
);
expect(host.mutations.filter((m) => m.kind === "project.setIcon")).toEqual([
{
kind: "project.setIcon",
args: { projectId: "v2p-5", icon: "none" },
},
]);
});

test("broken repo blocks the gate but not other entities; recovery unblocks", async () => {
const ipc = new FakeIpc();
const host = new FakeHost();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function DashboardSidebarProjectSection({
projectId={project.id}
projectName={project.name}
iconUrl={project.iconUrl}
projectColor={project.color}
isCollapsed={project.isCollapsed}
totalWorkspaceCount={totalWorkspaceCount}
projectChildren={project.children}
Expand All @@ -119,6 +120,7 @@ export function DashboardSidebarProjectSection({
<DashboardSidebarProjectRow
projectName={project.name}
iconUrl={project.iconUrl}
projectColor={project.color}
isCollapsed={project.isCollapsed}
isRenaming={isRenaming}
renameValue={renameValue}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface DashboardSidebarCollapsedProjectContentProps
projectId: string;
projectName: string;
iconUrl: string | null;
projectColor: string | null;
isCollapsed: boolean;
totalWorkspaceCount: number;
projectChildren: DashboardSidebarProjectChild[];
Expand All @@ -35,6 +36,7 @@ export const DashboardSidebarCollapsedProjectContent = forwardRef<
projectId,
projectName,
iconUrl,
projectColor,
isCollapsed,
totalWorkspaceCount,
projectChildren,
Expand Down Expand Up @@ -81,6 +83,7 @@ export const DashboardSidebarCollapsedProjectContent = forwardRef<
<ProjectThumbnail
projectName={projectName}
iconUrl={iconUrl}
color={projectColor}
className="size-4 text-[10px]"
/>
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface DashboardSidebarProjectRowProps
extends ComponentPropsWithoutRef<"div"> {
projectName: string;
iconUrl: string | null;
projectColor: string | null;
isCollapsed: boolean;
isRenaming: boolean;
renameValue: string;
Expand All @@ -28,6 +29,7 @@ export const DashboardSidebarProjectRow = forwardRef<
{
projectName,
iconUrl,
projectColor,
isCollapsed,
isRenaming,
renameValue,
Expand Down Expand Up @@ -72,6 +74,7 @@ export const DashboardSidebarProjectRow = forwardRef<
<ProjectThumbnail
projectName={projectName}
iconUrl={iconUrl}
color={projectColor}
className="size-4 group-hover:hidden"
/>
<HiChevronRight
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function makeProject(
githubOwner: null,
githubRepoName: null,
iconUrl: null,
color: null,
createdAt: DATE,
updatedAt: DATE,
isCollapsed: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface SidebarProjectInput {
githubOwner: string | null;
githubRepoName: string | null;
iconUrl: string | null;
/** Accent color as a `#rrggbb` hex, or null for the default. */
color: string | null;
createdAt: Date;
updatedAt: Date;
isCollapsed: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export function useDashboardSidebarData() {
githubOwner: project.repoOwner,
githubRepoName: project.repoName,
iconUrl: resolveProjectIconUrl(project),
color: project.color,
createdAt: new Date(project.createdAt),
updatedAt: new Date(project.updatedAt),
isCollapsed: row.isCollapsed,
Expand Down
Loading
Loading