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
@@ -0,0 +1,301 @@
import { Buffer } from "buffer";
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ProjectIconView } from "@/components/project-icon-view";
import { Button } from "@/components/ui/button";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useFilePicker } from "@/hooks/use-file-picker";
import {
openProjectEditForm,
type ProjectEditFormError,
type ProjectEditFormSnapshot,
type ProjectEditSubmission,
type ProjectIconIntent,
} from "@/projects/edit-form";
import { importProjectIconFromUrl } from "@/projects/import-project-icon";
import { toErrorMessage } from "@/utils/error-messages";
export interface ProjectEditSheetProps {
visible: boolean;
onClose: () => void;
serverId: string;
projectId: string;
projectViewKey: string;
client: DaemonClient;
/** False on hosts that predate custom project icons — the icon field is hidden. */
supportsCustomIcon: boolean;
snapshot: ProjectEditFormSnapshot;
}
/** Editing a project is its name and its icon, decided together and saved once. */
export function ProjectEditSheet({
visible,
onClose,
serverId,
projectId,
projectViewKey,
client,
supportsCustomIcon,
snapshot,
}: ProjectEditSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const toast = useToast();
const { pickFiles } = useFilePicker();
const size: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const [form] = useState(() => openProjectEditForm(snapshot));
const state = useSyncExternalStore(form.subscribe, form.getState, form.getState);
const mutation = useMutation({
mutationFn: (submission: ProjectEditSubmission) =>
submitProjectEdit({ client, projectId, submission }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["projectIcon", serverId] });
toast.show(t("settings.project.edit.savedToast"), { variant: "success" });
onClose();
},
onError: (error) => form.setError(toFormError(error)),
});
const isSaving = mutation.isPending;
const handleClose = useCallback(() => {
if (isSaving) return;
onClose();
}, [isSaving, onClose]);
const handleSubmit = useCallback(() => {
mutation.mutate(state.submission);
}, [mutation, state.submission]);
const chooseImage = useCallback(async () => {
const file = (await pickFiles())?.[0];
if (!file) return;
form.setPickedImage({
fileName: file.fileName,
mimeType: file.mimeType,
data: Buffer.from(file.bytes).toString("base64"),
});
}, [form, pickFiles]);
const handleChooseImage = useCallback(() => {
void chooseImage();
}, [chooseImage]);
const header = useMemo<SheetHeader>(() => ({ title: t("settings.project.edit.title") }), [t]);
const footer = useMemo(
() => (
<View style={styles.footer}>
<Button
variant="secondary"
size="md"
style={styles.footerButton}
onPress={handleClose}
disabled={isSaving}
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="md"
style={styles.footerButton}
onPress={handleSubmit}
disabled={!state.canSubmit}
loading={isSaving}
testID="project-edit-save"
>
{t("settings.project.edit.save")}
</Button>
</View>
),
[handleClose, handleSubmit, isSaving, state.canSubmit, t],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={handleClose}
footer={footer}
desktopMaxWidth={440}
// Bound the compact scroller to the live snap height so the footer stays
// on screen instead of being pushed past the bottom of the sheet.
sizeContentToCurrentSnapPoint
testID="project-edit-sheet"
>
<Field
label={t("settings.project.edit.name")}
error={state.error?.scope === "name" ? state.error.message : null}
>
<FormTextInput
size={size}
testID="project-edit-name"
accessibilityLabel={t("settings.project.edit.nameLabel")}
initialValue={state.name}
onChangeText={form.setName}
placeholder={snapshot.projectName}
editable={!isSaving}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
{supportsCustomIcon ? (
<Field
label={t("settings.project.edit.icon")}
hint={state.pickedFileName ?? undefined}
error={state.error?.scope === "icon" ? state.error.message : null}
>
<View style={styles.iconField}>
<View style={styles.iconRow}>
<ProjectIconView
iconDataUri={state.previewDataUri}
initial={projectInitial(snapshot.projectName)}
projectViewKey={projectViewKey}
imageStyle={styles.preview}
fallbackStyle={styles.previewFallback}
textStyle={styles.previewText}
/>
<Button
variant="outline"
size={size}
onPress={handleChooseImage}
disabled={isSaving}
testID="project-edit-choose-image"
>
{t("settings.project.edit.chooseImage")}
</Button>
{state.canUseAutomatic ? (
<Button
variant="ghost"
size={size}
onPress={form.useAutomaticIcon}
disabled={isSaving}
testID="project-edit-use-automatic"
>
{t("settings.project.edit.useAutomatic")}
</Button>
) : null}
</View>
<FormTextInput
size={size}
testID="project-edit-image-url"
accessibilityLabel={t("settings.project.edit.imageUrl")}
initialValue=""
resetKey={state.urlResetKey}
onChangeText={form.setImageUrl}
placeholder={t("settings.project.edit.imageUrl")}
editable={!isSaving}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
</Field>
) : null}
</AdaptiveModalSheet>
);
}
function projectInitial(projectName: string): string {
return projectName.trim().charAt(0).toUpperCase() || "?";
}
class ProjectEditStepError extends Error {
constructor(
readonly scope: ProjectEditFormError["scope"],
cause: unknown,
) {
super(toErrorMessage(cause));
this.name = "ProjectEditStepError";
}
}
async function step<T>(scope: ProjectEditFormError["scope"], run: () => Promise<T>): Promise<T> {
try {
return await run();
} catch (error) {
throw new ProjectEditStepError(scope, error);
}
}
/**
* One submit, up to two RPCs. Icon bytes are acquired before either RPC runs
* because that is the step most likely to fail; past that point both RPCs take
* the same inputs on a retry, so a failure between them is safe to submit again.
*/
async function submitProjectEdit(input: {
client: DaemonClient;
projectId: string;
submission: ProjectEditSubmission;
}): Promise<void> {
const { client, projectId, submission } = input;
const { rename, icon } = submission;
const source = icon ? await step("icon", () => acquireIconSource(icon)) : null;
if (rename) {
await step("name", () => client.renameProject(projectId, rename.customName));
}
if (source) {
await step("icon", () => client.setProjectIcon(projectId, source));
}
}
function acquireIconSource(intent: ProjectIconIntent): Promise<ProjectIconSource> {
if (intent.type === "url") return importProjectIconFromUrl(intent.url);
return Promise.resolve(intent);
}
function toFormError(error: unknown): ProjectEditFormError {
if (error instanceof ProjectEditStepError) {
return { scope: error.scope, message: error.message };
}
return { scope: "name", message: toErrorMessage(error) };
}
const styles = StyleSheet.create((theme) => ({
iconField: {
gap: theme.spacing[2],
},
iconRow: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
},
preview: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.md,
},
previewFallback: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
previewText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
footer: {
flex: 1,
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
footerButton: {
minWidth: 112,
},
}));
@@ -57,7 +57,7 @@ import {
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { useProjectIcons } from "@/projects/icons";
import {
buildNewWorkspaceRoute,
buildProjectSettingsRoute,
@@ -1958,7 +1958,7 @@ export function SidebarWorkspaceList({
() => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []),
[groupMode, projects],
);
const statusProjectIconByProjectViewKey = useProjectIconDataByProjectViewKey({
const statusProjectIconByProjectViewKey = useProjectIcons({
projects: statusProjectIconTargets,
});
@@ -2127,7 +2127,7 @@ function ProjectModeList({
[parentGestureRef],
);
const projectIconByProjectViewKey = useProjectIconDataByProjectViewKey({
const projectIconByProjectViewKey = useProjectIcons({
projects: projectIconTargets,
});
@@ -8,7 +8,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useProjectIcon } from "@/projects/icons";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
@@ -195,7 +195,7 @@ export function WorkspaceSetupDialog() {
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
}
const { icon: projectIcon } = useProjectIconQuery({
const { icon: projectIcon } = useProjectIcon({
serverId,
cwd: sourceDirectory,
});
@@ -51,7 +51,7 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
}),
).toMatchObject({
id: "worktree-setup-missing:server-1:prj_local",
projectSettingsRoute: "/settings/projects/server-1/prj_local",
projectSettingsRoute: "/settings/hosts/server-1/projects/prj_local",
testID: "worktree-setup-callout-prj_local",
});
});