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
+39 -76
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
@@ -112,7 +112,6 @@ import {
} from "@/desktop/hooks/use-enable-built-in-daemon-option";
import {
buildOpenProjectRoute,
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute,
buildSettingsSectionRoute,
type HostSectionSlug,
@@ -128,7 +127,6 @@ export type SettingsView =
| { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "projects" }
| { kind: "project"; serverId: string; projectId: string };
interface SidebarSectionItem {
@@ -167,6 +165,7 @@ interface HostSectionItem {
const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
{ id: "projects", labelKey: "settings.hostSections.projects", icon: FolderGit2 },
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
@@ -180,6 +179,8 @@ function renderHostSettingsContent(
onHostRemoved: () => void,
): ReactNode {
switch (view.section) {
case "projects":
return <ProjectsScreen serverId={view.serverId} />;
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "agents":
@@ -851,38 +852,6 @@ function SidebarHostSectionButton({
);
}
interface SidebarProjectsButtonProps {
isSelected: boolean;
onSelect: () => void;
}
function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onSelect}
testID="settings-projects"
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<FolderGit2
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
{t("settings.projects")}
</Text>
</Pressable>
);
}
interface HostPickerProps {
activeServerId: string | null;
sortedHosts: HostProfile[];
@@ -892,7 +861,7 @@ interface HostPickerProps {
}
/**
* Scopes the four host sections to a host. Reuses the canonical sidebar host
* Scopes the host sections to a host. Reuses the canonical sidebar host
* switcher pattern (left-sidebar.tsx): a quiet row-styled trigger opening a
* <Combobox>. The local host is listed first, each row shows the connection it
* is using right now; an "Add host" row is always reachable from the list —
@@ -970,7 +939,6 @@ interface SettingsSidebarProps {
onSelectSection: (section: SettingsSectionSlug) => void;
onSelectHostSection: (section: HostSectionSlug) => void;
onSelectHost: (serverId: string) => void;
onSelectProjects: () => void;
onAddHost: () => void;
onBackToWorkspace: () => void;
activeHostServerId: string | null;
@@ -982,7 +950,6 @@ function SettingsSidebar({
onSelectSection,
onSelectHostSection,
onSelectHost,
onSelectProjects,
onAddHost,
onBackToWorkspace,
activeHostServerId,
@@ -1008,26 +975,23 @@ function SettingsSidebar({
[insets.top, isDesktop],
);
const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedHostSection = view.kind === "host" ? view.section : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
let selectedHostSection: HostSectionSlug | null = null;
if (view.kind === "host") selectedHostSection = view.section;
if (view.kind === "project") selectedHostSection = "projects";
const sidebarBody = (
<>
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.app")}</Text>
{items.map((item) => (
<Fragment key={item.id}>
<SidebarSectionButton
itemId={item.id}
label={t(item.labelKey)}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
{item.id === "general" ? (
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
) : null}
</Fragment>
<SidebarSectionButton
key={item.id}
itemId={item.id}
label={t(item.labelKey)}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
))}
</View>
<SidebarSeparator />
@@ -1146,10 +1110,10 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId);
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
view.kind === "host" ? view.serverId : null,
view.kind === "host" || view.kind === "project" ? view.serverId : null,
);
useEffect(() => {
if (view.kind === "host") {
if (view.kind === "host" || view.kind === "project") {
setSelectedSettingsHostServerId(view.serverId);
}
}, [view]);
@@ -1157,7 +1121,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
// The host the four sections scope to: the host on the active view, otherwise
// the picker choice, otherwise the connected local daemon, otherwise the first host.
const activeHostServerId = useMemo(() => {
if (view.kind === "host") return view.serverId;
if (view.kind === "host" || view.kind === "project") return view.serverId;
return resolveActiveHostServerId({
selectedServerId: selectedSettingsHostServerId,
localServerId,
@@ -1286,6 +1250,15 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const handleSelectHost = useCallback(
(serverId: string) => {
setSelectedSettingsHostServerId(serverId);
if (view.kind === "project") {
const target = buildSettingsHostSectionRoute(serverId, "projects");
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
return;
}
if (view.kind !== "host") {
return;
}
@@ -1316,15 +1289,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[activeHostServerId, handleAddHost, isCompactLayout, router],
);
const handleSelectProjects = useCallback(() => {
const target = buildProjectsSettingsRoute();
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
}, [isCompactLayout, router]);
const handleScanQr = useCallback(() => {
closeAddConnectionFlow();
router.push({
@@ -1350,6 +1314,15 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
}
}, [router]);
const detailProjectServerId = view.kind === "project" ? view.serverId : null;
const handleBackFromDetail = useCallback(() => {
if (detailProjectServerId) {
router.navigate(buildSettingsHostSectionRoute(detailProjectServerId, "projects"));
return;
}
handleBackToRoot();
}, [detailProjectServerId, handleBackToRoot, router]);
const handleBackToWorkspace = useCallback(() => {
if (navigateToLastWorkspace()) {
return;
@@ -1372,7 +1345,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
if (!item) return null;
return { title: t(item.labelKey), Icon: item.icon };
}
if (view.kind === "project" || view.kind === "projects") {
if (view.kind === "project") {
return { title: t("settings.projects"), Icon: FolderGit2 };
}
return null;
@@ -1382,9 +1355,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
if (view.kind === "host") {
return renderHostSettingsContent(view, handleHostRemoved);
}
if (view.kind === "projects") {
return <ProjectsScreen view={view} />;
}
if (view.kind === "project") {
return <ProjectSettingsScreen serverId={view.serverId} projectId={view.projectId} />;
}
@@ -1489,7 +1459,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
@@ -1501,18 +1470,13 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
);
}
// Mobile detail: full-screen content with a back header. Project detail uses
// an app-level back (out of settings, to the workspace) since the in-body
// "Back to projects" ghost button handles list-level back; other detail views
// step back to the settings root.
const detailBackHandler = view.kind === "project" ? handleBackToWorkspace : handleBackToRoot;
if (isCompactLayout) {
return (
<View style={styles.container}>
<BackHeader
title={detailHeader?.title}
titleAccessory={detailHeader?.titleAccessory}
onBack={detailBackHandler}
onBack={handleBackFromDetail}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
@@ -1534,7 +1498,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}