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:
nikuscs
2026-08-01 01:34:42 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 706ae3d9bb
commit 800bd2bef1
67 changed files with 2158 additions and 912 deletions
+4 -3
View File
@@ -193,13 +193,13 @@ describe("projects settings routes", () => {
expect(buildSettingsAddHostRoute("retry 1")).toBe("/settings/general?addHost=retry%201");
});
it("buildProjectsSettingsRoute returns /settings/projects", () => {
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
it("buildProjectsSettingsRoute scopes the list to a host", () => {
expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects");
});
it("buildProjectSettingsRoute addresses a host-local project id", () => {
expect(buildProjectSettingsRoute("host a", "project/1")).toBe(
"/settings/projects/host%20a/project%2F1",
"/settings/hosts/host%20a/projects/project%2F1",
);
});
@@ -248,6 +248,7 @@ describe("host settings section slugs", () => {
expect(normalizeHostSectionSlug("connections")).toBe("connections");
expect(normalizeHostSectionSlug("agents")).toBe("agents");
expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces");
expect(normalizeHostSectionSlug("projects")).toBe("projects");
expect(normalizeHostSectionSlug("providers")).toBe("providers");
expect(normalizeHostSectionSlug("usage")).toBe("usage");
expect(normalizeHostSectionSlug("host")).toBe("host");
+8 -3
View File
@@ -507,6 +507,7 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
}
export const HOST_SECTION_SLUGS = [
"projects",
"connections",
"agents",
"workspaces",
@@ -562,15 +563,19 @@ export function buildSettingsHostSectionRoute(serverId: string, section: HostSec
return `/settings/hosts/${encodeSegment(normalized)}/${section}` as const;
}
export function buildProjectsSettingsRoute() {
return "/settings/projects" as const;
export function buildProjectsSettingsRoute(serverId: string) {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
throw new Error("buildProjectsSettingsRoute requires a non-empty serverId");
}
return `/settings/hosts/${encodeSegment(normalized)}/projects` as const;
}
export function buildProjectSettingsRoute(serverId: string, projectId: string) {
if (!serverId.trim() || !projectId.trim()) {
throw new Error("buildProjectSettingsRoute requires a serverId and projectId");
}
return `/settings/projects/${encodeSegment(serverId)}/${encodeSegment(projectId)}` as const;
return `/settings/hosts/${encodeSegment(serverId)}/projects/${encodeSegment(projectId)}` as const;
}
export function normalizeProjectSettingsRouteId(value: string | string[] | undefined): string {
+4
View File
@@ -24,6 +24,7 @@ export interface ProjectHostEntry {
workspaces: WorkspaceSummary[];
gitRuntime?: WorkspaceDescriptor["gitRuntime"];
githubRuntime?: WorkspaceDescriptor["githubRuntime"];
customIconRevision?: string | null;
}
export interface ProjectSummary {
@@ -85,6 +86,7 @@ interface HostGroup {
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
customIconRevision?: string | null;
// Repo root for a project parent that has no workspaces yet. Without it the
// host's repoRoot resolves to "" and the project reads as non-editable.
fallbackRepoRoot: string;
@@ -156,6 +158,7 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
workspaces: group.workspaces.map(toWorkspaceSummary),
gitRuntime: canonical?.gitRuntime,
githubRuntime: canonical?.githubRuntime,
customIconRevision: canonical?.projectCustomIconRevision ?? group.customIconRevision,
};
}
@@ -219,6 +222,7 @@ function addHostProjects(
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
customIconRevision: placement.customIconRevision,
fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "",
});
}
@@ -4,6 +4,7 @@ export interface SidebarProjectHostTarget {
serverId: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
}
export type SidebarProjectTrailingAction =
@@ -23,12 +24,18 @@ function hostTarget(input: {
serverId: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
}): SidebarProjectHostTarget | null {
const iconWorkingDir = input.iconWorkingDir.trim();
if (!input.serverId || !iconWorkingDir) {
return null;
}
return { serverId: input.serverId, projectId: input.projectId, iconWorkingDir };
return {
serverId: input.serverId,
projectId: input.projectId,
iconWorkingDir,
customIconRevision: input.customIconRevision,
};
}
export function resolveSidebarProjectIconTarget(