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:
co-authored by
Mohamed Boudra
parent
706ae3d9bb
commit
800bd2bef1
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user