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
@@ -94,7 +94,10 @@ vi.mock("react-native", () => {
Pressable: passthrough,
Image: ({ source }: { source?: { uri?: string } }) =>
React.createElement("img", { src: source?.uri ?? "" }),
Platform: { OS: "web" },
Platform: {
OS: "web",
select: <T,>(options: { web?: T; default?: T }) => options.web ?? options.default,
},
};
});
@@ -201,8 +204,8 @@ vi.mock("@/hooks/use-projects", () => ({
useProjects: () => projectsState.current,
}));
vi.mock("@/projects/project-icons", () => ({
useProjectIconDataByProjectViewKey: () => new Map(),
vi.mock("@/projects/icons", () => ({
useProjectIcons: () => new Map(),
}));
import ProjectsScreen from "./projects-screen";
@@ -293,11 +296,9 @@ describe("ProjectsScreen", () => {
vi.unstubAllGlobals();
});
function render(
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string },
) {
function render(serverId = "host-a") {
act(() => {
root?.render(<ProjectsScreen view={view} />);
root?.render(<ProjectsScreen serverId={serverId} />);
});
}
@@ -306,12 +307,12 @@ describe("ProjectsScreen", () => {
projects: [
project({
projectName: "acme/app",
hosts: [hostEntry({ serverName: "alpha", workspaceCount: 5 })],
hosts: [hostEntry({ projectName: "acme/app", serverName: "alpha", workspaceCount: 5 })],
}),
],
});
render({ kind: "projects" });
render();
const rows = container?.querySelectorAll('[data-testid^="project-row-"]') ?? [];
expect(rows.length).toBe(1);
@@ -326,7 +327,7 @@ describe("ProjectsScreen", () => {
projects: [project({ viewKey: "remote:github.com/acme/app" })],
});
render({ kind: "projects" });
render();
const row = findRow(container!, "remote:github.com/acme/app");
act(() => {
@@ -334,7 +335,7 @@ describe("ProjectsScreen", () => {
});
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/settings/projects/host-a/project-a");
expect(navigate).toHaveBeenCalledWith("/settings/hosts/host-a/projects/project-a");
});
it("does not render a kebab menu on the row", () => {
@@ -342,7 +343,7 @@ describe("ProjectsScreen", () => {
projects: [project({ viewKey: "remote:github.com/acme/app" })],
});
render({ kind: "projects" });
render();
expect(
container?.querySelector('[data-testid="project-row-menu-remote:github.com/acme/app"]'),
@@ -352,7 +353,7 @@ describe("ProjectsScreen", () => {
it("renders a centered loading spinner before the first response", () => {
setProjectsState({ isLoading: true, projects: [] });
render({ kind: "projects" });
render();
expect(container?.querySelector('[data-testid="projects-loading-spinner"]')).not.toBeNull();
});
@@ -360,7 +361,7 @@ describe("ProjectsScreen", () => {
it("renders the empty state when there are no projects", () => {
setProjectsState({ projects: [] });
render({ kind: "projects" });
render();
expect(container?.textContent).toContain("No projects yet");
expect(container?.textContent).not.toContain("Non-GitHub remote projects aren't supported yet");
@@ -372,37 +373,39 @@ describe("ProjectsScreen", () => {
{ serverId: "b", serverName: "beta", message: "unreachable" },
];
setProjectsState({
projects: [project()],
projects: [project({ hosts: [hostEntry({ serverId: "a" })] })],
hostErrors,
});
render({ kind: "projects" });
render("a");
const banner = container?.querySelector('[data-testid="projects-host-errors"]');
expect(banner).not.toBeNull();
expect(banner?.textContent).toContain("alpha");
expect(banner?.textContent).toContain("beta");
expect(banner?.textContent).not.toContain("beta");
expect(container?.querySelector('[data-testid^="project-row-"]')).not.toBeNull();
});
it("highlights the selected row when the active view targets a project", () => {
it("shows only projects belonging to the selected host", () => {
setProjectsState({
projects: [
project({ viewKey: "remote:github.com/acme/app" }),
project({
viewKey: "remote:github.com/acme/other",
projectName: "acme/other",
githubUrl: "https://github.com/acme/other",
viewKey: "host-a-project",
projectName: "Host A",
hosts: [hostEntry({ serverId: "host-a", projectId: "project-a", projectName: "Host A" })],
}),
project({
viewKey: "host-b-project",
projectName: "Host B",
hosts: [hostEntry({ serverId: "host-b", projectId: "project-b", projectName: "Host B" })],
}),
],
});
render({ kind: "project", serverId: "host-a", projectId: "project-a" });
render("host-b");
const selected = findRow(container!, "remote:github.com/acme/app");
const other = findRow(container!, "remote:github.com/acme/other");
expect(selected.getAttribute("data-selected")).toBe("true");
expect(other.getAttribute("data-selected")).toBe("false");
expect(container?.textContent).not.toContain("Host A");
expect(container?.textContent).toContain("Host B");
});
it("does not include the word 'checkout' anywhere in the rendered tree", () => {
@@ -418,7 +421,7 @@ describe("ProjectsScreen", () => {
hostErrors: [{ serverId: "x", serverName: "x", message: "down" }],
});
render({ kind: "projects" });
render("a");
const html = container?.innerHTML.toLowerCase() ?? "";
expect(html).not.toContain("checkout");