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
@@ -75,7 +75,7 @@ import {
useHostProjects,
type HostProjectListItem,
} from "@/projects/host-projects";
import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { useProjectIcons } from "@/projects/icons";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
@@ -1578,12 +1578,22 @@ export function NewWorkspaceScreen({
if (!iconWorkingDir) {
return [];
}
return [{ projectViewKey: project.viewKey, serverId: selectedServerId, iconWorkingDir }];
const host = project.hosts.find((candidate) => candidate.serverId === selectedServerId);
if (!host) return [];
return [
{
projectViewKey: project.viewKey,
projectId: host.projectId,
serverId: selectedServerId,
iconWorkingDir,
customIconRevision: host.customIconRevision,
},
];
}),
[projects, selectedServerId],
);
const projectIconDataByProjectViewKey = useProjectIconDataByProjectViewKey({
const projectIconDataByProjectViewKey = useProjectIcons({
projects: projectIconTargets,
});
const draftKey = buildNewWorkspaceDraftKey(draftId);
@@ -1,30 +0,0 @@
import { expect, test } from "vitest";
import { resolveProjectSettingsHostTarget } from "./project-settings-host-selection";
test("falls back when the selected host is no longer editable", () => {
const hostA = { serverId: "host-a", projectId: "project-a" };
const hostB = { serverId: "host-b", projectId: "project-b" };
expect(
resolveProjectSettingsHostTarget({
routeKey: "host-a:project-a",
editableHosts: [hostA],
defaultHost: hostA,
selection: { routeKey: "host-a:project-a", ...hostB },
}),
).toEqual(hostA);
});
test("keeps the selected host while it remains editable", () => {
const hostA = { serverId: "host-a", projectId: "project-a" };
const hostB = { serverId: "host-b", projectId: "project-b" };
expect(
resolveProjectSettingsHostTarget({
routeKey: "host-a:project-a",
editableHosts: [hostA, hostB],
defaultHost: hostA,
selection: { routeKey: "host-a:project-a", ...hostB },
}),
).toEqual(hostB);
});
@@ -1,40 +0,0 @@
export interface ProjectSettingsHostTarget {
serverId: string;
projectId: string;
}
export interface StoredProjectSettingsHostSelection extends ProjectSettingsHostTarget {
routeKey: string;
}
interface ResolveProjectSettingsHostTargetInput {
routeKey: string;
editableHosts: readonly ProjectSettingsHostTarget[];
defaultHost: ProjectSettingsHostTarget | undefined;
selection: StoredProjectSettingsHostSelection;
}
export function resolveProjectSettingsHostTarget(
input: ResolveProjectSettingsHostTargetInput,
): ProjectSettingsHostTarget | undefined {
const selectedHost =
input.selection.routeKey === input.routeKey
? getEditableHost(input.editableHosts, input.selection)
: undefined;
return (
selectedHost ??
getEditableHost(input.editableHosts, input.defaultHost) ??
input.editableHosts[0]
);
}
function getEditableHost(
editableHosts: readonly ProjectSettingsHostTarget[],
target: ProjectSettingsHostTarget | undefined,
): ProjectSettingsHostTarget | undefined {
if (!target) return undefined;
for (const host of editableHosts) {
if (host.serverId === target.serverId && host.projectId === target.projectId) return host;
}
return undefined;
}
@@ -1,13 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native";
import { router } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
import { ArrowLeft, MoreVertical, Pencil, Plus } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker";
import type {
PaseoConfigRaw,
PaseoConfigRevision,
@@ -26,13 +25,16 @@ import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ProjectEditSheet } from "@/components/project-edit-sheet";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { useProjects } from "@/hooks/use-projects";
import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import type { ProjectEditFormSnapshot } from "@/projects/edit-form";
import { useProjectIcons } from "@/projects/icons";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features";
import { useToast } from "@/contexts/toast-context";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
@@ -45,11 +47,12 @@ import {
type ProjectScriptDraft,
} from "@/utils/project-config-form";
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
import { getProjectHostEntry, getProjectSummaryForHostProject } from "@/utils/projects";
import { useSessionStore } from "@/stores/session-store";
import { selectProjectIdForServer } from "@/stores/session-store-hooks/selectors";
import { resolveProjectSettingsHostTarget } from "@/screens/project-settings-host-selection";
import {
getProjectHostEntry,
getProjectSummaryForHostProject,
type ProjectHostEntry,
type ProjectSummary,
} from "@/utils/projects";
const SCRIPT_SERVICE_TYPE = "service";
@@ -98,82 +101,47 @@ export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSe
() => getProjectSummaryForHostProject(projects, serverId, projectId),
[projectId, projects, serverId],
);
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);
const routeKey = `${serverId}:${projectId}`;
const routeHost = getProjectHostEntry(project, serverId, projectId);
const defaultHost = routeHost?.isOnline ? routeHost : editableHosts[0];
const [hostSelection, setHostSelection] = useState({
routeKey: "",
serverId: "",
projectId: "",
});
const selectedTarget = resolveProjectSettingsHostTarget({
routeKey,
editableHosts,
defaultHost,
selection: hostSelection,
});
const selectedServerId = selectedTarget?.serverId ?? "";
const selectedProjectId = selectedTarget?.projectId ?? "";
const setSelectedServerId = useCallback(
(targetServerId: string) => {
const targetProjectId = selectProjectIdForServer(useSessionStore.getState(), {
sourceServerId: serverId,
projectId,
targetServerId,
});
if (targetProjectId) {
setHostSelection({ routeKey, serverId: targetServerId, projectId: targetProjectId });
}
},
[projectId, routeKey, serverId],
);
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
const selectedHost = getProjectHostEntry(project, serverId, projectId);
const selectedSnapshot = useHostRuntimeSnapshot(serverId);
const isHostGone =
Boolean(selectedServerId) &&
Boolean(serverId) &&
(selectedSnapshot?.connectionStatus === "offline" ||
selectedSnapshot?.connectionStatus === "error");
const selectedHost = getProjectHostEntry(project, selectedServerId, selectedProjectId);
const client = useHostRuntimeClient(selectedHost?.serverId ?? "");
const client = useHostRuntimeClient(serverId);
const canEdit =
selectedHost?.isOnline === true &&
selectedHost.serverId.trim().length > 0 &&
selectedHost.repoRoot.trim().length > 0;
if (!project || editableHosts.length === 0 || !selectedHost || !client) {
return <NoEditableTarget />;
if (!project || !selectedHost || !client || !canEdit) {
return <NoEditableTarget serverId={serverId} />;
}
return (
<ProjectSettingsBody
project={project}
hosts={editableHosts}
selectedHost={selectedHost}
onSelectHost={setSelectedServerId}
client={client}
isHostGone={isHostGone}
/>
);
}
function filterEditableHosts(project: ProjectSummary | undefined): ProjectHostEntry[] {
if (!project) return [];
return project.hosts.filter(
(host) => host.isOnline && host.serverId.trim().length > 0 && host.repoRoot.trim().length > 0,
);
function navigateBackToProjects(serverId: string) {
router.navigate(buildProjectsSettingsRoute(serverId));
}
function navigateBackToProjects() {
router.navigate(buildProjectsSettingsRoute());
}
function NoEditableTarget() {
function NoEditableTarget({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const handleBack = useCallback(() => navigateBackToProjects(serverId), [serverId]);
return (
<View style={styles.noTargetContainer}>
<BackToProjectsButton />
<BackToProjectsButton serverId={serverId} />
<Text style={styles.noTargetText}>{t("settings.project.noEditableTarget")}</Text>
<Button
testID="project-settings-back-button"
onPress={navigateBackToProjects}
onPress={handleBack}
variant="secondary"
size="md"
>
@@ -183,13 +151,14 @@ function NoEditableTarget() {
);
}
function BackToProjectsButton() {
function BackToProjectsButton({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const handleBack = useCallback(() => navigateBackToProjects(serverId), [serverId]);
return (
<Button
testID="project-settings-back-link"
accessibilityLabel={t("settings.project.backToProjects")}
onPress={navigateBackToProjects}
onPress={handleBack}
variant="ghost"
size="sm"
leftIcon={ArrowLeft}
@@ -202,21 +171,25 @@ function BackToProjectsButton() {
interface ProjectSettingsBodyProps {
project: ProjectSummary;
hosts: ProjectHostEntry[];
selectedHost: ProjectHostEntry;
onSelectHost: (serverId: string) => void;
client: DaemonClient;
isHostGone: boolean;
}
function ProjectSettingsBody({
project,
hosts,
selectedHost,
onSelectHost,
client,
isHostGone,
}: ProjectSettingsBodyProps) {
const { t } = useTranslation();
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
const [editSessionId, setEditSessionId] = useState(0);
const openEditSheet = useCallback(() => {
setEditSessionId((id) => id + 1);
setIsEditSheetOpen(true);
}, []);
const closeEditSheet = useCallback(() => setIsEditSheetOpen(false), []);
const queryKey = useMemo(
() => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const,
[selectedHost.serverId, selectedHost.repoRoot],
@@ -229,20 +202,42 @@ function ProjectSettingsBody({
});
const data = readQuery.data;
const supportsCustomIcon = useHostFeature(selectedHost.serverId, "projectCustomIcon");
const customIconRevision = selectedHost.customIconRevision ?? null;
const projectIconTargets = useMemo(
() => [
{
serverId: selectedHost.serverId,
projectViewKey: project.viewKey,
projectId: selectedHost.projectId,
iconWorkingDir: selectedHost.repoRoot,
customIconRevision,
},
],
[project.viewKey, selectedHost.repoRoot, selectedHost.serverId],
[
customIconRevision,
project.viewKey,
selectedHost.projectId,
selectedHost.repoRoot,
selectedHost.serverId,
],
);
const projectIcons = useProjectIcons({ projects: projectIconTargets });
const projectIconDataUri = projectIcons.get(project.viewKey) ?? null;
const editSnapshot = useMemo<ProjectEditFormSnapshot>(
() => ({
projectName: selectedHost.projectName,
projectCustomName: selectedHost.projectCustomName,
hasCustomIcon: customIconRevision !== null,
currentIconDataUri: projectIconDataUri,
}),
[
customIconRevision,
projectIconDataUri,
selectedHost.projectCustomName,
selectedHost.projectName,
],
);
const projectIconDataByKey = useProjectIconDataByProjectViewKey({
projects: projectIconTargets,
});
const projectIconDataUri = projectIconDataByKey.get(project.viewKey) ?? null;
const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null;
const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null;
const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null;
@@ -251,30 +246,46 @@ function ProjectSettingsBody({
void readQuery.refetch();
}, [readQuery]);
const hasMultipleHosts = hosts.length > 1;
return (
<View role="main" style={styles.body}>
<BackToProjectsButton />
<BackToProjectsButton serverId={selectedHost.serverId} />
<View style={styles.headerBlock}>
<View style={styles.titleRow}>
<ProjectTitleIcon
iconDataUri={projectIconDataUri}
projectName={project.projectName}
projectName={selectedHost.projectName}
projectViewKey={project.viewKey}
/>
<ProjectNameEditor
key={`${selectedHost.serverId}:${selectedHost.projectId}`}
projectName={selectedHost.projectName}
projectCustomName={selectedHost.projectCustomName}
projectId={selectedHost.projectId}
client={client}
/>
<Text style={styles.projectTitle} numberOfLines={1}>
{selectedHost.projectName}
</Text>
<Pressable
testID="project-edit-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.edit.title")}
onPress={openEditSheet}
hitSlop={8}
style={styles.editButton}
>
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
</View>
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
</View>
<ProjectEditSheet
// A new instance per open seeds the form from the project as it stands now.
key={`${selectedHost.serverId}:${selectedHost.projectId}:${editSessionId}`}
visible={isEditSheetOpen}
onClose={closeEditSheet}
serverId={selectedHost.serverId}
projectId={selectedHost.projectId}
projectViewKey={project.viewKey}
client={client}
supportsCustomIcon={supportsCustomIcon}
snapshot={editSnapshot}
/>
{renderContent({
readQuery,
loadedConfig,
@@ -284,7 +295,6 @@ function ProjectSettingsBody({
queryKey,
client,
onReload: handleReload,
hasMultipleHosts,
isHostGone,
})}
</View>
@@ -300,7 +310,6 @@ interface RenderContentInput {
queryKey: readonly [string, string, string];
client: DaemonClient;
onReload: () => void;
hasMultipleHosts: boolean;
isHostGone: boolean;
}
@@ -313,7 +322,6 @@ function renderContent({
queryKey,
client,
onReload,
hasMultipleHosts,
isHostGone,
}: RenderContentInput) {
if (readQuery.isLoading) {
@@ -325,29 +333,15 @@ function renderContent({
}
if (readQuery.isError) {
return (
<ReadFailureCallout
kind="transport"
error={readQuery.error}
onReload={onReload}
hasMultipleHosts={hasMultipleHosts}
/>
);
return <ReadFailureCallout kind="transport" error={readQuery.error} onReload={onReload} />;
}
if (readError) {
return (
<ReadFailureCallout
kind={readError.code}
error={null}
onReload={onReload}
hasMultipleHosts={hasMultipleHosts}
/>
);
return <ReadFailureCallout kind={readError.code} error={null} onReload={onReload} />;
}
if (isHostGone) {
return <NoEditableTarget />;
return <NoEditableTarget serverId={selectedHost.serverId} />;
}
if (!loadedConfig) {
@@ -381,15 +375,13 @@ interface ReadFailureCalloutProps {
kind: "transport" | ProjectConfigRpcError["code"];
error: unknown;
onReload: () => void;
hasMultipleHosts: boolean;
}
function ReadFailureCallout({ kind, error, onReload, hasMultipleHosts }: ReadFailureCalloutProps) {
function ReadFailureCallout({ kind, error, onReload }: ReadFailureCalloutProps) {
const { t } = useTranslation();
const { testID, title, description } = resolveReadFailureCopy({
kind,
error,
hasMultipleHosts,
t,
});
return (
@@ -406,7 +398,6 @@ function ReadFailureCallout({ kind, error, onReload, hasMultipleHosts }: ReadFai
function resolveReadFailureCopy(input: {
kind: ReadFailureCalloutProps["kind"];
error: unknown;
hasMultipleHosts: boolean;
t: TFunction;
}): { testID: string; title: string; description: string } {
if (input.kind === "invalid_project_config") {
@@ -420,9 +411,7 @@ function resolveReadFailureCopy(input: {
return {
testID: "project-not-found-callout",
title: input.t("settings.project.readFailures.missingTitle"),
description: input.hasMultipleHosts
? input.t("settings.project.readFailures.missingWithHosts")
: input.t("settings.project.readFailures.missingSingleHost"),
description: input.t("settings.project.readFailures.missingSingleHost"),
};
}
if (input.kind === "transport") {
@@ -817,145 +806,6 @@ function ResolveSpinnerColor(): string {
return styles.spinnerColor.color;
}
interface ProjectNameEditorProps {
projectName: string;
projectCustomName: string | null;
projectId: string;
client: DaemonClient;
}
function ProjectNameEditor({
projectName,
projectCustomName,
projectId,
client,
}: ProjectNameEditorProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const toast = useToast();
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(projectCustomName ?? "");
const renameMutation = useMutation({
mutationFn: (customName: string | null) => client.renameProject(projectId, customName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
setIsEditing(false);
toast.show(t("settings.project.rename.renamedToast"), { variant: "success" });
},
onError: (error) => {
const message =
error instanceof Error ? error.message : t("settings.project.rename.errorFallback");
toast.show(message, { variant: "error" });
},
});
const handleStartEdit = useCallback(() => {
setValue(projectCustomName ?? "");
setIsEditing(true);
}, [projectCustomName]);
const handleCancel = useCallback(() => {
setIsEditing(false);
setValue(projectCustomName ?? "");
}, [projectCustomName]);
const handleSave = useCallback(() => {
const trimmed = value.trim();
const next = trimmed.length === 0 ? null : trimmed;
if (next === projectCustomName) {
setIsEditing(false);
return;
}
renameMutation.mutate(next);
}, [value, projectCustomName, renameMutation]);
const handleReset = useCallback(() => {
renameMutation.mutate(null);
}, [renameMutation]);
if (!isEditing) {
return (
<View
role="group"
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
style={styles.nameEditorRow}
>
<Text style={styles.projectTitle} numberOfLines={1}>
{projectName}
</Text>
<Pressable
testID="project-name-edit-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.renameLabel")}
onPress={handleStartEdit}
hitSlop={8}
style={styles.nameEditorIconButton}
>
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
{projectCustomName ? (
<Pressable
testID="project-name-reset-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.resetLabel")}
onPress={handleReset}
disabled={renameMutation.isPending}
hitSlop={8}
style={styles.nameEditorResetButton}
>
<Text style={styles.nameEditorResetText}>{t("settings.project.rename.reset")}</Text>
</Pressable>
) : null}
</View>
);
}
return (
<View
role="group"
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
style={styles.nameEditorRow}
>
<TextInput
testID="project-name-input"
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
value={value}
onChangeText={setValue}
placeholder={projectName}
placeholderTextColor={styles.placeholderColor.color}
autoFocus
style={styles.nameEditorInput}
editable={!renameMutation.isPending}
onSubmitEditing={handleSave}
returnKeyType="done"
/>
<Pressable
testID="project-name-save-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.saveLabel")}
onPress={handleSave}
disabled={renameMutation.isPending}
hitSlop={8}
style={styles.nameEditorIconButton}
>
<Check size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
<Pressable
testID="project-name-cancel-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.cancelLabel")}
onPress={handleCancel}
disabled={renameMutation.isPending}
hitSlop={8}
style={styles.nameEditorIconButton}
>
<X size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
</View>
);
}
function ProjectTitleIcon({
iconDataUri,
projectName,
@@ -978,74 +828,6 @@ function ProjectTitleIcon({
);
}
interface HostContextProps {
hosts: ProjectHostEntry[];
selectedHost: ProjectHostEntry;
onSelectHost: (serverId: string) => void;
}
function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) {
if (hosts.length > 1) {
return <HostPicker hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />;
}
return (
<View testID="host-indicator" style={styles.hostIndicator}>
<HostStatusDotSlot serverId={selectedHost.serverId} />
<Text style={styles.hostName} numberOfLines={1}>
{selectedHost.serverName}
</Text>
</View>
);
}
interface HostPickerProps {
hosts: ProjectHostEntry[];
selectedHost: ProjectHostEntry;
onSelectHost: (serverId: string) => void;
}
function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
const hostOptions = useMemo(
() => hosts.map((host) => ({ serverId: host.serverId, label: host.serverName })),
[hosts],
);
const handleOpen = useCallback(() => setOpen(true), []);
const hostOptionTestID = useCallback((serverId: string) => `host-picker-item-${serverId}`, []);
return (
<SharedHostPicker
hosts={hostOptions}
value={selectedHost.serverId}
onSelect={onSelectHost}
open={open}
onOpenChange={setOpen}
anchorRef={triggerRef}
searchable={false}
title={t("settings.project.switchHost")}
desktopPlacement="bottom-start"
desktopMinWidth={240}
hostOptionTestID={hostOptionTestID}
>
<Pressable
ref={triggerRef}
accessibilityRole="button"
accessibilityLabel={t("settings.project.switchHost")}
testID="host-picker"
style={styles.hostIndicator}
onPress={handleOpen}
>
<HostStatusDotSlot serverId={selectedHost.serverId} />
<Text style={styles.hostName} numberOfLines={1}>
{selectedHost.serverName}
</Text>
<ChevronDown size={ICON_SIZE} color={styles.chevronColor.color} />
</Pressable>
</SharedHostPicker>
);
}
interface MetadataPromptSectionProps {
promptKey: MetadataPromptKey;
value: string;
@@ -1320,37 +1102,9 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
flexShrink: 1,
},
nameEditorRow: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minWidth: 0,
},
nameEditorIconButton: {
editButton: {
padding: theme.spacing[1],
},
nameEditorInput: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
minWidth: 0,
},
nameEditorResetButton: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
},
nameEditorResetText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
titleIcon: {
width: 28,
height: 28,
@@ -1370,22 +1124,6 @@ const styles = StyleSheet.create((theme) => ({
iconColor: {
color: theme.colors.foregroundMuted,
},
hostIndicator: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
alignSelf: "flex-start",
minWidth: 0,
},
hostName: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
flexShrink: 1,
minWidth: 0,
},
centered: {
flex: 1,
alignItems: "center",
@@ -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");
+42 -40
View File
@@ -7,42 +7,49 @@ import { useTranslation } from "react-i18next";
import { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { useProjectIcons } from "@/projects/icons";
import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
interface ProjectsScreenProps {
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string };
serverId: string;
}
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
interface HostProject {
project: ProjectSummary;
host: ProjectHostEntry;
}
export default function ProjectsScreen({ serverId }: ProjectsScreenProps) {
const { t } = useTranslation();
const { projects, hostErrors, isLoading } = useProjects();
const selectedProjectViewKey =
view.kind === "project"
? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.viewKey ?? null)
: null;
const hostProjects = useMemo<HostProject[]>(
() =>
projects.flatMap((project) =>
project.hosts
.filter((host) => host.serverId === serverId)
.map((host) => ({ project, host })),
),
[projects, serverId],
);
const scopedErrors = hostErrors.filter((error) => error.serverId === serverId);
const iconTargets = useMemo(
() =>
projects.flatMap((project) => {
const host = project.hosts[0];
if (!host) return [];
return [
{
serverId: host.serverId,
projectViewKey: project.viewKey,
iconWorkingDir: host.repoRoot,
},
];
}),
[projects],
hostProjects.map(({ project, host }) => ({
serverId: host.serverId,
projectViewKey: project.viewKey,
projectId: host.projectId,
iconWorkingDir: host.repoRoot,
customIconRevision: host.customIconRevision,
})),
[hostProjects],
);
const iconDataByProjectViewKey = useProjectIconDataByProjectViewKey({
const iconDataByProjectViewKey = useProjectIcons({
projects: iconTargets,
});
if (isLoading && projects.length === 0) {
if (isLoading && hostProjects.length === 0) {
return (
<View style={styles.centered} testID="projects-list">
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
@@ -50,7 +57,7 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
);
}
if (projects.length === 0) {
if (hostProjects.length === 0) {
return (
<View style={styles.centered} testID="projects-list">
<Text style={styles.emptyText}>{t("sidebar.project.empty.title")}</Text>
@@ -60,14 +67,14 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
return (
<View testID="projects-list">
{hostErrors.length > 0 ? <HostErrorsBanner errors={hostErrors} /> : null}
{scopedErrors.length > 0 ? <HostErrorsBanner errors={scopedErrors} /> : null}
<View style={settingsStyles.card}>
{projects.map((project, index) => (
{hostProjects.map(({ project, host }, index) => (
<ProjectRow
key={project.viewKey}
key={host.projectId}
project={project}
host={host}
isFirst={index === 0}
isSelected={selectedProjectViewKey === project.viewKey}
iconDataUri={iconDataByProjectViewKey.get(project.viewKey) ?? null}
/>
))}
@@ -94,30 +101,29 @@ function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) {
interface ProjectRowProps {
project: ProjectSummary;
host: ProjectHostEntry;
isFirst: boolean;
isSelected: boolean;
iconDataUri: string | null;
}
function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) {
function ProjectRow({ project, host, isFirst, iconDataUri }: ProjectRowProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { viewKey, projectName } = project;
const target = project.hosts[0];
const { viewKey } = project;
const { projectName } = host;
const handleNavigate = useCallback(() => {
if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId));
}, [target]);
router.navigate(buildProjectSettingsRoute(host.serverId, host.projectId));
}, [host.projectId, host.serverId]);
const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
!isFirst && settingsStyles.rowBorder,
styles.row,
isSelected && styles.rowSelected,
hovered && !isSelected && styles.rowHovered,
hovered && styles.rowHovered,
pressed && styles.rowPressed,
],
[isFirst, isSelected],
[isFirst],
);
return (
@@ -127,7 +133,6 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
accessibilityRole="button"
accessibilityLabel={t("settings.projectList.editProject", { projectName })}
testID={`project-row-${viewKey}`}
data-selected={isSelected ? "true" : "false"}
>
<View style={styles.rowMain}>
<View style={styles.leading}>
@@ -207,9 +212,6 @@ const styles = StyleSheet.create((theme) => ({
rowPressed: {
backgroundColor: theme.colors.surface3,
},
rowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
leading: {
width: 16,
height: 16,
+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}