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
+23 -18
View File
@@ -2,7 +2,9 @@ import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts";
import { openProjectSettings } from "./project-settings";
import { selectSettingsHost } from "./settings";
import { waitForSidebarHydration } from "./workspace-ui";
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
const PROJECT_VISIBILITY_TIMEOUT = 30_000;
@@ -98,34 +100,37 @@ export async function expectProjectWorkspaceCountForHost(
}
export async function renameProject(page: Page, name: string): Promise<void> {
await page.getByRole("button", { name: "Rename project", exact: true }).click();
await page.getByRole("button", { name: "Edit project", exact: true }).click();
await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name);
await page.getByRole("button", { name: "Save project name", exact: true }).click();
await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({
await page.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(page.getByText("Project updated", { exact: true })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
export async function openGroupedProjectSettings(page: Page, projectName: string): Promise<void> {
export async function openGroupedProjectSettings(
page: Page,
input: { serverId: string; projectName: string },
): Promise<void> {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/general$/);
await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/projects$/);
await openProjectSettings(page, projectName);
await selectSettingsHost(page, input.serverId);
await page.locator('[data-testid="settings-host-section-projects"]:visible').click();
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/projects$/);
await openProjectSettings(page, input.projectName);
}
export async function switchProjectSettingsHost(page: Page, hostName: string): Promise<void> {
const projectSettings = page.getByRole("main");
await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click();
await page.getByRole("button", { name: hostName, exact: true }).click();
await expect(
projectSettings.getByRole("button", { name: "Switch host", exact: true }),
).toContainText(hostName);
export async function openProjectsForSettingsHost(
page: Page,
input: { serverId: string; projectName: string },
): Promise<void> {
await selectSettingsHost(page, input.serverId);
await expect(page).toHaveURL(buildProjectsSettingsRoute(input.serverId));
await openProjectSettings(page, input.projectName);
}
export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> {
await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText(
projectName,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
await expect(page.getByRole("main").getByText(projectName, { exact: true })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
+78 -11
View File
@@ -4,6 +4,8 @@ import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { daemonWsRoutePattern } from "./daemon-port";
import { getServerId } from "./server-id";
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
type WebSocketMessage = string | Buffer;
@@ -33,8 +35,8 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
export async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
}
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
@@ -48,6 +50,21 @@ export async function navigateToProjectSettings(page: Page, projectName: string)
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
}
export async function returnToProjectsList(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back to projects", exact: true }).click();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
}
export async function expectProjectSettingsHistoryRoundTrip(
page: Page,
projectName: string,
): Promise<void> {
await page.goBack();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
await page.goForward();
await expectProjectTitle(page, projectName);
}
// --- Form interactions ---
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
@@ -57,7 +74,7 @@ export async function editWorktreeSetup(page: Page, setupCommands: string[]): Pr
}
export async function clickSaveProjectSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save" }).click();
await page.getByTestId("save-button").click();
}
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
@@ -72,6 +89,61 @@ export async function clickReloadProjectSettings(page: Page): Promise<void> {
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
}
// --- Project edit sheet (name + icon) ---
export async function openProjectEditSheet(page: Page): Promise<void> {
await page.getByRole("button", { name: "Edit project", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Project name" })).toBeVisible();
}
export async function fillProjectName(page: Page, name: string): Promise<void> {
await page.getByRole("textbox", { name: "Project name" }).fill(name);
}
export async function chooseProjectIconImage(
page: Page,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
await page.getByRole("button", { name: "Choose image" }).click();
await (await chooserPromise).setFiles([file]);
await expect(page.getByText(file.name, { exact: true })).toBeVisible();
}
export async function fillProjectIconUrl(page: Page, url: string): Promise<void> {
await page.getByRole("textbox", { name: "Image or website URL" }).fill(url);
}
export async function useAutomaticProjectIcon(page: Page): Promise<void> {
await page.getByRole("button", { name: "Use automatic" }).click();
}
export async function saveProjectEdits(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save changes" }).click();
}
export async function expectProjectEditName(page: Page, name: string): Promise<void> {
await expect(page.getByRole("textbox", { name: "Project name" })).toHaveValue(name);
}
export async function expectProjectEditsSaveDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled();
}
export async function expectProjectEditSaved(page: Page): Promise<void> {
await expect(page.getByTestId("app-toast-message")).toHaveText("Project updated");
}
// The sheet keeps the user's input on a failed save so the value stays editable.
export async function expectProjectEditFailed(page: Page, detail: string): Promise<void> {
await expect(page.getByText(detail, { exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Project name" })).toBeVisible();
}
export async function expectProjectTitle(page: Page, projectName: string): Promise<void> {
await expect(page.getByRole("main").getByText(projectName, { exact: true })).toBeVisible();
}
// --- Error-state assertions ---
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
@@ -102,7 +174,7 @@ export async function expectWriteFailedCalloutActions(page: Page): Promise<void>
}
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save" })).toBeDisabled();
await expect(page.getByTestId("save-button")).toBeDisabled();
}
// --- Form-state assertions ---
@@ -121,13 +193,8 @@ export async function expectNoEditableTarget(page: Page): Promise<void> {
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
}
// --- Host-section assertions ---
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).toBeVisible();
}
export async function expectHostPickerHidden(page: Page): Promise<void> {
export async function expectProjectHostContextHidden(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).not.toBeVisible();
await expect(page.getByTestId("host-picker")).not.toBeVisible();
}
+13 -12
View File
@@ -3,7 +3,6 @@ import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from ".
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import {
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute,
buildSettingsRoute,
buildSettingsSectionRoute,
@@ -30,20 +29,21 @@ const SECTION_LABELS = {
about: "About",
} as const;
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
export type SettingsSection = keyof typeof SECTION_LABELS;
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host";
type HostSection =
| "projects"
| "connections"
| "agents"
| "workspaces"
| "providers"
| "usage"
| "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
if (section === "projects") {
await page.getByTestId("settings-projects").click();
await expectAppRoute(page, buildProjectsSettingsRoute());
return;
}
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
await expectAppRoute(page, buildSettingsSectionRoute(section));
}
@@ -143,8 +143,8 @@ export async function seedSavedSettingsHosts(
}
export async function selectSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId("settings-host-picker").click();
await page.getByTestId(`settings-host-picker-item-${serverId}`).click();
await page.locator('[data-testid="settings-host-picker"]:visible').click();
await page.locator(`[data-testid="settings-host-picker-item-${serverId}"]:visible`).click();
}
export async function expectSettingsHostPickerLabel(page: Page, label: string): Promise<void> {
@@ -171,7 +171,7 @@ export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
export async function expectSettingsSidebarSections(
page: Page,
sections: Array<Exclude<SettingsSection, "projects">>,
sections: SettingsSection[],
): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
for (const section of sections) {
@@ -376,6 +376,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
// Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-projects")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
+98 -5
View File
@@ -5,29 +5,40 @@ import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import {
blockPaseoConfigWrites,
bumpPaseoConfigOnDisk,
chooseProjectIconImage,
clickReloadProjectSettings,
clickRetryProjectSettingsSave,
clickSaveProjectSettings,
corruptPaseoConfig,
editWorktreeSetup,
expectEmptyScriptList,
expectHostIndicatorVisible,
expectHostPickerHidden,
expectProjectHostContextHidden,
expectNoEditableTarget,
expectNoProjectSettingsError,
expectProjectEditFailed,
expectProjectEditName,
expectProjectEditSaved,
expectProjectEditsSaveDisabled,
expectProjectSettingsError,
expectProjectSettingsFormHidden,
expectProjectSettingsFormVisible,
expectProjectTitle,
expectProjectSettingsHistoryRoundTrip,
expectSaveButtonDisabled,
expectScriptRowCount,
expectWriteFailedCalloutActions,
fillProjectIconUrl,
fillProjectName,
installDaemonConnectionGate,
installReadTransportFailure,
navigateToProjectSettings,
openProjectEditSheet,
openProjectSettings,
openProjects,
removeProjectScript,
restorePaseoConfig,
returnToProjectsList,
saveProjectEdits,
unblockPaseoConfigWrites,
} from "./helpers/project-settings";
import { gotoAppShell } from "./helpers/app";
@@ -40,6 +51,12 @@ import { createTempGitRepo } from "./helpers/workspace";
const updatedSetup = ["npm install", "npm run build"];
// Smallest valid square PNG the daemon will accept as a custom project icon.
const PNG_1X1 = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00,
]);
interface ProjectsSettingsProject {
name: string;
path: string;
@@ -204,6 +221,15 @@ test.describe("Projects settings", () => {
await expectProjectConfigSaved(editableProject);
});
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectProjectHostContextHidden(page);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
});
test("user edits worktree setup on a non-GitHub remote project", async ({
page,
gitlabRemoteProject,
@@ -215,6 +241,74 @@ test.describe("Projects settings", () => {
await clickSaveProjectSettings(page);
await expectProjectConfigSaved(gitlabRemoteProject);
});
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
});
test("reopening the edit sheet seeds from the saved project", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await openProjectEditSheet(page);
await expectProjectEditName(page, "Renamed project");
await expectProjectEditsSaveDisabled(page);
});
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
});
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Both at once");
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Both at once");
});
test("project edit keeps a rejected icon URL actionable", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectIconUrl(page, "file:///etc/passwd");
await saveProjectEdits(page);
await expectProjectEditFailed(page, "URL must use HTTP or HTTPS without credentials");
});
});
test.describe("Projects settings — error UX", () => {
@@ -322,15 +416,14 @@ test.describe("Projects settings — error UX", () => {
await expectNoEditableTarget(page);
});
test("single-host project renders static host indicator, not a picker chip", async ({
test("project detail does not render a second host selector", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectHostIndicatorVisible(page);
await expectHostPickerHidden(page);
await expectProjectHostContextHidden(page);
});
test("script removal via kebab menu removes the row from the form", async ({
@@ -9,11 +9,11 @@ import {
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
openProjectsForSettingsHost,
openProjectDirectory,
openProjectDirectoryWithHosts,
renameProject,
selectWorkspaceHost,
switchProjectSettingsHost,
} from "./helpers/project-grouping";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
@@ -380,10 +380,18 @@ test.describe("Sidebar project grouping", () => {
test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => {
await openScenario(page, crossHostProject);
await openGroupedProjectSettings(page, GROUPED_PROJECT_NAME);
await openGroupedProjectSettings(page, {
serverId: getServerId(),
projectName: GROUPED_PROJECT_NAME,
});
await renameProject(page, "Primary-only project name");
await expectProjectSettingsName(page, "Primary-only project name");
await switchProjectSettingsHost(page, SECONDARY_HOST_LABEL);
const secondaryHost = crossHostProject.hosts[0];
if (!secondaryHost) throw new Error("Expected a secondary host");
await openProjectsForSettingsHost(page, {
serverId: secondaryHost.serverId,
projectName: GROUPED_PROJECT_NAME,
});
await expectProjectSettingsName(page, GROUPED_PROJECT_NAME);
});
+2 -2
View File
@@ -882,8 +882,6 @@ function RootStack() {
<Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[serverId]/[projectId]" />
<Stack.Screen name="new" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
@@ -893,6 +891,8 @@ function RootStack() {
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
</ThemedStack>
);
}
@@ -1,9 +1,10 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
export default function SettingsProjectDetailRoute() {
export default function SettingsHostProjectDetailRoute() {
const params = useLocalSearchParams<{
serverId?: string | string[];
projectId?: string | string[];
@@ -15,5 +16,9 @@ export default function SettingsProjectDetailRoute() {
[projectId, serverId],
);
return <SettingsScreen view={view} />;
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -0,0 +1,20 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
export default function SettingsHostProjectsRoute() {
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
const serverId = normalizeProjectSettingsRouteId(params.serverId);
const view = useMemo(
() => ({ kind: "host" as const, serverId, section: "projects" as const }),
[serverId],
);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -1,7 +0,0 @@
import SettingsScreen from "@/screens/settings-screen";
const PROJECTS_VIEW = { kind: "projects" as const };
export default function SettingsProjectsIndexRoute() {
return <SettingsScreen view={PROJECTS_VIEW} />;
}
@@ -0,0 +1,301 @@
import { Buffer } from "buffer";
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ProjectIconView } from "@/components/project-icon-view";
import { Button } from "@/components/ui/button";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useFilePicker } from "@/hooks/use-file-picker";
import {
openProjectEditForm,
type ProjectEditFormError,
type ProjectEditFormSnapshot,
type ProjectEditSubmission,
type ProjectIconIntent,
} from "@/projects/edit-form";
import { importProjectIconFromUrl } from "@/projects/import-project-icon";
import { toErrorMessage } from "@/utils/error-messages";
export interface ProjectEditSheetProps {
visible: boolean;
onClose: () => void;
serverId: string;
projectId: string;
projectViewKey: string;
client: DaemonClient;
/** False on hosts that predate custom project icons — the icon field is hidden. */
supportsCustomIcon: boolean;
snapshot: ProjectEditFormSnapshot;
}
/** Editing a project is its name and its icon, decided together and saved once. */
export function ProjectEditSheet({
visible,
onClose,
serverId,
projectId,
projectViewKey,
client,
supportsCustomIcon,
snapshot,
}: ProjectEditSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const toast = useToast();
const { pickFiles } = useFilePicker();
const size: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const [form] = useState(() => openProjectEditForm(snapshot));
const state = useSyncExternalStore(form.subscribe, form.getState, form.getState);
const mutation = useMutation({
mutationFn: (submission: ProjectEditSubmission) =>
submitProjectEdit({ client, projectId, submission }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["projectIcon", serverId] });
toast.show(t("settings.project.edit.savedToast"), { variant: "success" });
onClose();
},
onError: (error) => form.setError(toFormError(error)),
});
const isSaving = mutation.isPending;
const handleClose = useCallback(() => {
if (isSaving) return;
onClose();
}, [isSaving, onClose]);
const handleSubmit = useCallback(() => {
mutation.mutate(state.submission);
}, [mutation, state.submission]);
const chooseImage = useCallback(async () => {
const file = (await pickFiles())?.[0];
if (!file) return;
form.setPickedImage({
fileName: file.fileName,
mimeType: file.mimeType,
data: Buffer.from(file.bytes).toString("base64"),
});
}, [form, pickFiles]);
const handleChooseImage = useCallback(() => {
void chooseImage();
}, [chooseImage]);
const header = useMemo<SheetHeader>(() => ({ title: t("settings.project.edit.title") }), [t]);
const footer = useMemo(
() => (
<View style={styles.footer}>
<Button
variant="secondary"
size="md"
style={styles.footerButton}
onPress={handleClose}
disabled={isSaving}
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="md"
style={styles.footerButton}
onPress={handleSubmit}
disabled={!state.canSubmit}
loading={isSaving}
testID="project-edit-save"
>
{t("settings.project.edit.save")}
</Button>
</View>
),
[handleClose, handleSubmit, isSaving, state.canSubmit, t],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={handleClose}
footer={footer}
desktopMaxWidth={440}
// Bound the compact scroller to the live snap height so the footer stays
// on screen instead of being pushed past the bottom of the sheet.
sizeContentToCurrentSnapPoint
testID="project-edit-sheet"
>
<Field
label={t("settings.project.edit.name")}
error={state.error?.scope === "name" ? state.error.message : null}
>
<FormTextInput
size={size}
testID="project-edit-name"
accessibilityLabel={t("settings.project.edit.nameLabel")}
initialValue={state.name}
onChangeText={form.setName}
placeholder={snapshot.projectName}
editable={!isSaving}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
{supportsCustomIcon ? (
<Field
label={t("settings.project.edit.icon")}
hint={state.pickedFileName ?? undefined}
error={state.error?.scope === "icon" ? state.error.message : null}
>
<View style={styles.iconField}>
<View style={styles.iconRow}>
<ProjectIconView
iconDataUri={state.previewDataUri}
initial={projectInitial(snapshot.projectName)}
projectViewKey={projectViewKey}
imageStyle={styles.preview}
fallbackStyle={styles.previewFallback}
textStyle={styles.previewText}
/>
<Button
variant="outline"
size={size}
onPress={handleChooseImage}
disabled={isSaving}
testID="project-edit-choose-image"
>
{t("settings.project.edit.chooseImage")}
</Button>
{state.canUseAutomatic ? (
<Button
variant="ghost"
size={size}
onPress={form.useAutomaticIcon}
disabled={isSaving}
testID="project-edit-use-automatic"
>
{t("settings.project.edit.useAutomatic")}
</Button>
) : null}
</View>
<FormTextInput
size={size}
testID="project-edit-image-url"
accessibilityLabel={t("settings.project.edit.imageUrl")}
initialValue=""
resetKey={state.urlResetKey}
onChangeText={form.setImageUrl}
placeholder={t("settings.project.edit.imageUrl")}
editable={!isSaving}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
</Field>
) : null}
</AdaptiveModalSheet>
);
}
function projectInitial(projectName: string): string {
return projectName.trim().charAt(0).toUpperCase() || "?";
}
class ProjectEditStepError extends Error {
constructor(
readonly scope: ProjectEditFormError["scope"],
cause: unknown,
) {
super(toErrorMessage(cause));
this.name = "ProjectEditStepError";
}
}
async function step<T>(scope: ProjectEditFormError["scope"], run: () => Promise<T>): Promise<T> {
try {
return await run();
} catch (error) {
throw new ProjectEditStepError(scope, error);
}
}
/**
* One submit, up to two RPCs. Icon bytes are acquired before either RPC runs
* because that is the step most likely to fail; past that point both RPCs take
* the same inputs on a retry, so a failure between them is safe to submit again.
*/
async function submitProjectEdit(input: {
client: DaemonClient;
projectId: string;
submission: ProjectEditSubmission;
}): Promise<void> {
const { client, projectId, submission } = input;
const { rename, icon } = submission;
const source = icon ? await step("icon", () => acquireIconSource(icon)) : null;
if (rename) {
await step("name", () => client.renameProject(projectId, rename.customName));
}
if (source) {
await step("icon", () => client.setProjectIcon(projectId, source));
}
}
function acquireIconSource(intent: ProjectIconIntent): Promise<ProjectIconSource> {
if (intent.type === "url") return importProjectIconFromUrl(intent.url);
return Promise.resolve(intent);
}
function toFormError(error: unknown): ProjectEditFormError {
if (error instanceof ProjectEditStepError) {
return { scope: error.scope, message: error.message };
}
return { scope: "name", message: toErrorMessage(error) };
}
const styles = StyleSheet.create((theme) => ({
iconField: {
gap: theme.spacing[2],
},
iconRow: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
},
preview: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.md,
},
previewFallback: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
previewText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
footer: {
flex: 1,
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
footerButton: {
minWidth: 112,
},
}));
@@ -57,7 +57,7 @@ import {
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { useProjectIcons } from "@/projects/icons";
import {
buildNewWorkspaceRoute,
buildProjectSettingsRoute,
@@ -1958,7 +1958,7 @@ export function SidebarWorkspaceList({
() => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []),
[groupMode, projects],
);
const statusProjectIconByProjectViewKey = useProjectIconDataByProjectViewKey({
const statusProjectIconByProjectViewKey = useProjectIcons({
projects: statusProjectIconTargets,
});
@@ -2127,7 +2127,7 @@ function ProjectModeList({
[parentGestureRef],
);
const projectIconByProjectViewKey = useProjectIconDataByProjectViewKey({
const projectIconByProjectViewKey = useProjectIcons({
projects: projectIconTargets,
});
@@ -8,7 +8,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useProjectIcon } from "@/projects/icons";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
@@ -195,7 +195,7 @@ export function WorkspaceSetupDialog() {
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
}
const { icon: projectIcon } = useProjectIconQuery({
const { icon: projectIcon } = useProjectIcon({
serverId,
cwd: sourceDirectory,
});
@@ -51,7 +51,7 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
}),
).toMatchObject({
id: "worktree-setup-missing:server-1:prj_local",
projectSettingsRoute: "/settings/projects/server-1/prj_local",
projectSettingsRoute: "/settings/hosts/server-1/projects/prj_local",
testID: "worktree-setup-callout-prj_local",
});
});
@@ -96,6 +96,7 @@ describe("openProjectDirectly", () => {
projectKey: null,
projectDisplayName: "project",
projectCustomName: null,
projectCustomIconRevision: null,
projectKind: "git",
projectRootPath: PROJECT_PATH,
},
@@ -193,6 +194,7 @@ describe("cloneGithubProjectDirectly", () => {
...projectPayload,
projectCustomName: null,
projectKey: null,
projectCustomIconRevision: null,
},
},
]);
@@ -1,49 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { ProjectIcon } from "@getpaseo/protocol/messages";
export function projectIconQueryKey(serverId: string, cwd: string) {
return ["projectIcon", serverId, cwd] as const;
}
export function projectIconToDataUri(icon: ProjectIcon | null): string | null {
if (!icon) {
return null;
}
return `data:${icon.mimeType};base64,${icon.data}`;
}
interface UseProjectIconQueryOptions {
serverId: string;
cwd: string;
}
export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({
queryKey: projectIconQueryKey(serverId, cwd),
queryFn: async (): Promise<ProjectIcon | null> => {
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const result = await client.requestProjectIcon(cwd);
return result.icon;
},
enabled: !!client && isConnected && !!cwd,
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
return {
icon: query.data ?? null,
isLoading: query.isLoading,
isError: query.isError,
};
}
+12 -12
View File
@@ -1621,6 +1621,7 @@ export const ar: TranslationResources = {
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
},
hostSections: {
projects: "المشاريع",
connections: "اتصالات",
agents: "Agents",
workspaces: "Workspaces",
@@ -2121,24 +2122,23 @@ export const ar: TranslationResources = {
},
},
project: {
noEditableTarget: "ليس لدينا نسخة قابلة للتحرير من هذا المشروع على أي مضيف متصل.",
noEditableTarget: "هذا المشروع غير متاح للتحرير على هذا المضيف.",
backToProjects: "العودة إلى المشاريع",
switchHost: "تبديل المضيف",
rename: {
renamedToast: "تمت إعادة تسمية المشروع",
errorFallback: "تعذرت إعادة تسمية المشروع",
renameLabel: "إعادة تسمية المشروع",
resetLabel: "إعادة تعيين اسم المشروع إلى الافتراضي",
projectNameLabel: "اسم المشروع",
saveLabel: "احفظ اسم المشروع",
cancelLabel: "إلغاء إعادة التسمية",
reset: "إعادة ضبط",
edit: {
title: "تعديل المشروع",
name: "الاسم",
nameLabel: "اسم المشروع",
icon: "الأيقونة",
chooseImage: "اختيار صورة",
useAutomatic: "استخدام التلقائي",
imageUrl: "رابط صورة أو موقع",
save: "حفظ التغييرات",
savedToast: "تم تحديث المشروع",
},
readFailures: {
invalidTitle: "تعذر تحليل paseo.json",
invalidDescription: "قم بإصلاح الملف على القرص، ثم أعد تحميله.",
missingTitle: "هذا المضيف ليس لديه هذا المشروع",
missingWithHosts: "قم بالتبديل إلى مضيف آخر أعلاه، أو أعد التحميل.",
missingSingleHost: "المضيف المحدد ليس لديه سجل لهذا المشروع.",
transportTitle: "تعذر تحميل paseo.json",
transportFallback: "المضيف لم يستجب.",
+12 -12
View File
@@ -1632,6 +1632,7 @@ export const en = {
vimHint: "Applies to source files on web and desktop.",
},
hostSections: {
projects: "Projects",
connections: "Connections",
agents: "Agents",
workspaces: "Workspaces",
@@ -2133,24 +2134,23 @@ export const en = {
},
},
project: {
noEditableTarget: "We don't have an editable copy of this project on any connected host.",
noEditableTarget: "This project isn't editable on this host.",
backToProjects: "Back to projects",
switchHost: "Switch host",
rename: {
renamedToast: "Project renamed",
errorFallback: "Couldn't rename project",
renameLabel: "Rename project",
resetLabel: "Reset project name to default",
projectNameLabel: "Project name",
saveLabel: "Save project name",
cancelLabel: "Cancel renaming",
reset: "Reset",
edit: {
title: "Edit project",
name: "Name",
nameLabel: "Project name",
icon: "Icon",
chooseImage: "Choose image",
useAutomatic: "Use automatic",
imageUrl: "Image or website URL",
save: "Save changes",
savedToast: "Project updated",
},
readFailures: {
invalidTitle: "paseo.json couldn't be parsed",
invalidDescription: "Fix the file on disk, then reload.",
missingTitle: "This host doesn't have this project",
missingWithHosts: "Switch to another host above, or reload.",
missingSingleHost: "The selected host has no record of this project.",
transportTitle: "Couldn't load paseo.json",
transportFallback: "The host didn't respond.",
+12 -12
View File
@@ -1664,6 +1664,7 @@ export const es: TranslationResources = {
vimHint: "Se aplica a archivos fuente en web y escritorio.",
},
hostSections: {
projects: "Proyectos",
connections: "Conexiones",
agents: "Agents",
workspaces: "Workspaces",
@@ -2173,24 +2174,23 @@ export const es: TranslationResources = {
},
},
project: {
noEditableTarget: "No tenemos una copia editable de este proyecto en ningún host conectado.",
noEditableTarget: "Este proyecto no se puede editar en este host.",
backToProjects: "Volver a proyectos",
switchHost: "Cambiar de anfitrión",
rename: {
renamedToast: "Proyecto renombrado",
errorFallback: "No se pudo cambiar el nombre del proyecto",
renameLabel: "Cambiar nombre del proyecto",
resetLabel: "Restablecer el nombre del proyecto al valor predeterminado",
projectNameLabel: "Nombre del proyecto",
saveLabel: "Guardar nombre del proyecto",
cancelLabel: "Cancelar cambio de nombre",
reset: "Reiniciar",
edit: {
title: "Editar proyecto",
name: "Nombre",
nameLabel: "Nombre del proyecto",
icon: "Icono",
chooseImage: "Elegir imagen",
useAutomatic: "Usar automático",
imageUrl: "URL de imagen o sitio web",
save: "Guardar cambios",
savedToast: "Proyecto actualizado",
},
readFailures: {
invalidTitle: "paseo.json no se pudo analizar",
invalidDescription: "Fije el archivo en el disco y luego vuelva a cargarlo.",
missingTitle: "Este anfitrión no tiene este proyecto",
missingWithHosts: "Cambie a otro host de arriba o vuelva a cargar.",
missingSingleHost: "El anfitrión seleccionado no tiene registro de este proyecto.",
transportTitle: "No se pudo cargar paseo.json",
transportFallback: "El anfitrión no respondió.",
+12 -13
View File
@@ -1668,6 +1668,7 @@ export const fr: TranslationResources = {
vimHint: "S’applique aux fichiers source sur le web et le bureau.",
},
hostSections: {
projects: "Projets",
connections: "Relations",
agents: "Agents",
workspaces: "Workspaces",
@@ -2177,25 +2178,23 @@ export const fr: TranslationResources = {
},
},
project: {
noEditableTarget:
"Nous n'avons pas de copie modifiable de ce projet sur aucun hôte connecté.",
noEditableTarget: "Ce projet n'est pas modifiable sur cet hôte.",
backToProjects: "Retour aux projets",
switchHost: "Changer d'hôte",
rename: {
renamedToast: "Projet renommé",
errorFallback: "Impossible de renommer le projet",
renameLabel: "Renommer le projet",
resetLabel: "Réinitialiser le nom du projet par défaut",
projectNameLabel: "Nom du projet",
saveLabel: "Enregistrer le nom du projet",
cancelLabel: "Annuler le changement de nom",
reset: "Réinitialiser",
edit: {
title: "Modifier le projet",
name: "Nom",
nameLabel: "Nom du projet",
icon: "Icône",
chooseImage: "Choisir une image",
useAutomatic: "Utiliser l'automatique",
imageUrl: "URL d'image ou de site",
save: "Enregistrer",
savedToast: "Projet mis à jour",
},
readFailures: {
invalidTitle: "paseo.json n'a pas pu être analysé",
invalidDescription: "Corrigez le fichier sur le disque, puis rechargez.",
missingTitle: "Cet hôte n'a pas ce projet",
missingWithHosts: "Basculez vers un autre hôte ci-dessus ou rechargez.",
missingSingleHost: "L'hôte sélectionné n'a aucune trace de ce projet.",
transportTitle: "Impossible de charger paseo.json",
transportFallback: "L'hôte n'a pas répondu.",
+12 -13
View File
@@ -1637,6 +1637,7 @@ export const ja: TranslationResources = {
vimHint: "Web とデスクトップのソースファイルに適用されます。",
},
hostSections: {
projects: "プロジェクト",
connections: "接続",
agents: "エージェント",
workspaces: "ワークスペース",
@@ -2143,25 +2144,23 @@ export const ja: TranslationResources = {
},
},
project: {
noEditableTarget:
"接続されているホストのどれにもこのプロジェクトの編集可能なコピーがありません。",
noEditableTarget: "このホストではこのプロジェクトを編集できません。",
backToProjects: "プロジェクトに戻る",
switchHost: "ホストを切り替え",
rename: {
renamedToast: "プロジェクト名を変更しました",
errorFallback: "プロジェクト名を変更できませんでした",
renameLabel: "プロジェクトの名前を変更",
resetLabel: "プロジェクト名をデフォルトにリセット",
projectNameLabel: "プロジェクト名",
saveLabel: "プロジェクト名を保存",
cancelLabel: "名前変更をキャンセル",
reset: "リセット",
edit: {
title: "プロジェクトを編集",
name: "名前",
nameLabel: "プロジェクト名",
icon: "アイコン",
chooseImage: "画像を選択",
useAutomatic: "自動を使う",
imageUrl: "画像またはサイトのURL",
save: "変更を保存",
savedToast: "プロジェクトを更新しました",
},
readFailures: {
invalidTitle: "paseo.jsonを解析できませんでした",
invalidDescription: "ディスク上のファイルを修正してから再読み込みしてください。",
missingTitle: "このホストにはこのプロジェクトがありません",
missingWithHosts: "上で別のホストに切り替えるか、再読み込みしてください。",
missingSingleHost: "選択したホストにはこのプロジェクトの記録がありません。",
transportTitle: "paseo.jsonを読み込めませんでした",
transportFallback: "ホストが応答しませんでした。",
+12 -12
View File
@@ -1650,6 +1650,7 @@ export const ptBR: TranslationResources = {
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
},
hostSections: {
projects: "Projetos",
connections: "Conexões",
agents: "Agentes",
workspaces: "Workspaces",
@@ -2158,24 +2159,23 @@ export const ptBR: TranslationResources = {
},
},
project: {
noEditableTarget: "Não há uma cópia editável deste projeto em nenhum host conectado.",
noEditableTarget: "Este projeto não pode ser editado neste host.",
backToProjects: "Voltar para projetos",
switchHost: "Trocar host",
rename: {
renamedToast: "Projeto renomeado",
errorFallback: "Não foi possível renomear projeto",
renameLabel: "Renomear projeto",
resetLabel: "Redefinir nome do projeto para o padrão",
projectNameLabel: "Nome do projeto",
saveLabel: "Salvar nome do projeto",
cancelLabel: "Cancelar renomeação",
reset: "Redefinir",
edit: {
title: "Editar projeto",
name: "Nome",
nameLabel: "Nome do projeto",
icon: "Ícone",
chooseImage: "Escolher imagem",
useAutomatic: "Usar automático",
imageUrl: "URL de imagem ou site",
save: "Salvar alterações",
savedToast: "Projeto atualizado",
},
readFailures: {
invalidTitle: "Não foi possível analisar paseo.json",
invalidDescription: "Corrija o arquivo no disco e recarregue.",
missingTitle: "Este host não tem este projeto",
missingWithHosts: "Troque para outro host acima ou recarregue.",
missingSingleHost: "O host selecionado não tem registro deste projeto.",
transportTitle: "Não foi possível carregar paseo.json",
transportFallback: "O host não respondeu.",
+12 -13
View File
@@ -1655,6 +1655,7 @@ export const ru: TranslationResources = {
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
},
hostSections: {
projects: "Проекты",
connections: "Соединения",
agents: "Agents",
workspaces: "Workspaces",
@@ -2161,25 +2162,23 @@ export const ru: TranslationResources = {
},
},
project: {
noEditableTarget:
"У нас нет редактируемой копии этого проекта ни на одном подключенном хосте.",
noEditableTarget: "Этот проект нельзя редактировать на этом хосте.",
backToProjects: "Вернуться к проектам",
switchHost: "Сменить хост",
rename: {
renamedToast: "Проект переименован",
errorFallback: "Не удалось переименовать проект",
renameLabel: "Переименовать проект",
resetLabel: "Сбросить имя проекта по умолчанию",
projectNameLabel: "Название проекта",
saveLabel: "Сохранить название проекта",
cancelLabel: "Отменить переименование",
reset: "Перезагрузить",
edit: {
title: "Изменить проект",
name: "Название",
nameLabel: "Название проекта",
icon: "Значок",
chooseImage: "Выбрать изображение",
useAutomatic: "Использовать автоматический",
imageUrl: "URL изображения или сайта",
save: "Сохранить изменения",
savedToast: "Проект обновлён",
},
readFailures: {
invalidTitle: "paseo.json не удалось разобрать",
invalidDescription: "Исправьте файл на диске, затем перезагрузите.",
missingTitle: "У этого хоста нет этого проекта",
missingWithHosts: "Переключитесь на другой хост выше или перезагрузите компьютер.",
missingSingleHost: "У выбранного хоста нет записей об этом проекте.",
transportTitle: "Не удалось загрузить paseo.json.",
transportFallback: "Хозяин не ответил.",
+12 -12
View File
@@ -1602,6 +1602,7 @@ export const zhCN: TranslationResources = {
vimHint: "适用于网页和桌面端的源文件。",
},
hostSections: {
projects: "项目",
connections: "连接",
agents: "Agents",
workspaces: "Workspaces",
@@ -2095,24 +2096,23 @@ export const zhCN: TranslationResources = {
},
},
project: {
noEditableTarget: "任何已连接 Host 上都没有这个 Project 的可编辑副本。",
noEditableTarget: "此项目无法在这个 Host 上编辑。",
backToProjects: "返回 Projects",
switchHost: "切换 Host",
rename: {
renamedToast: "Project 已重命名",
errorFallback: "无法重命名 Project",
renameLabel: "重命名 Project",
resetLabel: "将 Project 名称重置为默认值",
projectNameLabel: "Project 名称",
saveLabel: "保存 Project 名称",
cancelLabel: "取消重命名",
reset: "重置",
edit: {
title: "编辑 Project",
name: "名称",
nameLabel: "Project 名称",
icon: "图标",
chooseImage: "选择图片",
useAutomatic: "使用自动",
imageUrl: "图片或网站 URL",
save: "保存更改",
savedToast: "Project 已更新",
},
readFailures: {
invalidTitle: "无法解析 paseo.json",
invalidDescription: "修复磁盘上的文件,然后重新加载。",
missingTitle: "这个 Host 没有这个 Project",
missingWithHosts: "切换到上方其他 Host,或重新加载。",
missingSingleHost: "所选 Host 没有这个 Project 的记录。",
transportTitle: "无法加载 paseo.json",
transportFallback: "Host 没有响应。",
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { openProjectEditForm, type ProjectEditFormSnapshot } from "./edit-form";
const derivedName: ProjectEditFormSnapshot = {
projectName: "grumpy-turtle",
projectCustomName: null,
hasCustomIcon: false,
currentIconDataUri: "data:image/png;base64,ZGVyaXZlZA==",
};
const customIcon: ProjectEditFormSnapshot = {
projectName: "Turtle",
projectCustomName: "Turtle",
hasCustomIcon: true,
currentIconDataUri: "data:image/png;base64,Y3VzdG9t",
};
describe("project edit form model", () => {
it("has nothing to submit until the user changes something", () => {
expect(openProjectEditForm(derivedName).getState()).toMatchObject({
name: "",
canSubmit: false,
canUseAutomatic: false,
submission: { rename: null, icon: null },
});
});
it("submits only the name when only the name changed", () => {
const model = openProjectEditForm(derivedName);
model.setName(" Turtle ");
expect(model.getState()).toMatchObject({
canSubmit: true,
submission: { rename: { customName: "Turtle" }, icon: null },
});
});
it("clears the override when the name is emptied", () => {
const model = openProjectEditForm(customIcon);
model.setName(" ");
expect(model.getState().submission.rename).toEqual({ customName: null });
});
it("treats a retyped identical name as no change", () => {
const model = openProjectEditForm(customIcon);
model.setName("Turtle");
expect(model.getState()).toMatchObject({ canSubmit: false, submission: { rename: null } });
});
it("previews a picked image and submits it as an upload", () => {
const model = openProjectEditForm(derivedName);
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
expect(model.getState()).toMatchObject({
previewDataUri: "data:image/png;base64,aW1hZ2U=",
pickedFileName: "logo.png",
canUseAutomatic: true,
submission: { rename: null, icon: { type: "upload", data: "aW1hZ2U=" } },
});
});
it("keeps a URL client-side and leaves the preview on the current icon", () => {
const model = openProjectEditForm(derivedName);
model.setImageUrl(" https://example.com ");
expect(model.getState()).toMatchObject({
previewDataUri: derivedName.currentIconDataUri,
pickedFileName: null,
submission: { icon: { type: "url", url: "https://example.com" } },
});
});
it("falls back to the earlier pick when the URL box is emptied", () => {
const model = openProjectEditForm(derivedName);
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
model.setImageUrl("https://example.com");
expect(model.getState().submission.icon).toEqual({
type: "url",
url: "https://example.com",
});
model.setImageUrl("");
expect(model.getState().submission.icon).toEqual({ type: "upload", data: "aW1hZ2U=" });
});
it("resets a stored custom icon back to automatic", () => {
const model = openProjectEditForm(customIcon);
expect(model.getState().canUseAutomatic).toBe(true);
model.useAutomaticIcon();
expect(model.getState()).toMatchObject({
previewDataUri: null,
canUseAutomatic: false,
submission: { rename: null, icon: { type: "automatic" } },
});
});
it("discards a pick without asking the daemon for an icon it already has", () => {
const model = openProjectEditForm(derivedName);
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
model.useAutomaticIcon();
expect(model.getState()).toMatchObject({
previewDataUri: derivedName.currentIconDataUri,
canSubmit: false,
submission: { rename: null, icon: null },
});
});
it("clears the URL box when the user falls back to automatic", () => {
const model = openProjectEditForm(customIcon);
model.setImageUrl("https://example.com");
const before = model.getState().urlResetKey;
model.useAutomaticIcon();
expect(model.getState().urlResetKey).not.toBe(before);
});
it("submits both fields together when both changed", () => {
const model = openProjectEditForm(customIcon);
model.setName("Renamed");
model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" });
expect(model.getState().submission).toEqual({
rename: { customName: "Renamed" },
icon: { type: "upload", data: "aW1hZ2U=" },
});
});
it("drops a submit failure as soon as the user edits again", () => {
const model = openProjectEditForm(derivedName);
model.setError({ scope: "icon", message: "Enter a valid website or image URL" });
expect(model.getState().error).toMatchObject({ scope: "icon" });
model.setImageUrl("https://example.com");
expect(model.getState().error).toBeNull();
});
});
+167
View File
@@ -0,0 +1,167 @@
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
/**
* What the user asked the icon to become. A URL is client-side only: the caller
* acquires its bytes and submits the same upload payload a picked file produces.
*/
export type ProjectIconIntent = ProjectIconSource | { type: "url"; url: string };
/** The RPCs one submit has to issue. A null field is a field the user left alone. */
export interface ProjectEditSubmission {
rename: { customName: string | null } | null;
icon: ProjectIconIntent | null;
}
export interface ProjectEditFormError {
/** Which field the failure belongs under. */
scope: "name" | "icon";
message: string;
}
export interface ProjectEditFormState {
name: string;
/** The image the icon tile renders, null for the derived letter fallback. */
previewDataUri: string | null;
pickedFileName: string | null;
/** False once the resulting icon is the automatic one — the reset has nothing to do. */
canUseAutomatic: boolean;
canSubmit: boolean;
error: ProjectEditFormError | null;
/** Bumped only when the model clears the URL box on the user's behalf. */
urlResetKey: number;
submission: ProjectEditSubmission;
}
export interface ProjectEditFormModel {
getState: () => ProjectEditFormState;
subscribe: (listener: () => void) => () => void;
setName: (name: string) => void;
setImageUrl: (url: string) => void;
setPickedImage: (image: { fileName: string; mimeType: string; data: string }) => void;
useAutomaticIcon: () => void;
setError: (error: ProjectEditFormError | null) => void;
}
export interface ProjectEditFormSnapshot {
/** The name on screen today, custom or derived. Seeds the input's placeholder. */
projectName: string;
/** The override in effect, null while the project uses its derived name. */
projectCustomName: string | null;
hasCustomIcon: boolean;
/** The icon the project renders today, custom or derived. */
currentIconDataUri: string | null;
}
type IconChoice = "unchanged" | "automatic" | "upload" | "url";
interface PickedImage {
fileName: string;
mimeType: string;
data: string;
}
export function openProjectEditForm(snapshot: ProjectEditFormSnapshot): ProjectEditFormModel {
let name = snapshot.projectCustomName ?? "";
let urlText = "";
let picked: PickedImage | null = null;
let iconChoice: IconChoice = "unchanged";
let error: ProjectEditFormError | null = null;
let urlResetKey = 0;
const listeners = new Set<() => void>();
function deriveRename(): ProjectEditSubmission["rename"] {
const trimmed = name.trim();
const customName = trimmed.length === 0 ? null : trimmed;
return customName === snapshot.projectCustomName ? null : { customName };
}
function deriveIcon(): ProjectIconIntent | null {
if (iconChoice === "automatic") {
return snapshot.hasCustomIcon ? { type: "automatic" } : null;
}
if (iconChoice === "upload" && picked) {
return { type: "upload", data: picked.data };
}
if (iconChoice === "url") {
const url = urlText.trim();
return url.length === 0 ? null : { type: "url", url };
}
return null;
}
function derivePreview(): string | null {
// Dropping a custom icon leaves the daemon to re-derive one, so the tile
// falls back to the letter rather than claiming to know the result.
if (iconChoice === "automatic") return null;
if (iconChoice === "upload" && picked) {
return `data:${picked.mimeType};base64,${picked.data}`;
}
return snapshot.currentIconDataUri;
}
function deriveState(): ProjectEditFormState {
const submission = { rename: deriveRename(), icon: deriveIcon() };
const willBeCustom = submission.icon
? submission.icon.type !== "automatic"
: snapshot.hasCustomIcon;
return {
name,
previewDataUri: derivePreview(),
pickedFileName: iconChoice === "upload" ? (picked?.fileName ?? null) : null,
canUseAutomatic: willBeCustom,
canSubmit: Boolean(submission.rename || submission.icon),
error,
urlResetKey,
submission,
};
}
let state = deriveState();
function publish(): void {
state = deriveState();
for (const listener of listeners) listener();
}
return {
getState: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
setName: (nextName) => {
name = nextName;
error = null;
publish();
},
setImageUrl: (url) => {
urlText = url;
// Emptying the box withdraws the URL without discarding an earlier pick.
if (url.trim().length > 0) {
iconChoice = "url";
} else {
iconChoice = picked ? "upload" : "unchanged";
}
error = null;
publish();
},
setPickedImage: (image) => {
picked = image;
iconChoice = "upload";
error = null;
publish();
},
useAutomaticIcon: () => {
picked = null;
urlText = "";
urlResetKey += 1;
iconChoice = snapshot.hasCustomIcon ? "automatic" : "unchanged";
error = null;
publish();
},
setError: (nextError) => {
error = nextError;
publish();
},
};
}
@@ -81,6 +81,7 @@ export function hostProjectFromWorkspace(input: {
projectId: input.workspace.projectId,
iconWorkingDir,
worktreeSupport: canCreate ? "supported" : "unsupported",
customIconRevision: input.workspace.projectCustomIconRevision,
},
],
workspaceKeys: [`${input.serverId}:${input.workspace.id}`],
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { resolveProjectIconLookup } from "./icons";
describe("project icon lookup", () => {
const target = {
projectId: "prj_host_local",
iconWorkingDir: "/projects/paseo",
};
it("uses the host-local project ID with custom-icon-capable daemons", () => {
expect(resolveProjectIconLookup(target, true)).toEqual({
kind: "project",
projectId: "prj_host_local",
});
});
it("uses the project directory with legacy daemons", () => {
expect(resolveProjectIconLookup(target, false)).toEqual({
kind: "legacy",
cwd: "/projects/paseo",
});
});
});
+157
View File
@@ -0,0 +1,157 @@
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQueries, useQuery } from "@tanstack/react-query";
import type { ProjectIcon } from "@getpaseo/protocol/messages";
import { useHostFeatureMap } from "@/runtime/host-features";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeClient,
useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
interface ProjectIconTarget {
serverId: string;
projectViewKey: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
}
/**
* Daemons without custom-icon support only answer the legacy cwd lookup, which
* still serves their automatically discovered icons.
*/
export function resolveProjectIconLookup(
target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">,
supportsCustomIcons: boolean,
): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } {
return supportsCustomIcons
? { kind: "project", projectId: target.projectId }
: { kind: "legacy", cwd: target.iconWorkingDir };
}
function legacyIconQueryKey(serverId: string, cwd: string) {
return ["projectIcon", serverId, "legacy", cwd] as const;
}
function iconQueryKey(serverId: string, projectId: string, revision: string) {
return ["projectIcon", serverId, projectId, revision] as const;
}
function iconDataUri(icon: ProjectIcon | null): string | null {
if (!icon) return null;
return `data:${icon.mimeType};base64,${icon.data}`;
}
function useStableIconData(data: (string | null)[], signature: string): readonly (string | null)[] {
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
if (stableRef.current?.signature !== signature) {
stableRef.current = { signature, data };
}
return stableRef.current.data;
}
export function useProjectIcon({ serverId, cwd }: { serverId: string; cwd: string }) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({
queryKey: legacyIconQueryKey(serverId, cwd),
queryFn: async (): Promise<ProjectIcon | null> => {
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const result = await client.requestProjectIcon(cwd);
return result.icon;
},
enabled: Boolean(client && isConnected && cwd),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
return {
icon: query.data ?? null,
isLoading: query.isLoading,
isError: query.isError,
};
}
export function useProjectIcons(input: {
projects: readonly ProjectIconTarget[];
}): Map<string, string | null> {
const serverIds = useMemo(
() => [...new Set(input.projects.map((project) => project.serverId))],
[input.projects],
);
const supportsCustomIcons = useHostFeatureMap(serverIds, "projectCustomIcon");
const requests = useMemo(() => {
const unique = new Map<string, ProjectIconTarget>();
for (const project of input.projects) {
if (!project.serverId || !project.projectId || !project.iconWorkingDir.trim()) continue;
unique.set(`${project.serverId}:${project.projectId}`, project);
}
return Array.from(unique.values());
}, [input.projects]);
const queries = useQueries({
queries: requests.map((request) => {
const revision = request.customIconRevision ?? "automatic";
const lookup = resolveProjectIconLookup(
request,
supportsCustomIcons.get(request.serverId) === true,
);
return {
queryKey:
lookup.kind === "project"
? iconQueryKey(request.serverId, lookup.projectId, revision)
: legacyIconQueryKey(request.serverId, lookup.cwd),
queryFn: async () => {
const client = getHostRuntimeStore().getClient(request.serverId);
if (!client) return null;
const result =
lookup.kind === "project"
? await client.getProjectIcon(lookup.projectId)
: await client.requestProjectIcon(lookup.cwd);
return result.icon;
},
select: iconDataUri,
enabled: Boolean(
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)),
),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
};
}),
});
const signature = queries.map((query) => query.data ?? "").join("\u0000");
const data = useStableIconData(
queries.map((query) => query.data ?? null),
signature,
);
return useMemo(() => {
const byTarget = new Map<string, string | null>();
requests.forEach((request, index) => {
byTarget.set(`${request.serverId}:${request.projectId}`, data[index] ?? null);
});
const byProject = new Map<string, string | null>();
for (const project of input.projects) {
byProject.set(
project.projectViewKey,
byTarget.get(`${project.serverId}:${project.projectId}`) ?? null,
);
}
return byProject;
}, [data, input.projects, requests]);
}
@@ -0,0 +1,46 @@
import { Buffer } from "buffer";
import { describe, expect, it } from "vitest";
import { importProjectIconFromUrl } from "./import-project-icon";
const IMAGE = new Uint8Array([1, 2, 3, 4]);
function response(body: BodyInit, contentType: string): Response {
return new Response(body, { headers: { "content-type": contentType } });
}
describe("project icon URL import", () => {
it("turns a direct image URL into an upload payload", async () => {
const source = await importProjectIconFromUrl("https://example.com/icon.png", async () =>
response(IMAGE.buffer, "image/png"),
);
expect(source).toEqual({ type: "upload", data: Buffer.from(IMAGE).toString("base64") });
});
it("fetches a website icon on the client and returns an upload payload", async () => {
const requested: string[] = [];
const source = await importProjectIconFromUrl("https://example.com", async (input) => {
const url = input.toString();
requested.push(url);
if (url === "https://example.com/") {
return response('<link rel="icon" href="/brand.png">', "text/html");
}
return response(IMAGE.buffer, "image/png");
});
expect(requested).toEqual(["https://example.com/", "https://example.com/brand.png"]);
expect(source.type).toBe("upload");
});
it("rejects non-HTTP URLs without fetching", async () => {
let fetched = false;
await expect(
importProjectIconFromUrl("file:///etc/passwd", async () => {
fetched = true;
return response(IMAGE.buffer, "image/png");
}),
).rejects.toThrow("URL must use HTTP or HTTPS without credentials");
expect(fetched).toBe(false);
});
});
@@ -0,0 +1,89 @@
import { Buffer } from "buffer";
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
const MAX_ICON_BYTES = 512 * 1024;
const MAX_HTML_BYTES = 512 * 1024;
const MAX_ICON_CANDIDATES = 8;
type UploadSource = Extract<ProjectIconSource, { type: "upload" }>;
type FetchIcon = (input: string | URL, init?: RequestInit) => Promise<Response>;
function parseHttpUrl(value: string): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("Enter a valid website or image URL");
}
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
throw new Error("URL must use HTTP or HTTPS without credentials");
}
return url;
}
async function fetchOk(fetchIcon: FetchIcon, url: URL): Promise<Response> {
const response = await fetchIcon(url);
if (!response.ok) throw new Error(`Download failed (${response.status})`);
return response;
}
async function readBytes(response: Response, maxBytes: number): Promise<Buffer> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new Error("Icon must be 512 KB or smaller");
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length > maxBytes) throw new Error("Icon must be 512 KB or smaller");
return bytes;
}
function parseTagAttribute(tag: string, name: string): string | null {
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i"));
return match?.[1]?.trim() || null;
}
function iconCandidates(html: string, pageUrl: URL): URL[] {
const candidates: URL[] = [];
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
const rel = parseTagAttribute(tag, "rel")?.toLowerCase() ?? "";
const href = parseTagAttribute(tag, "href");
const type = parseTagAttribute(tag, "type")?.toLowerCase() ?? "";
if (!href || !rel.includes("icon") || type.includes("svg") || href.endsWith(".svg")) continue;
try {
candidates.push(new URL(href, pageUrl));
} catch {
// Try the next declared icon and then the conventional fallback.
}
}
candidates.push(new URL("/favicon.ico", pageUrl));
return candidates.slice(0, MAX_ICON_CANDIDATES);
}
/** Acquires a URL on the client and collapses it into the normal upload payload. */
export async function importProjectIconFromUrl(
value: string,
fetchIcon: FetchIcon = fetch,
): Promise<UploadSource> {
const url = parseHttpUrl(value.trim());
const response = await fetchOk(fetchIcon, url);
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("text/html")) {
return { type: "upload", data: (await readBytes(response, MAX_ICON_BYTES)).toString("base64") };
}
const html = (await readBytes(response, MAX_HTML_BYTES)).toString("utf8");
let lastError: unknown = new Error("Website does not expose a supported icon");
for (const candidate of iconCandidates(html, url)) {
try {
const iconResponse = await fetchOk(fetchIcon, candidate);
return {
type: "upload",
data: (await readBytes(iconResponse, MAX_ICON_BYTES)).toString("base64"),
};
} catch (error) {
lastError = error;
}
}
throw lastError;
}
@@ -1,94 +0,0 @@
import { useMemo, useRef } from "react";
import { useQueries } from "@tanstack/react-query";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
export interface ProjectIconRequestTarget {
serverId: string;
projectViewKey: string;
iconWorkingDir: string;
}
function useStableProjectIconData(
data: (string | null)[],
signature: string,
): readonly (string | null)[] {
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
if (stableRef.current?.signature !== signature) {
stableRef.current = { signature, data };
}
return stableRef.current.data;
}
export function useProjectIconDataByProjectViewKey(input: {
projects: readonly ProjectIconRequestTarget[];
}): Map<string, string | null> {
const projectIconRequests = useMemo(() => {
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const project of input.projects) {
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
continue;
}
unique.set(`${project.serverId}:${cwd}`, { serverId: project.serverId, cwd });
}
return Array.from(unique.values());
}, [input.projects]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
queryKey: projectIconQueryKey(request.serverId, request.cwd),
queryFn: async () => {
const client = getHostRuntimeStore().getClient(request.serverId);
if (!client) {
return null;
}
const result = await client.requestProjectIcon(request.cwd);
return result.icon;
},
select: projectIconToDataUri,
enabled: Boolean(
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
request.cwd,
),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})),
});
const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000");
const projectIconData = useStableProjectIconData(
projectIconQueries.map((query) => query.data ?? null),
projectIconSignature,
);
return useMemo(() => {
const iconByServerAndCwd = new Map<string, string | null>();
for (let index = 0; index < projectIconRequests.length; index += 1) {
const request = projectIconRequests[index];
if (!request) {
continue;
}
iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null);
}
const byProject = new Map<string, string | null>();
for (const project of input.projects) {
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
byProject.set(project.projectViewKey, null);
continue;
}
byProject.set(
project.projectViewKey,
iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null,
);
}
return byProject;
}, [input.projects, projectIconData, projectIconRequests]);
}
@@ -6,6 +6,7 @@ export interface WorkspaceStructureHostPlacement {
projectId: string;
iconWorkingDir: string;
worktreeSupport: "supported" | "unsupported" | "unknown";
customIconRevision?: string | null;
}
export interface WorkspaceStructureProject {
@@ -158,6 +159,7 @@ function addProjectToView(input: {
projectId: project.projectId,
iconWorkingDir: project.projectRootPath,
worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported",
customIconRevision: project.projectCustomIconRevision,
};
const draft = byProject.get(viewKey);
if (!draft) {
@@ -43,6 +43,7 @@ function applyProjectDelta(
...workspace,
projectDisplayName: project.projectDisplayName,
projectCustomName: project.projectCustomName,
projectCustomIconRevision: project.projectCustomIconRevision,
projectRootPath: project.projectRootPath,
projectKind: project.projectKind,
});
@@ -196,6 +196,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectCustomIconRevision: workspace.projectCustomIconRevision ?? null,
projectRootPath: workspace.projectRootPath,
workspaceDirectory: workspace.workspaceDirectory,
projectKind: workspace.projectKind,
@@ -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}
@@ -56,6 +56,7 @@ function projectDescriptorFromTestWorkspace(workspace: WorkspaceDescriptor): Pro
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectCustomIconRevision: workspace.projectCustomIconRevision ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
@@ -348,6 +349,7 @@ describe("workspace structure composition", () => {
projectKey: "project-a",
projectDisplayName: "Project A",
projectCustomName: null,
projectCustomIconRevision: null,
projectRootPath: "/repo/a",
projectKind: "git",
};
+4
View File
@@ -129,6 +129,7 @@ export interface WorkspaceDescriptor {
projectId: string;
projectDisplayName: string;
projectCustomName?: string | null;
projectCustomIconRevision?: string | null;
projectRootPath: string;
workspaceDirectory: string;
projectKind: WorkspaceDescriptorPayload["projectKind"];
@@ -160,6 +161,7 @@ export function normalizeWorkspaceDescriptor(
projectId: payload.projectId,
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectCustomIconRevision: payload.projectCustomIconRevision ?? null,
projectRootPath: payload.projectRootPath,
// Canonicalize the workspace directory once, at the store boundary, so every
// consumer can read workspace.workspaceDirectory directly. Empty means "no
@@ -187,6 +189,7 @@ export interface ProjectDescriptor {
projectKey?: string | null;
projectDisplayName: string;
projectCustomName: string | null;
projectCustomIconRevision?: string | null;
projectRootPath: string;
projectKind: WorkspaceDescriptorPayload["projectKind"];
}
@@ -199,6 +202,7 @@ export function normalizeProjectDescriptor(
projectKey: payload.projectKey ?? null,
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectCustomIconRevision: payload.projectCustomIconRevision ?? null,
projectRootPath: payload.projectRootPath,
projectKind: payload.projectKind,
};
+4 -3
View File
@@ -193,13 +193,13 @@ describe("projects settings routes", () => {
expect(buildSettingsAddHostRoute("retry 1")).toBe("/settings/general?addHost=retry%201");
});
it("buildProjectsSettingsRoute returns /settings/projects", () => {
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
it("buildProjectsSettingsRoute scopes the list to a host", () => {
expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects");
});
it("buildProjectSettingsRoute addresses a host-local project id", () => {
expect(buildProjectSettingsRoute("host a", "project/1")).toBe(
"/settings/projects/host%20a/project%2F1",
"/settings/hosts/host%20a/projects/project%2F1",
);
});
@@ -248,6 +248,7 @@ describe("host settings section slugs", () => {
expect(normalizeHostSectionSlug("connections")).toBe("connections");
expect(normalizeHostSectionSlug("agents")).toBe("agents");
expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces");
expect(normalizeHostSectionSlug("projects")).toBe("projects");
expect(normalizeHostSectionSlug("providers")).toBe("providers");
expect(normalizeHostSectionSlug("usage")).toBe("usage");
expect(normalizeHostSectionSlug("host")).toBe("host");
+8 -3
View File
@@ -507,6 +507,7 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
}
export const HOST_SECTION_SLUGS = [
"projects",
"connections",
"agents",
"workspaces",
@@ -562,15 +563,19 @@ export function buildSettingsHostSectionRoute(serverId: string, section: HostSec
return `/settings/hosts/${encodeSegment(normalized)}/${section}` as const;
}
export function buildProjectsSettingsRoute() {
return "/settings/projects" as const;
export function buildProjectsSettingsRoute(serverId: string) {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
throw new Error("buildProjectsSettingsRoute requires a non-empty serverId");
}
return `/settings/hosts/${encodeSegment(normalized)}/projects` as const;
}
export function buildProjectSettingsRoute(serverId: string, projectId: string) {
if (!serverId.trim() || !projectId.trim()) {
throw new Error("buildProjectSettingsRoute requires a serverId and projectId");
}
return `/settings/projects/${encodeSegment(serverId)}/${encodeSegment(projectId)}` as const;
return `/settings/hosts/${encodeSegment(serverId)}/projects/${encodeSegment(projectId)}` as const;
}
export function normalizeProjectSettingsRouteId(value: string | string[] | undefined): string {
+4
View File
@@ -24,6 +24,7 @@ export interface ProjectHostEntry {
workspaces: WorkspaceSummary[];
gitRuntime?: WorkspaceDescriptor["gitRuntime"];
githubRuntime?: WorkspaceDescriptor["githubRuntime"];
customIconRevision?: string | null;
}
export interface ProjectSummary {
@@ -85,6 +86,7 @@ interface HostGroup {
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
customIconRevision?: string | null;
// Repo root for a project parent that has no workspaces yet. Without it the
// host's repoRoot resolves to "" and the project reads as non-editable.
fallbackRepoRoot: string;
@@ -156,6 +158,7 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
workspaces: group.workspaces.map(toWorkspaceSummary),
gitRuntime: canonical?.gitRuntime,
githubRuntime: canonical?.githubRuntime,
customIconRevision: canonical?.projectCustomIconRevision ?? group.customIconRevision,
};
}
@@ -219,6 +222,7 @@ function addHostProjects(
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
customIconRevision: placement.customIconRevision,
fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "",
});
}
@@ -4,6 +4,7 @@ export interface SidebarProjectHostTarget {
serverId: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
}
export type SidebarProjectTrailingAction =
@@ -23,12 +24,18 @@ function hostTarget(input: {
serverId: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
}): SidebarProjectHostTarget | null {
const iconWorkingDir = input.iconWorkingDir.trim();
if (!input.serverId || !iconWorkingDir) {
return null;
}
return { serverId: input.serverId, projectId: input.projectId, iconWorkingDir };
return {
serverId: input.serverId,
projectId: input.projectId,
iconWorkingDir,
customIconRevision: input.customIconRevision,
};
}
export function resolveSidebarProjectIconTarget(