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
+98 -5
View File
@@ -5,29 +5,40 @@ import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import {
blockPaseoConfigWrites,
bumpPaseoConfigOnDisk,
chooseProjectIconImage,
clickReloadProjectSettings,
clickRetryProjectSettingsSave,
clickSaveProjectSettings,
corruptPaseoConfig,
editWorktreeSetup,
expectEmptyScriptList,
expectHostIndicatorVisible,
expectHostPickerHidden,
expectProjectHostContextHidden,
expectNoEditableTarget,
expectNoProjectSettingsError,
expectProjectEditFailed,
expectProjectEditName,
expectProjectEditSaved,
expectProjectEditsSaveDisabled,
expectProjectSettingsError,
expectProjectSettingsFormHidden,
expectProjectSettingsFormVisible,
expectProjectTitle,
expectProjectSettingsHistoryRoundTrip,
expectSaveButtonDisabled,
expectScriptRowCount,
expectWriteFailedCalloutActions,
fillProjectIconUrl,
fillProjectName,
installDaemonConnectionGate,
installReadTransportFailure,
navigateToProjectSettings,
openProjectEditSheet,
openProjectSettings,
openProjects,
removeProjectScript,
restorePaseoConfig,
returnToProjectsList,
saveProjectEdits,
unblockPaseoConfigWrites,
} from "./helpers/project-settings";
import { gotoAppShell } from "./helpers/app";
@@ -40,6 +51,12 @@ import { createTempGitRepo } from "./helpers/workspace";
const updatedSetup = ["npm install", "npm run build"];
// Smallest valid square PNG the daemon will accept as a custom project icon.
const PNG_1X1 = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00,
]);
interface ProjectsSettingsProject {
name: string;
path: string;
@@ -204,6 +221,15 @@ test.describe("Projects settings", () => {
await expectProjectConfigSaved(editableProject);
});
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectProjectHostContextHidden(page);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
});
test("user edits worktree setup on a non-GitHub remote project", async ({
page,
gitlabRemoteProject,
@@ -215,6 +241,74 @@ test.describe("Projects settings", () => {
await clickSaveProjectSettings(page);
await expectProjectConfigSaved(gitlabRemoteProject);
});
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
});
test("reopening the edit sheet seeds from the saved project", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await openProjectEditSheet(page);
await expectProjectEditName(page, "Renamed project");
await expectProjectEditsSaveDisabled(page);
});
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
});
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Both at once");
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Both at once");
});
test("project edit keeps a rejected icon URL actionable", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectIconUrl(page, "file:///etc/passwd");
await saveProjectEdits(page);
await expectProjectEditFailed(page, "URL must use HTTP or HTTPS without credentials");
});
});
test.describe("Projects settings — error UX", () => {
@@ -322,15 +416,14 @@ test.describe("Projects settings — error UX", () => {
await expectNoEditableTarget(page);
});
test("single-host project renders static host indicator, not a picker chip", async ({
test("project detail does not render a second host selector", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectHostIndicatorVisible(page);
await expectHostPickerHidden(page);
await expectProjectHostContextHidden(page);
});
test("script removal via kebab menu removes the row from the form", async ({