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
+78 -11
View File
@@ -4,6 +4,8 @@ import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { daemonWsRoutePattern } from "./daemon-port";
import { getServerId } from "./server-id";
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
type WebSocketMessage = string | Buffer;
@@ -33,8 +35,8 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
export async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
}
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
@@ -48,6 +50,21 @@ export async function navigateToProjectSettings(page: Page, projectName: string)
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
}
export async function returnToProjectsList(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back to projects", exact: true }).click();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
}
export async function expectProjectSettingsHistoryRoundTrip(
page: Page,
projectName: string,
): Promise<void> {
await page.goBack();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
await page.goForward();
await expectProjectTitle(page, projectName);
}
// --- Form interactions ---
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
@@ -57,7 +74,7 @@ export async function editWorktreeSetup(page: Page, setupCommands: string[]): Pr
}
export async function clickSaveProjectSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save" }).click();
await page.getByTestId("save-button").click();
}
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
@@ -72,6 +89,61 @@ export async function clickReloadProjectSettings(page: Page): Promise<void> {
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
}
// --- Project edit sheet (name + icon) ---
export async function openProjectEditSheet(page: Page): Promise<void> {
await page.getByRole("button", { name: "Edit project", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Project name" })).toBeVisible();
}
export async function fillProjectName(page: Page, name: string): Promise<void> {
await page.getByRole("textbox", { name: "Project name" }).fill(name);
}
export async function chooseProjectIconImage(
page: Page,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
await page.getByRole("button", { name: "Choose image" }).click();
await (await chooserPromise).setFiles([file]);
await expect(page.getByText(file.name, { exact: true })).toBeVisible();
}
export async function fillProjectIconUrl(page: Page, url: string): Promise<void> {
await page.getByRole("textbox", { name: "Image or website URL" }).fill(url);
}
export async function useAutomaticProjectIcon(page: Page): Promise<void> {
await page.getByRole("button", { name: "Use automatic" }).click();
}
export async function saveProjectEdits(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save changes" }).click();
}
export async function expectProjectEditName(page: Page, name: string): Promise<void> {
await expect(page.getByRole("textbox", { name: "Project name" })).toHaveValue(name);
}
export async function expectProjectEditsSaveDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled();
}
export async function expectProjectEditSaved(page: Page): Promise<void> {
await expect(page.getByTestId("app-toast-message")).toHaveText("Project updated");
}
// The sheet keeps the user's input on a failed save so the value stays editable.
export async function expectProjectEditFailed(page: Page, detail: string): Promise<void> {
await expect(page.getByText(detail, { exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Project name" })).toBeVisible();
}
export async function expectProjectTitle(page: Page, projectName: string): Promise<void> {
await expect(page.getByRole("main").getByText(projectName, { exact: true })).toBeVisible();
}
// --- Error-state assertions ---
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
@@ -102,7 +174,7 @@ export async function expectWriteFailedCalloutActions(page: Page): Promise<void>
}
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save" })).toBeDisabled();
await expect(page.getByTestId("save-button")).toBeDisabled();
}
// --- Form-state assertions ---
@@ -121,13 +193,8 @@ export async function expectNoEditableTarget(page: Page): Promise<void> {
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
}
// --- Host-section assertions ---
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).toBeVisible();
}
export async function expectHostPickerHidden(page: Page): Promise<void> {
export async function expectProjectHostContextHidden(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).not.toBeVisible();
await expect(page.getByTestId("host-picker")).not.toBeVisible();
}