Skip to content
Closed
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
121 changes: 118 additions & 3 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
sanitizeAuthorPrefix,
} from "../workspaces/utils/git";
import { getDefaultProjectColor } from "./utils/colors";
import { discoverFavicon } from "./utils/favicon-discovery";
import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github";

type Project = SelectProject;
Expand All @@ -53,10 +54,39 @@ export type OpenNewResult =
| OpenNewNeedsGitInit
| OpenNewError;

/**
* Fire-and-forget favicon discovery for a project.
* Finds a favicon in the repo directory and stores it as a base64 data URL.
*/
function triggerFaviconDiscovery({
projectId,
repoPath,
}: {
projectId: string;
repoPath: string;
}): void {
discoverFavicon(repoPath)
.then((icon) => {
if (icon) {
localDb
.update(projects)
.set({ iconUrl: icon })
.where(eq(projects.id, projectId))
.run();
console.log(
`[upsertProject] Discovered favicon for project: ${projectId}`,
);
}
})
.catch((err) => {
console.error("[upsertProject] Favicon discovery error:", err);
});
}

/**
* Creates or updates a project record in the database.
* If a project with the same mainRepoPath exists, updates lastOpenedAt.
* Otherwise, creates a new project.
* Otherwise, creates a new project and triggers favicon discovery.
*/
function upsertProject(mainRepoPath: string, defaultBranch: string): Project {
const name = basename(mainRepoPath);
Expand All @@ -73,6 +103,14 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project {
.set({ lastOpenedAt: Date.now(), defaultBranch })
.where(eq(projects.id, existing.id))
.run();

if (!existing.iconOverride && !existing.iconUrl) {
triggerFaviconDiscovery({
projectId: existing.id,
repoPath: mainRepoPath,
});
}

return { ...existing, lastOpenedAt: Date.now(), defaultBranch };
}

Expand All @@ -87,6 +125,8 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project {
.returning()
.get();

triggerFaviconDiscovery({ projectId: project.id, repoPath: mainRepoPath });

return project;
}

Expand Down Expand Up @@ -184,6 +224,10 @@ async function ensureMainWorkspace(project: Project): Promise<void> {
// Allows most valid Git repo names while avoiding path traversal characters
const SAFE_REPO_NAME_REGEX = /^[a-zA-Z0-9._\- ]+$/;

const ICON_DATA_URL_REGEX =
/^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+$/i;
const MAX_ICON_DATA_URL_LENGTH = 512 * 1024;

/**
* Extracts and validates a repository name from a git URL.
* Handles HTTP/HTTPS URLs, SSH-style URLs (git@host:user/repo), and edge cases.
Expand Down Expand Up @@ -713,7 +757,6 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
const git = simpleGit();
await git.clone(input.url, clonePath);

// Create new project
const name = basename(clonePath);
const defaultBranch = await getDefaultBranch(clonePath);
const project = localDb
Expand All @@ -727,7 +770,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.returning()
.get();

// Auto-create main workspace if it doesn't exist
triggerFaviconDiscovery({
projectId: project.id,
repoPath: clonePath,
});

await ensureMainWorkspace(project);

track("project_opened", {
Expand Down Expand Up @@ -1036,6 +1083,74 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
prefix: sanitizeAuthorPrefix(authorName),
};
}),

setProjectIcon: publicProcedure
.input(
z.object({
id: z.string(),
icon: z
.string()
.trim()
.max(MAX_ICON_DATA_URL_LENGTH, "Icon data URL is too large")
.regex(ICON_DATA_URL_REGEX, "Icon must be a base64 image data URL")
.nullable(), // Base64 data URL or null to remove
}),
)
.mutation(({ input }) => {
const project = localDb
.select()
.from(projects)
.where(eq(projects.id, input.id))
.get();

if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Project ${input.id} not found`,
});
}

localDb
.update(projects)
.set({ iconOverride: input.icon })
.where(eq(projects.id, input.id))
.run();

return { success: true };
}),

discoverProjectIcon: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
const project = localDb
.select()
.from(projects)
.where(eq(projects.id, input.id))
.get();

if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Project ${input.id} not found`,
});
}

if (project.iconOverride || project.iconUrl) {
return { success: true, found: false, skipped: true };
}

const icon = await discoverFavicon(project.mainRepoPath);

if (icon) {
localDb
.update(projects)
.set({ iconUrl: icon })
.where(eq(projects.id, input.id))
.run();
}

return { success: true, found: !!icon };
}),
Comment thread
Ipriyankrajai marked this conversation as resolved.
Resolved
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { readFileSync, statSync } from "node:fs";
import fg from "fast-glob";

const FAVICON_GLOB = "**/favicon.{ico,png,svg,jpg,jpeg,webp}";

const MAX_FILE_SIZE = 500 * 1024; // 500KB

/**
* Discovers a favicon in the project directory and returns it as a base64 data URL.
* Picks the favicon closest to the project root if multiple are found.
*/
export async function discoverFavicon(
repoPath: string,
): Promise<string | null> {
try {
const matches = await fg(FAVICON_GLOB, {
cwd: repoPath,
absolute: true,
onlyFiles: true,
dot: false,
});

if (matches.length === 0) return null;

const shortest = matches.sort((a, b) => a.length - b.length)[0];

const stats = statSync(shortest);
if (stats.size > MAX_FILE_SIZE) {
console.log(
`[favicon-discovery] File too large: ${shortest} (${stats.size} bytes)`,
);
return null;
}

const buffer = readFileSync(shortest);
const base64 = buffer.toString("base64");
const ext = shortest.split(".").pop()?.toLowerCase();
const mime = getMimeType(ext);

console.log(
`[favicon-discovery] Found favicon: ${shortest} (${stats.size} bytes)`,
);

return `data:${mime};base64,${base64}`;
} catch (error) {
console.error("[favicon-discovery] Error:", error);
return null;
}
}

function getMimeType(ext: string | undefined): string {
const mimes: Record<string, string> = {
ico: "image/x-icon",
png: "image/png",
svg: "image/svg+xml",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
};
return mimes[ext ?? ""] ?? "image/png";
}
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/trpc/routers/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const createWindowRouter = (getWindow: () => BrowserWindow | null) => {

const result = await dialog.showOpenDialog(window, {
properties: ["openFile"],
title: "Select Organization Logo",
title: "Select Image",
filters: [
{
name: "Images",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ export const createQueryProcedures = () => {
tabOrder: number;
githubOwner: string | null;
mainRepoPath: string;
iconUrl: string | null;
iconOverride: string | null;
};
workspaces: Array<{
id: string;
Expand Down Expand Up @@ -188,6 +190,8 @@ export const createQueryProcedures = () => {
tabOrder: project.tabOrder!,
githubOwner: project.githubOwner ?? null,
mainRepoPath: project.mainRepoPath,
iconUrl: project.iconUrl ?? null,
iconOverride: project.iconOverride ?? null,
},
workspaces: [],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ import {
SelectTrigger,
SelectValue,
} from "@superset/ui/select";
import { toast } from "@superset/ui/sonner";
import { useEffect, useState } from "react";
import { HiOutlineCog6Tooth, HiOutlineFolder } from "react-icons/hi2";
import {
HiOutlineCog6Tooth,
HiOutlineFolder,
HiOutlinePhoto,
} from "react-icons/hi2";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { resolveBranchPrefix, sanitizeSegment } from "shared/utils/branch";
import { ClickablePath } from "../../../../components/ClickablePath";
import { BRANCH_PREFIX_MODE_LABELS_WITH_DEFAULT } from "../../../../utils/branch-prefix";
import { IconUploader } from "./components/IconUploader";
import { ScriptsEditor } from "./components/ScriptsEditor";

interface ProjectSettingsProps {
Expand Down Expand Up @@ -102,6 +108,17 @@ export function ProjectSettings({ projectId }: ProjectSettingsProps) {
);
};

const setIcon = electronTrpc.projects.setProjectIcon.useMutation({
onSuccess: () => {
utils.projects.get.invalidate({ id: projectId });
utils.workspaces.getAllGrouped.invalidate();
},
onError: (err) => {
console.error("[project-settings/setIcon] Failed to update icon:", err);
toast.error(`Failed to update icon: ${err.message}`);
},
});
Comment thread
Ipriyankrajai marked this conversation as resolved.
Resolved

if (!project) {
return null;
}
Expand All @@ -116,6 +133,24 @@ export function ProjectSettings({ projectId }: ProjectSettingsProps) {
</div>

<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-base font-semibold text-foreground flex items-center gap-2">
<HiOutlinePhoto className="h-4 w-4" />
Project Icon
</h3>
<p className="text-sm text-muted-foreground">
Upload a custom icon or use the auto-discovered favicon.
</p>
<IconUploader
projectName={project.name}
iconUrl={project.iconUrl ?? null}
iconOverride={project.iconOverride ?? null}
githubOwner={project.githubOwner ?? null}
onIconChange={(icon) => setIcon.mutate({ id: projectId, icon })}
isPending={setIcon.isPending}
/>
</div>

<div className="space-y-2">
<h3 className="text-base font-semibold text-foreground">Name</h3>
<p>{project.name}</p>
Expand Down
Loading