feat: add host-local custom project icons (#2335)
* feat(projects): add local icons and colors * refactor(projects): simplify appearance handling * fix(projects): serialize appearance saves * test(app): update project appearance expectations * test: fix project appearance CI expectations * refactor(projects): organize appearance code * fix(server): adopt main's reconcile model in project appearance updates * fix(projects): preserve host-local appearance targets * fix(desktop): restore Linux development startup Ubuntu can block Electron's unprivileged user-namespace fallback while the npm-installed sandbox helper is not SUID. Disable the Chromium process sandbox only in the Linux development runner so packaged desktop policy remains unchanged. * feat(projects): add custom project icons Keep URL acquisition in the client so the host daemon only validates uploaded bytes. Project name and icon changes share one adaptive edit sheet. * refactor(settings): scope projects by host --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
706ae3d9bb
commit
800bd2bef1
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openProjectEditForm, type ProjectEditFormSnapshot } from "./edit-form";
|
||||
|
||||
const derivedName: ProjectEditFormSnapshot = {
|
||||
projectName: "grumpy-turtle",
|
||||
projectCustomName: null,
|
||||
hasCustomIcon: false,
|
||||
currentIconDataUri: "data:image/png;base64,ZGVyaXZlZA==",
|
||||
};
|
||||
|
||||
const customIcon: ProjectEditFormSnapshot = {
|
||||
projectName: "Turtle",
|
||||
projectCustomName: "Turtle",
|
||||
hasCustomIcon: true,
|
||||
currentIconDataUri: "data:image/png;base64,Y3VzdG9t",
|
||||
};
|
||||
|
||||
describe("project edit form model", () => {
|
||||
it("has nothing to submit until the user changes something", () => {
|
||||
expect(openProjectEditForm(derivedName).getState()).toMatchObject({
|
||||
name: "",
|
||||
canSubmit: false,
|
||||
canUseAutomatic: false,
|
||||
submission: { rename: null, icon: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("submits only the name when only the name changed", () => {
|
||||
const model = openProjectEditForm(derivedName);
|
||||
|
||||
model.setName(" Turtle ");
|
||||
|
||||
expect(model.getState()).toMatchObject({
|
||||
canSubmit: true,
|
||||
submission: { rename: { customName: "Turtle" }, icon: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the override when the name is emptied", () => {
|
||||
const model = openProjectEditForm(customIcon);
|
||||
|
||||
model.setName(" ");
|
||||
|
||||
expect(model.getState().submission.rename).toEqual({ customName: null });
|
||||
});
|
||||
|
||||
it("treats a retyped identical name as no change", () => {
|
||||
const model = openProjectEditForm(customIcon);
|
||||
|
||||
model.setName("Turtle");
|
||||
|
||||
expect(model.getState()).toMatchObject({ canSubmit: false, submission: { rename: null } });
|
||||
});
|
||||
|
||||
it("previews a picked image and submits it as an upload", () => {
|
||||
const model = openProjectEditForm(derivedName);
|
||||
|
||||
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
|
||||
|
||||
expect(model.getState()).toMatchObject({
|
||||
previewDataUri: "data:image/png;base64,aW1hZ2U=",
|
||||
pickedFileName: "logo.png",
|
||||
canUseAutomatic: true,
|
||||
submission: { rename: null, icon: { type: "upload", data: "aW1hZ2U=" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a URL client-side and leaves the preview on the current icon", () => {
|
||||
const model = openProjectEditForm(derivedName);
|
||||
|
||||
model.setImageUrl(" https://example.com ");
|
||||
|
||||
expect(model.getState()).toMatchObject({
|
||||
previewDataUri: derivedName.currentIconDataUri,
|
||||
pickedFileName: null,
|
||||
submission: { icon: { type: "url", url: "https://example.com" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the earlier pick when the URL box is emptied", () => {
|
||||
const model = openProjectEditForm(derivedName);
|
||||
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
|
||||
|
||||
model.setImageUrl("https://example.com");
|
||||
expect(model.getState().submission.icon).toEqual({
|
||||
type: "url",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
model.setImageUrl("");
|
||||
expect(model.getState().submission.icon).toEqual({ type: "upload", data: "aW1hZ2U=" });
|
||||
});
|
||||
|
||||
it("resets a stored custom icon back to automatic", () => {
|
||||
const model = openProjectEditForm(customIcon);
|
||||
|
||||
expect(model.getState().canUseAutomatic).toBe(true);
|
||||
|
||||
model.useAutomaticIcon();
|
||||
|
||||
expect(model.getState()).toMatchObject({
|
||||
previewDataUri: null,
|
||||
canUseAutomatic: false,
|
||||
submission: { rename: null, icon: { type: "automatic" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("discards a pick without asking the daemon for an icon it already has", () => {
|
||||
const model = openProjectEditForm(derivedName);
|
||||
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
|
||||
|
||||
model.useAutomaticIcon();
|
||||
|
||||
expect(model.getState()).toMatchObject({
|
||||
previewDataUri: derivedName.currentIconDataUri,
|
||||
canSubmit: false,
|
||||
submission: { rename: null, icon: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the URL box when the user falls back to automatic", () => {
|
||||
const model = openProjectEditForm(customIcon);
|
||||
model.setImageUrl("https://example.com");
|
||||
const before = model.getState().urlResetKey;
|
||||
|
||||
model.useAutomaticIcon();
|
||||
|
||||
expect(model.getState().urlResetKey).not.toBe(before);
|
||||
});
|
||||
|
||||
it("submits both fields together when both changed", () => {
|
||||
const model = openProjectEditForm(customIcon);
|
||||
|
||||
model.setName("Renamed");
|
||||
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
|
||||
|
||||
expect(model.getState().submission).toEqual({
|
||||
rename: { customName: "Renamed" },
|
||||
icon: { type: "upload", data: "aW1hZ2U=" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a submit failure as soon as the user edits again", () => {
|
||||
const model = openProjectEditForm(derivedName);
|
||||
model.setError({ scope: "icon", message: "Enter a valid website or image URL" });
|
||||
|
||||
expect(model.getState().error).toMatchObject({ scope: "icon" });
|
||||
|
||||
model.setImageUrl("https://example.com");
|
||||
|
||||
expect(model.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
|
||||
|
||||
/**
|
||||
* What the user asked the icon to become. A URL is client-side only: the caller
|
||||
* acquires its bytes and submits the same upload payload a picked file produces.
|
||||
*/
|
||||
export type ProjectIconIntent = ProjectIconSource | { type: "url"; url: string };
|
||||
|
||||
/** The RPCs one submit has to issue. A null field is a field the user left alone. */
|
||||
export interface ProjectEditSubmission {
|
||||
rename: { customName: string | null } | null;
|
||||
icon: ProjectIconIntent | null;
|
||||
}
|
||||
|
||||
export interface ProjectEditFormError {
|
||||
/** Which field the failure belongs under. */
|
||||
scope: "name" | "icon";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProjectEditFormState {
|
||||
name: string;
|
||||
/** The image the icon tile renders, null for the derived letter fallback. */
|
||||
previewDataUri: string | null;
|
||||
pickedFileName: string | null;
|
||||
/** False once the resulting icon is the automatic one — the reset has nothing to do. */
|
||||
canUseAutomatic: boolean;
|
||||
canSubmit: boolean;
|
||||
error: ProjectEditFormError | null;
|
||||
/** Bumped only when the model clears the URL box on the user's behalf. */
|
||||
urlResetKey: number;
|
||||
submission: ProjectEditSubmission;
|
||||
}
|
||||
|
||||
export interface ProjectEditFormModel {
|
||||
getState: () => ProjectEditFormState;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
setName: (name: string) => void;
|
||||
setImageUrl: (url: string) => void;
|
||||
setPickedImage: (image: { fileName: string; mimeType: string; data: string }) => void;
|
||||
useAutomaticIcon: () => void;
|
||||
setError: (error: ProjectEditFormError | null) => void;
|
||||
}
|
||||
|
||||
export interface ProjectEditFormSnapshot {
|
||||
/** The name on screen today, custom or derived. Seeds the input's placeholder. */
|
||||
projectName: string;
|
||||
/** The override in effect, null while the project uses its derived name. */
|
||||
projectCustomName: string | null;
|
||||
hasCustomIcon: boolean;
|
||||
/** The icon the project renders today, custom or derived. */
|
||||
currentIconDataUri: string | null;
|
||||
}
|
||||
|
||||
type IconChoice = "unchanged" | "automatic" | "upload" | "url";
|
||||
|
||||
interface PickedImage {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export function openProjectEditForm(snapshot: ProjectEditFormSnapshot): ProjectEditFormModel {
|
||||
let name = snapshot.projectCustomName ?? "";
|
||||
let urlText = "";
|
||||
let picked: PickedImage | null = null;
|
||||
let iconChoice: IconChoice = "unchanged";
|
||||
let error: ProjectEditFormError | null = null;
|
||||
let urlResetKey = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function deriveRename(): ProjectEditSubmission["rename"] {
|
||||
const trimmed = name.trim();
|
||||
const customName = trimmed.length === 0 ? null : trimmed;
|
||||
return customName === snapshot.projectCustomName ? null : { customName };
|
||||
}
|
||||
|
||||
function deriveIcon(): ProjectIconIntent | null {
|
||||
if (iconChoice === "automatic") {
|
||||
return snapshot.hasCustomIcon ? { type: "automatic" } : null;
|
||||
}
|
||||
if (iconChoice === "upload" && picked) {
|
||||
return { type: "upload", data: picked.data };
|
||||
}
|
||||
if (iconChoice === "url") {
|
||||
const url = urlText.trim();
|
||||
return url.length === 0 ? null : { type: "url", url };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function derivePreview(): string | null {
|
||||
// Dropping a custom icon leaves the daemon to re-derive one, so the tile
|
||||
// falls back to the letter rather than claiming to know the result.
|
||||
if (iconChoice === "automatic") return null;
|
||||
if (iconChoice === "upload" && picked) {
|
||||
return `data:${picked.mimeType};base64,${picked.data}`;
|
||||
}
|
||||
return snapshot.currentIconDataUri;
|
||||
}
|
||||
|
||||
function deriveState(): ProjectEditFormState {
|
||||
const submission = { rename: deriveRename(), icon: deriveIcon() };
|
||||
const willBeCustom = submission.icon
|
||||
? submission.icon.type !== "automatic"
|
||||
: snapshot.hasCustomIcon;
|
||||
return {
|
||||
name,
|
||||
previewDataUri: derivePreview(),
|
||||
pickedFileName: iconChoice === "upload" ? (picked?.fileName ?? null) : null,
|
||||
canUseAutomatic: willBeCustom,
|
||||
canSubmit: Boolean(submission.rename || submission.icon),
|
||||
error,
|
||||
urlResetKey,
|
||||
submission,
|
||||
};
|
||||
}
|
||||
|
||||
let state = deriveState();
|
||||
|
||||
function publish(): void {
|
||||
state = deriveState();
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
setName: (nextName) => {
|
||||
name = nextName;
|
||||
error = null;
|
||||
publish();
|
||||
},
|
||||
setImageUrl: (url) => {
|
||||
urlText = url;
|
||||
// Emptying the box withdraws the URL without discarding an earlier pick.
|
||||
if (url.trim().length > 0) {
|
||||
iconChoice = "url";
|
||||
} else {
|
||||
iconChoice = picked ? "upload" : "unchanged";
|
||||
}
|
||||
error = null;
|
||||
publish();
|
||||
},
|
||||
setPickedImage: (image) => {
|
||||
picked = image;
|
||||
iconChoice = "upload";
|
||||
error = null;
|
||||
publish();
|
||||
},
|
||||
useAutomaticIcon: () => {
|
||||
picked = null;
|
||||
urlText = "";
|
||||
urlResetKey += 1;
|
||||
iconChoice = snapshot.hasCustomIcon ? "automatic" : "unchanged";
|
||||
error = null;
|
||||
publish();
|
||||
},
|
||||
setError: (nextError) => {
|
||||
error = nextError;
|
||||
publish();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export function hostProjectFromWorkspace(input: {
|
||||
projectId: input.workspace.projectId,
|
||||
iconWorkingDir,
|
||||
worktreeSupport: canCreate ? "supported" : "unsupported",
|
||||
customIconRevision: input.workspace.projectCustomIconRevision,
|
||||
},
|
||||
],
|
||||
workspaceKeys: [`${input.serverId}:${input.workspace.id}`],
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProjectIconLookup } from "./icons";
|
||||
|
||||
describe("project icon lookup", () => {
|
||||
const target = {
|
||||
projectId: "prj_host_local",
|
||||
iconWorkingDir: "/projects/paseo",
|
||||
};
|
||||
|
||||
it("uses the host-local project ID with custom-icon-capable daemons", () => {
|
||||
expect(resolveProjectIconLookup(target, true)).toEqual({
|
||||
kind: "project",
|
||||
projectId: "prj_host_local",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the project directory with legacy daemons", () => {
|
||||
expect(resolveProjectIconLookup(target, false)).toEqual({
|
||||
kind: "legacy",
|
||||
cwd: "/projects/paseo",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import type { ProjectIcon } from "@getpaseo/protocol/messages";
|
||||
import { useHostFeatureMap } from "@/runtime/host-features";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeIsConnected,
|
||||
} from "@/runtime/host-runtime";
|
||||
|
||||
interface ProjectIconTarget {
|
||||
serverId: string;
|
||||
projectViewKey: string;
|
||||
projectId: string;
|
||||
iconWorkingDir: string;
|
||||
customIconRevision?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daemons without custom-icon support only answer the legacy cwd lookup, which
|
||||
* still serves their automatically discovered icons.
|
||||
*/
|
||||
export function resolveProjectIconLookup(
|
||||
target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">,
|
||||
supportsCustomIcons: boolean,
|
||||
): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } {
|
||||
return supportsCustomIcons
|
||||
? { kind: "project", projectId: target.projectId }
|
||||
: { kind: "legacy", cwd: target.iconWorkingDir };
|
||||
}
|
||||
|
||||
function legacyIconQueryKey(serverId: string, cwd: string) {
|
||||
return ["projectIcon", serverId, "legacy", cwd] as const;
|
||||
}
|
||||
|
||||
function iconQueryKey(serverId: string, projectId: string, revision: string) {
|
||||
return ["projectIcon", serverId, projectId, revision] as const;
|
||||
}
|
||||
|
||||
function iconDataUri(icon: ProjectIcon | null): string | null {
|
||||
if (!icon) return null;
|
||||
return `data:${icon.mimeType};base64,${icon.data}`;
|
||||
}
|
||||
|
||||
function useStableIconData(data: (string | null)[], signature: string): readonly (string | null)[] {
|
||||
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
|
||||
if (stableRef.current?.signature !== signature) {
|
||||
stableRef.current = { signature, data };
|
||||
}
|
||||
return stableRef.current.data;
|
||||
}
|
||||
|
||||
export function useProjectIcon({ serverId, cwd }: { serverId: string; cwd: string }) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: legacyIconQueryKey(serverId, cwd),
|
||||
queryFn: async (): Promise<ProjectIcon | null> => {
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
const result = await client.requestProjectIcon(cwd);
|
||||
return result.icon;
|
||||
},
|
||||
enabled: Boolean(client && isConnected && cwd),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 60,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
return {
|
||||
icon: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
};
|
||||
}
|
||||
|
||||
export function useProjectIcons(input: {
|
||||
projects: readonly ProjectIconTarget[];
|
||||
}): Map<string, string | null> {
|
||||
const serverIds = useMemo(
|
||||
() => [...new Set(input.projects.map((project) => project.serverId))],
|
||||
[input.projects],
|
||||
);
|
||||
const supportsCustomIcons = useHostFeatureMap(serverIds, "projectCustomIcon");
|
||||
const requests = useMemo(() => {
|
||||
const unique = new Map<string, ProjectIconTarget>();
|
||||
for (const project of input.projects) {
|
||||
if (!project.serverId || !project.projectId || !project.iconWorkingDir.trim()) continue;
|
||||
unique.set(`${project.serverId}:${project.projectId}`, project);
|
||||
}
|
||||
return Array.from(unique.values());
|
||||
}, [input.projects]);
|
||||
|
||||
const queries = useQueries({
|
||||
queries: requests.map((request) => {
|
||||
const revision = request.customIconRevision ?? "automatic";
|
||||
const lookup = resolveProjectIconLookup(
|
||||
request,
|
||||
supportsCustomIcons.get(request.serverId) === true,
|
||||
);
|
||||
return {
|
||||
queryKey:
|
||||
lookup.kind === "project"
|
||||
? iconQueryKey(request.serverId, lookup.projectId, revision)
|
||||
: legacyIconQueryKey(request.serverId, lookup.cwd),
|
||||
queryFn: async () => {
|
||||
const client = getHostRuntimeStore().getClient(request.serverId);
|
||||
if (!client) return null;
|
||||
const result =
|
||||
lookup.kind === "project"
|
||||
? await client.getProjectIcon(lookup.projectId)
|
||||
: await client.requestProjectIcon(lookup.cwd);
|
||||
return result.icon;
|
||||
},
|
||||
select: iconDataUri,
|
||||
enabled: Boolean(
|
||||
getHostRuntimeStore().getClient(request.serverId) &&
|
||||
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)),
|
||||
),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 60,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const signature = queries.map((query) => query.data ?? "").join("\u0000");
|
||||
const data = useStableIconData(
|
||||
queries.map((query) => query.data ?? null),
|
||||
signature,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const byTarget = new Map<string, string | null>();
|
||||
requests.forEach((request, index) => {
|
||||
byTarget.set(`${request.serverId}:${request.projectId}`, data[index] ?? null);
|
||||
});
|
||||
|
||||
const byProject = new Map<string, string | null>();
|
||||
for (const project of input.projects) {
|
||||
byProject.set(
|
||||
project.projectViewKey,
|
||||
byTarget.get(`${project.serverId}:${project.projectId}`) ?? null,
|
||||
);
|
||||
}
|
||||
return byProject;
|
||||
}, [data, input.projects, requests]);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Buffer } from "buffer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { importProjectIconFromUrl } from "./import-project-icon";
|
||||
|
||||
const IMAGE = new Uint8Array([1, 2, 3, 4]);
|
||||
|
||||
function response(body: BodyInit, contentType: string): Response {
|
||||
return new Response(body, { headers: { "content-type": contentType } });
|
||||
}
|
||||
|
||||
describe("project icon URL import", () => {
|
||||
it("turns a direct image URL into an upload payload", async () => {
|
||||
const source = await importProjectIconFromUrl("https://example.com/icon.png", async () =>
|
||||
response(IMAGE.buffer, "image/png"),
|
||||
);
|
||||
|
||||
expect(source).toEqual({ type: "upload", data: Buffer.from(IMAGE).toString("base64") });
|
||||
});
|
||||
|
||||
it("fetches a website icon on the client and returns an upload payload", async () => {
|
||||
const requested: string[] = [];
|
||||
const source = await importProjectIconFromUrl("https://example.com", async (input) => {
|
||||
const url = input.toString();
|
||||
requested.push(url);
|
||||
if (url === "https://example.com/") {
|
||||
return response('<link rel="icon" href="/brand.png">', "text/html");
|
||||
}
|
||||
return response(IMAGE.buffer, "image/png");
|
||||
});
|
||||
|
||||
expect(requested).toEqual(["https://example.com/", "https://example.com/brand.png"]);
|
||||
expect(source.type).toBe("upload");
|
||||
});
|
||||
|
||||
it("rejects non-HTTP URLs without fetching", async () => {
|
||||
let fetched = false;
|
||||
|
||||
await expect(
|
||||
importProjectIconFromUrl("file:///etc/passwd", async () => {
|
||||
fetched = true;
|
||||
return response(IMAGE.buffer, "image/png");
|
||||
}),
|
||||
).rejects.toThrow("URL must use HTTP or HTTPS without credentials");
|
||||
expect(fetched).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Buffer } from "buffer";
|
||||
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
|
||||
|
||||
const MAX_ICON_BYTES = 512 * 1024;
|
||||
const MAX_HTML_BYTES = 512 * 1024;
|
||||
const MAX_ICON_CANDIDATES = 8;
|
||||
|
||||
type UploadSource = Extract<ProjectIconSource, { type: "upload" }>;
|
||||
type FetchIcon = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
function parseHttpUrl(value: string): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error("Enter a valid website or image URL");
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
|
||||
throw new Error("URL must use HTTP or HTTPS without credentials");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchOk(fetchIcon: FetchIcon, url: URL): Promise<Response> {
|
||||
const response = await fetchIcon(url);
|
||||
if (!response.ok) throw new Error(`Download failed (${response.status})`);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function readBytes(response: Response, maxBytes: number): Promise<Buffer> {
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
throw new Error("Icon must be 512 KB or smaller");
|
||||
}
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (bytes.length > maxBytes) throw new Error("Icon must be 512 KB or smaller");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseTagAttribute(tag: string, name: string): string | null {
|
||||
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i"));
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function iconCandidates(html: string, pageUrl: URL): URL[] {
|
||||
const candidates: URL[] = [];
|
||||
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
|
||||
const rel = parseTagAttribute(tag, "rel")?.toLowerCase() ?? "";
|
||||
const href = parseTagAttribute(tag, "href");
|
||||
const type = parseTagAttribute(tag, "type")?.toLowerCase() ?? "";
|
||||
if (!href || !rel.includes("icon") || type.includes("svg") || href.endsWith(".svg")) continue;
|
||||
try {
|
||||
candidates.push(new URL(href, pageUrl));
|
||||
} catch {
|
||||
// Try the next declared icon and then the conventional fallback.
|
||||
}
|
||||
}
|
||||
candidates.push(new URL("/favicon.ico", pageUrl));
|
||||
return candidates.slice(0, MAX_ICON_CANDIDATES);
|
||||
}
|
||||
|
||||
/** Acquires a URL on the client and collapses it into the normal upload payload. */
|
||||
export async function importProjectIconFromUrl(
|
||||
value: string,
|
||||
fetchIcon: FetchIcon = fetch,
|
||||
): Promise<UploadSource> {
|
||||
const url = parseHttpUrl(value.trim());
|
||||
const response = await fetchOk(fetchIcon, url);
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
|
||||
if (!contentType.includes("text/html")) {
|
||||
return { type: "upload", data: (await readBytes(response, MAX_ICON_BYTES)).toString("base64") };
|
||||
}
|
||||
|
||||
const html = (await readBytes(response, MAX_HTML_BYTES)).toString("utf8");
|
||||
let lastError: unknown = new Error("Website does not expose a supported icon");
|
||||
for (const candidate of iconCandidates(html, url)) {
|
||||
try {
|
||||
const iconResponse = await fetchOk(fetchIcon, candidate);
|
||||
return {
|
||||
type: "upload",
|
||||
data: (await readBytes(iconResponse, MAX_ICON_BYTES)).toString("base64"),
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
|
||||
|
||||
export interface ProjectIconRequestTarget {
|
||||
serverId: string;
|
||||
projectViewKey: string;
|
||||
iconWorkingDir: string;
|
||||
}
|
||||
|
||||
function useStableProjectIconData(
|
||||
data: (string | null)[],
|
||||
signature: string,
|
||||
): readonly (string | null)[] {
|
||||
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
|
||||
if (stableRef.current?.signature !== signature) {
|
||||
stableRef.current = { signature, data };
|
||||
}
|
||||
return stableRef.current.data;
|
||||
}
|
||||
|
||||
export function useProjectIconDataByProjectViewKey(input: {
|
||||
projects: readonly ProjectIconRequestTarget[];
|
||||
}): Map<string, string | null> {
|
||||
const projectIconRequests = useMemo(() => {
|
||||
const unique = new Map<string, { serverId: string; cwd: string }>();
|
||||
for (const project of input.projects) {
|
||||
const cwd = project.iconWorkingDir.trim();
|
||||
if (!cwd) {
|
||||
continue;
|
||||
}
|
||||
unique.set(`${project.serverId}:${cwd}`, { serverId: project.serverId, cwd });
|
||||
}
|
||||
return Array.from(unique.values());
|
||||
}, [input.projects]);
|
||||
|
||||
const projectIconQueries = useQueries({
|
||||
queries: projectIconRequests.map((request) => ({
|
||||
queryKey: projectIconQueryKey(request.serverId, request.cwd),
|
||||
queryFn: async () => {
|
||||
const client = getHostRuntimeStore().getClient(request.serverId);
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
const result = await client.requestProjectIcon(request.cwd);
|
||||
return result.icon;
|
||||
},
|
||||
select: projectIconToDataUri,
|
||||
enabled: Boolean(
|
||||
getHostRuntimeStore().getClient(request.serverId) &&
|
||||
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
|
||||
request.cwd,
|
||||
),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 60,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})),
|
||||
});
|
||||
|
||||
const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000");
|
||||
const projectIconData = useStableProjectIconData(
|
||||
projectIconQueries.map((query) => query.data ?? null),
|
||||
projectIconSignature,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const iconByServerAndCwd = new Map<string, string | null>();
|
||||
for (let index = 0; index < projectIconRequests.length; index += 1) {
|
||||
const request = projectIconRequests[index];
|
||||
if (!request) {
|
||||
continue;
|
||||
}
|
||||
iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null);
|
||||
}
|
||||
|
||||
const byProject = new Map<string, string | null>();
|
||||
for (const project of input.projects) {
|
||||
const cwd = project.iconWorkingDir.trim();
|
||||
if (!cwd) {
|
||||
byProject.set(project.projectViewKey, null);
|
||||
continue;
|
||||
}
|
||||
byProject.set(
|
||||
project.projectViewKey,
|
||||
iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
return byProject;
|
||||
}, [input.projects, projectIconData, projectIconRequests]);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface WorkspaceStructureHostPlacement {
|
||||
projectId: string;
|
||||
iconWorkingDir: string;
|
||||
worktreeSupport: "supported" | "unsupported" | "unknown";
|
||||
customIconRevision?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceStructureProject {
|
||||
@@ -158,6 +159,7 @@ function addProjectToView(input: {
|
||||
projectId: project.projectId,
|
||||
iconWorkingDir: project.projectRootPath,
|
||||
worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported",
|
||||
customIconRevision: project.projectCustomIconRevision,
|
||||
};
|
||||
const draft = byProject.get(viewKey);
|
||||
if (!draft) {
|
||||
|
||||
Reference in New Issue
Block a user