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
+2 -3
View File
@@ -130,7 +130,7 @@
] ]
} }
}, },
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban. // Raw query burn-down: 34 files total; the 28 files below still enforce the useUnistyles ban.
{ {
"files": [ "files": [
"packages/app/src/assistant-file-links/use-file-link.ts", "packages/app/src/assistant-file-links/use-file-link.ts",
@@ -154,10 +154,9 @@
"packages/app/src/hooks/use-is-local-daemon.ts", "packages/app/src/hooks/use-is-local-daemon.ts",
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts", "packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
"packages/app/src/hooks/use-preferred-editor.ts", "packages/app/src/hooks/use-preferred-editor.ts",
"packages/app/src/hooks/use-project-icon-query.ts",
"packages/app/src/hooks/use-settings/index.ts", "packages/app/src/hooks/use-settings/index.ts",
"packages/app/src/panels/terminal-panel.tsx", "packages/app/src/panels/terminal-panel.tsx",
"packages/app/src/projects/project-icons.ts", "packages/app/src/projects/icons.ts",
"packages/app/src/provider-usage/use-provider-usage.ts", "packages/app/src/provider-usage/use-provider-usage.ts",
"packages/app/src/screens/project-settings-screen.tsx", "packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts", "packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
+1
View File
@@ -374,6 +374,7 @@ $PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows ├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
├── projects/projects.json # Project registry ├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry ├── projects/workspaces.json # Workspace registry
├── projects/icons/ # Custom project icon images
├── chat/ # Chat rooms ├── chat/ # Chat rooms
├── schedules/ # Scheduled-agent definitions and runs ├── schedules/ # Scheduled-agent definitions and runs
├── loops/ # Loop runs and logs ├── loops/ # Loop runs and logs
+20 -12
View File
@@ -60,7 +60,8 @@ $PASEO_HOME/
│ └── loops.json # All loop records │ └── loops.json # All loop records
├── projects/ ├── projects/
│ ├── projects.json # Project registry │ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry │ ├── workspaces.json # Workspace registry
│ └── icons/ # Host-local custom project icon images
├── runtime/ ├── runtime/
│ └── managed-processes/ │ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap │ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
@@ -454,17 +455,24 @@ Single file containing an array of all loop records. Writes are direct (not atom
Array of project records. Array of project records.
| Field | Type | Description | | Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- | | -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs | | `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values | | `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed | | `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key | | `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes | | `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". | | `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `createdAt` | `string` (ISO 8601) | | | `customIconRevision` | `string \| null` | Identifies the host-local custom icon stored under `projects/icons/`. Null means the icon is discovered by scanning the project directory. |
| `updatedAt` | `string` (ISO 8601) | | | `createdAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | | `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
Uploading a file and pasting a website or image URL are two ways of _acquiring_ the same custom
icon. The client fetches URL imports and sends their bytes through the upload RPC. The daemon never
receives or fetches the URL; it validates the uploaded bytes, stores them, and records a new
`customIconRevision`. Going back to automatic deletes the stored image, as does removing the
project.
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
+23 -18
View File
@@ -2,7 +2,9 @@ import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell } from "./app"; import { gotoAppShell } from "./app";
import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts"; import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts";
import { openProjectSettings } from "./project-settings"; import { openProjectSettings } from "./project-settings";
import { selectSettingsHost } from "./settings";
import { waitForSidebarHydration } from "./workspace-ui"; import { waitForSidebarHydration } from "./workspace-ui";
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
const PROJECT_VISIBILITY_TIMEOUT = 30_000; const PROJECT_VISIBILITY_TIMEOUT = 30_000;
@@ -98,34 +100,37 @@ export async function expectProjectWorkspaceCountForHost(
} }
export async function renameProject(page: Page, name: string): Promise<void> { 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("textbox", { name: "Project name", exact: true }).fill(name);
await page.getByRole("button", { name: "Save project name", exact: true }).click(); await page.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({ await expect(page.getByText("Project updated", { exact: true })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT, 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 page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/general$/); await expect(page).toHaveURL(/\/settings\/general$/);
await page.getByRole("button", { name: "Projects", exact: true }).click(); await selectSettingsHost(page, input.serverId);
await expect(page).toHaveURL(/\/settings\/projects$/); await page.locator('[data-testid="settings-host-section-projects"]:visible').click();
await openProjectSettings(page, projectName); await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/projects$/);
await openProjectSettings(page, input.projectName);
} }
export async function switchProjectSettingsHost(page: Page, hostName: string): Promise<void> { export async function openProjectsForSettingsHost(
const projectSettings = page.getByRole("main"); page: Page,
await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click(); input: { serverId: string; projectName: string },
await page.getByRole("button", { name: hostName, exact: true }).click(); ): Promise<void> {
await expect( await selectSettingsHost(page, input.serverId);
projectSettings.getByRole("button", { name: "Switch host", exact: true }), await expect(page).toHaveURL(buildProjectsSettingsRoute(input.serverId));
).toContainText(hostName); await openProjectSettings(page, input.projectName);
} }
export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> { export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> {
await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText( await expect(page.getByRole("main").getByText(projectName, { exact: true })).toBeVisible({
projectName, timeout: PROJECT_VISIBILITY_TIMEOUT,
{ 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 type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app"; import { gotoAppShell, openSettings } from "./app";
import { daemonWsRoutePattern } from "./daemon-port"; import { daemonWsRoutePattern } from "./daemon-port";
import { getServerId } from "./server-id";
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
type WebSocketMessage = string | Buffer; type WebSocketMessage = string | Buffer;
@@ -33,8 +35,8 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
export async function openProjects(page: Page): Promise<void> { export async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await page.getByTestId("settings-projects").click(); await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/projects$/); await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
} }
export async function openProjectSettings(page: Page, projectName: string): Promise<void> { 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(); 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 --- // --- Form interactions ---
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> { 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> { 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> { 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(); 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 --- // --- Error-state assertions ---
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed"; 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> { 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 --- // --- 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 }); await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
} }
// --- Host-section assertions --- export async function expectProjectHostContextHidden(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).not.toBeVisible();
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).toBeVisible();
}
export async function expectHostPickerHidden(page: Page): Promise<void> {
await expect(page.getByTestId("host-picker")).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 { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions"; import { expectAppRoute } from "./route-assertions";
import { import {
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute, buildSettingsHostSectionRoute,
buildSettingsRoute, buildSettingsRoute,
buildSettingsSectionRoute, buildSettingsSectionRoute,
@@ -30,20 +29,21 @@ const SECTION_LABELS = {
about: "About", about: "About",
} as const; } 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> { export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar"); const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible(); 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 sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
await expectAppRoute(page, buildSettingsSectionRoute(section)); await expectAppRoute(page, buildSettingsSectionRoute(section));
} }
@@ -143,8 +143,8 @@ export async function seedSavedSettingsHosts(
} }
export async function selectSettingsHost(page: Page, serverId: string): Promise<void> { export async function selectSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId("settings-host-picker").click(); await page.locator('[data-testid="settings-host-picker"]:visible').click();
await page.getByTestId(`settings-host-picker-item-${serverId}`).click(); await page.locator(`[data-testid="settings-host-picker-item-${serverId}"]:visible`).click();
} }
export async function expectSettingsHostPickerLabel(page: Page, label: string): Promise<void> { 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( export async function expectSettingsSidebarSections(
page: Page, page: Page,
sections: Array<Exclude<SettingsSection, "projects">>, sections: SettingsSection[],
): Promise<void> { ): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar"); const sidebar = page.getByTestId("settings-sidebar");
for (const section of sections) { 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). // 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-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-agents")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible(); await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).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 { import {
blockPaseoConfigWrites, blockPaseoConfigWrites,
bumpPaseoConfigOnDisk, bumpPaseoConfigOnDisk,
chooseProjectIconImage,
clickReloadProjectSettings, clickReloadProjectSettings,
clickRetryProjectSettingsSave, clickRetryProjectSettingsSave,
clickSaveProjectSettings, clickSaveProjectSettings,
corruptPaseoConfig, corruptPaseoConfig,
editWorktreeSetup, editWorktreeSetup,
expectEmptyScriptList, expectEmptyScriptList,
expectHostIndicatorVisible, expectProjectHostContextHidden,
expectHostPickerHidden,
expectNoEditableTarget, expectNoEditableTarget,
expectNoProjectSettingsError, expectNoProjectSettingsError,
expectProjectEditFailed,
expectProjectEditName,
expectProjectEditSaved,
expectProjectEditsSaveDisabled,
expectProjectSettingsError, expectProjectSettingsError,
expectProjectSettingsFormHidden, expectProjectSettingsFormHidden,
expectProjectSettingsFormVisible, expectProjectSettingsFormVisible,
expectProjectTitle,
expectProjectSettingsHistoryRoundTrip,
expectSaveButtonDisabled, expectSaveButtonDisabled,
expectScriptRowCount, expectScriptRowCount,
expectWriteFailedCalloutActions, expectWriteFailedCalloutActions,
fillProjectIconUrl,
fillProjectName,
installDaemonConnectionGate, installDaemonConnectionGate,
installReadTransportFailure, installReadTransportFailure,
navigateToProjectSettings, navigateToProjectSettings,
openProjectEditSheet,
openProjectSettings, openProjectSettings,
openProjects, openProjects,
removeProjectScript, removeProjectScript,
restorePaseoConfig, restorePaseoConfig,
returnToProjectsList,
saveProjectEdits,
unblockPaseoConfigWrites, unblockPaseoConfigWrites,
} from "./helpers/project-settings"; } from "./helpers/project-settings";
import { gotoAppShell } from "./helpers/app"; import { gotoAppShell } from "./helpers/app";
@@ -40,6 +51,12 @@ import { createTempGitRepo } from "./helpers/workspace";
const updatedSetup = ["npm install", "npm run build"]; 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 { interface ProjectsSettingsProject {
name: string; name: string;
path: string; path: string;
@@ -204,6 +221,15 @@ test.describe("Projects settings", () => {
await expectProjectConfigSaved(editableProject); 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 ({ test("user edits worktree setup on a non-GitHub remote project", async ({
page, page,
gitlabRemoteProject, gitlabRemoteProject,
@@ -215,6 +241,74 @@ test.describe("Projects settings", () => {
await clickSaveProjectSettings(page); await clickSaveProjectSettings(page);
await expectProjectConfigSaved(gitlabRemoteProject); 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", () => { test.describe("Projects settings — error UX", () => {
@@ -322,15 +416,14 @@ test.describe("Projects settings — error UX", () => {
await expectNoEditableTarget(page); 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, page,
editableProject, editableProject,
}) => { }) => {
await openProjects(page); await openProjects(page);
await openProjectSettings(page, editableProject.name); await openProjectSettings(page, editableProject.name);
await expectHostIndicatorVisible(page); await expectProjectHostContextHidden(page);
await expectHostPickerHidden(page);
}); });
test("script removal via kebab menu removes the row from the form", async ({ test("script removal via kebab menu removes the row from the form", async ({
@@ -9,11 +9,11 @@ import {
expectProjectWorkspaceCountForHost, expectProjectWorkspaceCountForHost,
expectSeparateProjects, expectSeparateProjects,
openGroupedProjectSettings, openGroupedProjectSettings,
openProjectsForSettingsHost,
openProjectDirectory, openProjectDirectory,
openProjectDirectoryWithHosts, openProjectDirectoryWithHosts,
renameProject, renameProject,
selectWorkspaceHost, selectWorkspaceHost,
switchProjectSettingsHost,
} from "./helpers/project-grouping"; } from "./helpers/project-grouping";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client"; 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 }) => { test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => {
await openScenario(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 renameProject(page, "Primary-only project name");
await expectProjectSettingsName(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); await expectProjectSettingsName(page, GROUPED_PROJECT_NAME);
}); });
+2 -2
View File
@@ -882,8 +882,6 @@ function RootStack() {
<Stack.Screen name="welcome" /> <Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" /> <Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" /> <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="new" />
<Stack.Screen name="open-project" /> <Stack.Screen name="open-project" />
<Stack.Screen name="sessions" /> <Stack.Screen name="sessions" />
@@ -893,6 +891,8 @@ function RootStack() {
<Stack.Screen name="h/[serverId]" /> <Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" /> <Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" /> <Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
</ThemedStack> </ThemedStack>
); );
} }
@@ -1,9 +1,10 @@
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react"; import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen"; import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes"; import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
export default function SettingsProjectDetailRoute() { export default function SettingsHostProjectDetailRoute() {
const params = useLocalSearchParams<{ const params = useLocalSearchParams<{
serverId?: string | string[]; serverId?: string | string[];
projectId?: string | string[]; projectId?: string | string[];
@@ -15,5 +16,9 @@ export default function SettingsProjectDetailRoute() {
[projectId, serverId], [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 { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useHostFeatureMap } from "@/runtime/host-features"; import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons"; import { useProjectIcons } from "@/projects/icons";
import { import {
buildNewWorkspaceRoute, buildNewWorkspaceRoute,
buildProjectSettingsRoute, buildProjectSettingsRoute,
@@ -1958,7 +1958,7 @@ export function SidebarWorkspaceList({
() => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []), () => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []),
[groupMode, projects], [groupMode, projects],
); );
const statusProjectIconByProjectViewKey = useProjectIconDataByProjectViewKey({ const statusProjectIconByProjectViewKey = useProjectIcons({
projects: statusProjectIconTargets, projects: statusProjectIconTargets,
}); });
@@ -2127,7 +2127,7 @@ function ProjectModeList({
[parentGestureRef], [parentGestureRef],
); );
const projectIconByProjectViewKey = useProjectIconDataByProjectViewKey({ const projectIconByProjectViewKey = useProjectIcons({
projects: projectIconTargets, projects: projectIconTargets,
}); });
@@ -8,7 +8,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer"; import { Composer } from "@/composer";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft"; 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 { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
@@ -195,7 +195,7 @@ export function WorkspaceSetupDialog() {
throw new Error(t("workspaceSetup.errors.composerStateRequired")); throw new Error(t("workspaceSetup.errors.composerStateRequired"));
} }
const { icon: projectIcon } = useProjectIconQuery({ const { icon: projectIcon } = useProjectIcon({
serverId, serverId,
cwd: sourceDirectory, cwd: sourceDirectory,
}); });
@@ -51,7 +51,7 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
}), }),
).toMatchObject({ ).toMatchObject({
id: "worktree-setup-missing:server-1:prj_local", 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", testID: "worktree-setup-callout-prj_local",
}); });
}); });
@@ -96,6 +96,7 @@ describe("openProjectDirectly", () => {
projectKey: null, projectKey: null,
projectDisplayName: "project", projectDisplayName: "project",
projectCustomName: null, projectCustomName: null,
projectCustomIconRevision: null,
projectKind: "git", projectKind: "git",
projectRootPath: PROJECT_PATH, projectRootPath: PROJECT_PATH,
}, },
@@ -193,6 +194,7 @@ describe("cloneGithubProjectDirectly", () => {
...projectPayload, ...projectPayload,
projectCustomName: null, projectCustomName: null,
projectKey: 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: "تنطبق على ملفات المصدر في الويب وسطح المكتب.", vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
}, },
hostSections: { hostSections: {
projects: "المشاريع",
connections: "اتصالات", connections: "اتصالات",
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
@@ -2121,24 +2122,23 @@ export const ar: TranslationResources = {
}, },
}, },
project: { project: {
noEditableTarget: "ليس لدينا نسخة قابلة للتحرير من هذا المشروع على أي مضيف متصل.", noEditableTarget: "هذا المشروع غير متاح للتحرير على هذا المضيف.",
backToProjects: "العودة إلى المشاريع", backToProjects: "العودة إلى المشاريع",
switchHost: "تبديل المضيف", edit: {
rename: { title: "تعديل المشروع",
renamedToast: "تمت إعادة تسمية المشروع", name: "الاسم",
errorFallback: "تعذرت إعادة تسمية المشروع", nameLabel: "اسم المشروع",
renameLabel: "إعادة تسمية المشروع", icon: "الأيقونة",
resetLabel: "إعادة تعيين اسم المشروع إلى الافتراضي", chooseImage: "اختيار صورة",
projectNameLabel: "اسم المشروع", useAutomatic: "استخدام التلقائي",
saveLabel: "احفظ اسم المشروع", imageUrl: "رابط صورة أو موقع",
cancelLabel: "إلغاء إعادة التسمية", save: "حفظ التغييرات",
reset: "إعادة ضبط", savedToast: "تم تحديث المشروع",
}, },
readFailures: { readFailures: {
invalidTitle: "تعذر تحليل paseo.json", invalidTitle: "تعذر تحليل paseo.json",
invalidDescription: "قم بإصلاح الملف على القرص، ثم أعد تحميله.", invalidDescription: "قم بإصلاح الملف على القرص، ثم أعد تحميله.",
missingTitle: "هذا المضيف ليس لديه هذا المشروع", missingTitle: "هذا المضيف ليس لديه هذا المشروع",
missingWithHosts: "قم بالتبديل إلى مضيف آخر أعلاه، أو أعد التحميل.",
missingSingleHost: "المضيف المحدد ليس لديه سجل لهذا المشروع.", missingSingleHost: "المضيف المحدد ليس لديه سجل لهذا المشروع.",
transportTitle: "تعذر تحميل paseo.json", transportTitle: "تعذر تحميل paseo.json",
transportFallback: "المضيف لم يستجب.", transportFallback: "المضيف لم يستجب.",
+12 -12
View File
@@ -1632,6 +1632,7 @@ export const en = {
vimHint: "Applies to source files on web and desktop.", vimHint: "Applies to source files on web and desktop.",
}, },
hostSections: { hostSections: {
projects: "Projects",
connections: "Connections", connections: "Connections",
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
@@ -2133,24 +2134,23 @@ export const en = {
}, },
}, },
project: { 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", backToProjects: "Back to projects",
switchHost: "Switch host", edit: {
rename: { title: "Edit project",
renamedToast: "Project renamed", name: "Name",
errorFallback: "Couldn't rename project", nameLabel: "Project name",
renameLabel: "Rename project", icon: "Icon",
resetLabel: "Reset project name to default", chooseImage: "Choose image",
projectNameLabel: "Project name", useAutomatic: "Use automatic",
saveLabel: "Save project name", imageUrl: "Image or website URL",
cancelLabel: "Cancel renaming", save: "Save changes",
reset: "Reset", savedToast: "Project updated",
}, },
readFailures: { readFailures: {
invalidTitle: "paseo.json couldn't be parsed", invalidTitle: "paseo.json couldn't be parsed",
invalidDescription: "Fix the file on disk, then reload.", invalidDescription: "Fix the file on disk, then reload.",
missingTitle: "This host doesn't have this project", 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.", missingSingleHost: "The selected host has no record of this project.",
transportTitle: "Couldn't load paseo.json", transportTitle: "Couldn't load paseo.json",
transportFallback: "The host didn't respond.", 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.", vimHint: "Se aplica a archivos fuente en web y escritorio.",
}, },
hostSections: { hostSections: {
projects: "Proyectos",
connections: "Conexiones", connections: "Conexiones",
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
@@ -2173,24 +2174,23 @@ export const es: TranslationResources = {
}, },
}, },
project: { 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", backToProjects: "Volver a proyectos",
switchHost: "Cambiar de anfitrión", edit: {
rename: { title: "Editar proyecto",
renamedToast: "Proyecto renombrado", name: "Nombre",
errorFallback: "No se pudo cambiar el nombre del proyecto", nameLabel: "Nombre del proyecto",
renameLabel: "Cambiar nombre del proyecto", icon: "Icono",
resetLabel: "Restablecer el nombre del proyecto al valor predeterminado", chooseImage: "Elegir imagen",
projectNameLabel: "Nombre del proyecto", useAutomatic: "Usar automático",
saveLabel: "Guardar nombre del proyecto", imageUrl: "URL de imagen o sitio web",
cancelLabel: "Cancelar cambio de nombre", save: "Guardar cambios",
reset: "Reiniciar", savedToast: "Proyecto actualizado",
}, },
readFailures: { readFailures: {
invalidTitle: "paseo.json no se pudo analizar", invalidTitle: "paseo.json no se pudo analizar",
invalidDescription: "Fije el archivo en el disco y luego vuelva a cargarlo.", invalidDescription: "Fije el archivo en el disco y luego vuelva a cargarlo.",
missingTitle: "Este anfitrión no tiene este proyecto", 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.", missingSingleHost: "El anfitrión seleccionado no tiene registro de este proyecto.",
transportTitle: "No se pudo cargar paseo.json", transportTitle: "No se pudo cargar paseo.json",
transportFallback: "El anfitrión no respondió.", 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.", vimHint: "S’applique aux fichiers source sur le web et le bureau.",
}, },
hostSections: { hostSections: {
projects: "Projets",
connections: "Relations", connections: "Relations",
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
@@ -2177,25 +2178,23 @@ export const fr: TranslationResources = {
}, },
}, },
project: { project: {
noEditableTarget: noEditableTarget: "Ce projet n'est pas modifiable sur cet hôte.",
"Nous n'avons pas de copie modifiable de ce projet sur aucun hôte connecté.",
backToProjects: "Retour aux projets", backToProjects: "Retour aux projets",
switchHost: "Changer d'hôte", edit: {
rename: { title: "Modifier le projet",
renamedToast: "Projet renommé", name: "Nom",
errorFallback: "Impossible de renommer le projet", nameLabel: "Nom du projet",
renameLabel: "Renommer le projet", icon: "Icône",
resetLabel: "Réinitialiser le nom du projet par défaut", chooseImage: "Choisir une image",
projectNameLabel: "Nom du projet", useAutomatic: "Utiliser l'automatique",
saveLabel: "Enregistrer le nom du projet", imageUrl: "URL d'image ou de site",
cancelLabel: "Annuler le changement de nom", save: "Enregistrer",
reset: "Réinitialiser", savedToast: "Projet mis à jour",
}, },
readFailures: { readFailures: {
invalidTitle: "paseo.json n'a pas pu être analysé", invalidTitle: "paseo.json n'a pas pu être analysé",
invalidDescription: "Corrigez le fichier sur le disque, puis rechargez.", invalidDescription: "Corrigez le fichier sur le disque, puis rechargez.",
missingTitle: "Cet hôte n'a pas ce projet", 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.", missingSingleHost: "L'hôte sélectionné n'a aucune trace de ce projet.",
transportTitle: "Impossible de charger paseo.json", transportTitle: "Impossible de charger paseo.json",
transportFallback: "L'hôte n'a pas répondu.", transportFallback: "L'hôte n'a pas répondu.",
+12 -13
View File
@@ -1637,6 +1637,7 @@ export const ja: TranslationResources = {
vimHint: "Web とデスクトップのソースファイルに適用されます。", vimHint: "Web とデスクトップのソースファイルに適用されます。",
}, },
hostSections: { hostSections: {
projects: "プロジェクト",
connections: "接続", connections: "接続",
agents: "エージェント", agents: "エージェント",
workspaces: "ワークスペース", workspaces: "ワークスペース",
@@ -2143,25 +2144,23 @@ export const ja: TranslationResources = {
}, },
}, },
project: { project: {
noEditableTarget: noEditableTarget: "このホストではこのプロジェクトを編集できません。",
"接続されているホストのどれにもこのプロジェクトの編集可能なコピーがありません。",
backToProjects: "プロジェクトに戻る", backToProjects: "プロジェクトに戻る",
switchHost: "ホストを切り替え", edit: {
rename: { title: "プロジェクトを編集",
renamedToast: "プロジェクト名を変更しました", name: "名前",
errorFallback: "プロジェクト名を変更できませんでした", nameLabel: "プロジェクト名",
renameLabel: "プロジェクトの名前を変更", icon: "アイコン",
resetLabel: "プロジェクト名をデフォルトにリセット", chooseImage: "画像を選択",
projectNameLabel: "プロジェクト名", useAutomatic: "自動を使う",
saveLabel: "プロジェクト名を保存", imageUrl: "画像またはサイトのURL",
cancelLabel: "名前変更をキャンセル", save: "変更を保存",
reset: "リセット", savedToast: "プロジェクトを更新しました",
}, },
readFailures: { readFailures: {
invalidTitle: "paseo.jsonを解析できませんでした", invalidTitle: "paseo.jsonを解析できませんでした",
invalidDescription: "ディスク上のファイルを修正してから再読み込みしてください。", invalidDescription: "ディスク上のファイルを修正してから再読み込みしてください。",
missingTitle: "このホストにはこのプロジェクトがありません", missingTitle: "このホストにはこのプロジェクトがありません",
missingWithHosts: "上で別のホストに切り替えるか、再読み込みしてください。",
missingSingleHost: "選択したホストにはこのプロジェクトの記録がありません。", missingSingleHost: "選択したホストにはこのプロジェクトの記録がありません。",
transportTitle: "paseo.jsonを読み込めませんでした", transportTitle: "paseo.jsonを読み込めませんでした",
transportFallback: "ホストが応答しませんでした。", transportFallback: "ホストが応答しませんでした。",
+12 -12
View File
@@ -1650,6 +1650,7 @@ export const ptBR: TranslationResources = {
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.", vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
}, },
hostSections: { hostSections: {
projects: "Projetos",
connections: "Conexões", connections: "Conexões",
agents: "Agentes", agents: "Agentes",
workspaces: "Workspaces", workspaces: "Workspaces",
@@ -2158,24 +2159,23 @@ export const ptBR: TranslationResources = {
}, },
}, },
project: { 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", backToProjects: "Voltar para projetos",
switchHost: "Trocar host", edit: {
rename: { title: "Editar projeto",
renamedToast: "Projeto renomeado", name: "Nome",
errorFallback: "Não foi possível renomear projeto", nameLabel: "Nome do projeto",
renameLabel: "Renomear projeto", icon: "Ícone",
resetLabel: "Redefinir nome do projeto para o padrão", chooseImage: "Escolher imagem",
projectNameLabel: "Nome do projeto", useAutomatic: "Usar automático",
saveLabel: "Salvar nome do projeto", imageUrl: "URL de imagem ou site",
cancelLabel: "Cancelar renomeação", save: "Salvar alterações",
reset: "Redefinir", savedToast: "Projeto atualizado",
}, },
readFailures: { readFailures: {
invalidTitle: "Não foi possível analisar paseo.json", invalidTitle: "Não foi possível analisar paseo.json",
invalidDescription: "Corrija o arquivo no disco e recarregue.", invalidDescription: "Corrija o arquivo no disco e recarregue.",
missingTitle: "Este host não tem este projeto", 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.", missingSingleHost: "O host selecionado não tem registro deste projeto.",
transportTitle: "Não foi possível carregar paseo.json", transportTitle: "Não foi possível carregar paseo.json",
transportFallback: "O host não respondeu.", transportFallback: "O host não respondeu.",
+12 -13
View File
@@ -1655,6 +1655,7 @@ export const ru: TranslationResources = {
vimHint: "Применяется к исходным файлам в веб- и настольной версии.", vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
}, },
hostSections: { hostSections: {
projects: "Проекты",
connections: "Соединения", connections: "Соединения",
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
@@ -2161,25 +2162,23 @@ export const ru: TranslationResources = {
}, },
}, },
project: { project: {
noEditableTarget: noEditableTarget: "Этот проект нельзя редактировать на этом хосте.",
"У нас нет редактируемой копии этого проекта ни на одном подключенном хосте.",
backToProjects: "Вернуться к проектам", backToProjects: "Вернуться к проектам",
switchHost: "Сменить хост", edit: {
rename: { title: "Изменить проект",
renamedToast: "Проект переименован", name: "Название",
errorFallback: "Не удалось переименовать проект", nameLabel: "Название проекта",
renameLabel: "Переименовать проект", icon: "Значок",
resetLabel: "Сбросить имя проекта по умолчанию", chooseImage: "Выбрать изображение",
projectNameLabel: "Название проекта", useAutomatic: "Использовать автоматический",
saveLabel: "Сохранить название проекта", imageUrl: "URL изображения или сайта",
cancelLabel: "Отменить переименование", save: "Сохранить изменения",
reset: "Перезагрузить", savedToast: "Проект обновлён",
}, },
readFailures: { readFailures: {
invalidTitle: "paseo.json не удалось разобрать", invalidTitle: "paseo.json не удалось разобрать",
invalidDescription: "Исправьте файл на диске, затем перезагрузите.", invalidDescription: "Исправьте файл на диске, затем перезагрузите.",
missingTitle: "У этого хоста нет этого проекта", missingTitle: "У этого хоста нет этого проекта",
missingWithHosts: "Переключитесь на другой хост выше или перезагрузите компьютер.",
missingSingleHost: "У выбранного хоста нет записей об этом проекте.", missingSingleHost: "У выбранного хоста нет записей об этом проекте.",
transportTitle: "Не удалось загрузить paseo.json.", transportTitle: "Не удалось загрузить paseo.json.",
transportFallback: "Хозяин не ответил.", transportFallback: "Хозяин не ответил.",
+12 -12
View File
@@ -1602,6 +1602,7 @@ export const zhCN: TranslationResources = {
vimHint: "适用于网页和桌面端的源文件。", vimHint: "适用于网页和桌面端的源文件。",
}, },
hostSections: { hostSections: {
projects: "项目",
connections: "连接", connections: "连接",
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
@@ -2095,24 +2096,23 @@ export const zhCN: TranslationResources = {
}, },
}, },
project: { project: {
noEditableTarget: "任何已连接 Host 上都没有这个 Project 的可编辑副本。", noEditableTarget: "此项目无法在这个 Host 上编辑。",
backToProjects: "返回 Projects", backToProjects: "返回 Projects",
switchHost: "切换 Host", edit: {
rename: { title: "编辑 Project",
renamedToast: "Project 已重命名", name: "名称",
errorFallback: "无法重命名 Project", nameLabel: "Project 名称",
renameLabel: "重命名 Project", icon: "图标",
resetLabel: "将 Project 名称重置为默认值", chooseImage: "选择图片",
projectNameLabel: "Project 名称", useAutomatic: "使用自动",
saveLabel: "保存 Project 名称", imageUrl: "图片或网站 URL",
cancelLabel: "取消重命名", save: "保存更改",
reset: "重置", savedToast: "Project 已更新",
}, },
readFailures: { readFailures: {
invalidTitle: "无法解析 paseo.json", invalidTitle: "无法解析 paseo.json",
invalidDescription: "修复磁盘上的文件,然后重新加载。", invalidDescription: "修复磁盘上的文件,然后重新加载。",
missingTitle: "这个 Host 没有这个 Project", missingTitle: "这个 Host 没有这个 Project",
missingWithHosts: "切换到上方其他 Host,或重新加载。",
missingSingleHost: "所选 Host 没有这个 Project 的记录。", missingSingleHost: "所选 Host 没有这个 Project 的记录。",
transportTitle: "无法加载 paseo.json", transportTitle: "无法加载 paseo.json",
transportFallback: "Host 没有响应。", 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, projectId: input.workspace.projectId,
iconWorkingDir, iconWorkingDir,
worktreeSupport: canCreate ? "supported" : "unsupported", worktreeSupport: canCreate ? "supported" : "unsupported",
customIconRevision: input.workspace.projectCustomIconRevision,
}, },
], ],
workspaceKeys: [`${input.serverId}:${input.workspace.id}`], 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; projectId: string;
iconWorkingDir: string; iconWorkingDir: string;
worktreeSupport: "supported" | "unsupported" | "unknown"; worktreeSupport: "supported" | "unsupported" | "unknown";
customIconRevision?: string | null;
} }
export interface WorkspaceStructureProject { export interface WorkspaceStructureProject {
@@ -158,6 +159,7 @@ function addProjectToView(input: {
projectId: project.projectId, projectId: project.projectId,
iconWorkingDir: project.projectRootPath, iconWorkingDir: project.projectRootPath,
worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported", worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported",
customIconRevision: project.projectCustomIconRevision,
}; };
const draft = byProject.get(viewKey); const draft = byProject.get(viewKey);
if (!draft) { if (!draft) {
@@ -43,6 +43,7 @@ function applyProjectDelta(
...workspace, ...workspace,
projectDisplayName: project.projectDisplayName, projectDisplayName: project.projectDisplayName,
projectCustomName: project.projectCustomName, projectCustomName: project.projectCustomName,
projectCustomIconRevision: project.projectCustomIconRevision,
projectRootPath: project.projectRootPath, projectRootPath: project.projectRootPath,
projectKind: project.projectKind, projectKind: project.projectKind,
}); });
@@ -196,6 +196,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
projectId: workspace.projectId, projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName, projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null, projectCustomName: workspace.projectCustomName ?? null,
projectCustomIconRevision: workspace.projectCustomIconRevision ?? null,
projectRootPath: workspace.projectRootPath, projectRootPath: workspace.projectRootPath,
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
projectKind: workspace.projectKind, projectKind: workspace.projectKind,
@@ -75,7 +75,7 @@ import {
useHostProjects, useHostProjects,
type HostProjectListItem, type HostProjectListItem,
} from "@/projects/host-projects"; } from "@/projects/host-projects";
import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons"; import { useProjectIcons } from "@/projects/icons";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
@@ -1578,12 +1578,22 @@ export function NewWorkspaceScreen({
if (!iconWorkingDir) { if (!iconWorkingDir) {
return []; 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], [projects, selectedServerId],
); );
const projectIconDataByProjectViewKey = useProjectIconDataByProjectViewKey({ const projectIconDataByProjectViewKey = useProjectIcons({
projects: projectIconTargets, projects: projectIconTargets,
}); });
const draftKey = buildNewWorkspaceDraftKey(draftId); 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 type { TFunction } from "i18next";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native"; import { Pressable, Text, TextInput, View } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { ProjectIconView } from "@/components/project-icon-view";
import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker";
import type { import type {
PaseoConfigRaw, PaseoConfigRaw,
PaseoConfigRevision, PaseoConfigRevision,
@@ -26,13 +25,16 @@ import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ProjectEditSheet } from "@/components/project-edit-sheet";
import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { useProjects } from "@/hooks/use-projects"; 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 { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { import {
@@ -45,11 +47,12 @@ import {
type ProjectScriptDraft, type ProjectScriptDraft,
} from "@/utils/project-config-form"; } from "@/utils/project-config-form";
import { buildProjectsSettingsRoute } from "@/utils/host-routes"; import { buildProjectsSettingsRoute } from "@/utils/host-routes";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects"; import {
import { getProjectHostEntry, getProjectSummaryForHostProject } from "@/utils/projects"; getProjectHostEntry,
import { useSessionStore } from "@/stores/session-store"; getProjectSummaryForHostProject,
import { selectProjectIdForServer } from "@/stores/session-store-hooks/selectors"; type ProjectHostEntry,
import { resolveProjectSettingsHostTarget } from "@/screens/project-settings-host-selection"; type ProjectSummary,
} from "@/utils/projects";
const SCRIPT_SERVICE_TYPE = "service"; const SCRIPT_SERVICE_TYPE = "service";
@@ -98,82 +101,47 @@ export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSe
() => getProjectSummaryForHostProject(projects, serverId, projectId), () => getProjectSummaryForHostProject(projects, serverId, projectId),
[projectId, projects, serverId], [projectId, projects, serverId],
); );
const editableHosts = useMemo(() => filterEditableHosts(project), [project]); const selectedHost = getProjectHostEntry(project, serverId, projectId);
const routeKey = `${serverId}:${projectId}`; const selectedSnapshot = useHostRuntimeSnapshot(serverId);
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 isHostGone = const isHostGone =
Boolean(selectedServerId) && Boolean(serverId) &&
(selectedSnapshot?.connectionStatus === "offline" || (selectedSnapshot?.connectionStatus === "offline" ||
selectedSnapshot?.connectionStatus === "error"); selectedSnapshot?.connectionStatus === "error");
const selectedHost = getProjectHostEntry(project, selectedServerId, selectedProjectId); const client = useHostRuntimeClient(serverId);
const client = useHostRuntimeClient(selectedHost?.serverId ?? ""); const canEdit =
selectedHost?.isOnline === true &&
selectedHost.serverId.trim().length > 0 &&
selectedHost.repoRoot.trim().length > 0;
if (!project || editableHosts.length === 0 || !selectedHost || !client) { if (!project || !selectedHost || !client || !canEdit) {
return <NoEditableTarget />; return <NoEditableTarget serverId={serverId} />;
} }
return ( return (
<ProjectSettingsBody <ProjectSettingsBody
project={project} project={project}
hosts={editableHosts}
selectedHost={selectedHost} selectedHost={selectedHost}
onSelectHost={setSelectedServerId}
client={client} client={client}
isHostGone={isHostGone} isHostGone={isHostGone}
/> />
); );
} }
function filterEditableHosts(project: ProjectSummary | undefined): ProjectHostEntry[] { function navigateBackToProjects(serverId: string) {
if (!project) return []; router.navigate(buildProjectsSettingsRoute(serverId));
return project.hosts.filter(
(host) => host.isOnline && host.serverId.trim().length > 0 && host.repoRoot.trim().length > 0,
);
} }
function navigateBackToProjects() { function NoEditableTarget({ serverId }: { serverId: string }) {
router.navigate(buildProjectsSettingsRoute());
}
function NoEditableTarget() {
const { t } = useTranslation(); const { t } = useTranslation();
const handleBack = useCallback(() => navigateBackToProjects(serverId), [serverId]);
return ( return (
<View style={styles.noTargetContainer}> <View style={styles.noTargetContainer}>
<BackToProjectsButton /> <BackToProjectsButton serverId={serverId} />
<Text style={styles.noTargetText}>{t("settings.project.noEditableTarget")}</Text> <Text style={styles.noTargetText}>{t("settings.project.noEditableTarget")}</Text>
<Button <Button
testID="project-settings-back-button" testID="project-settings-back-button"
onPress={navigateBackToProjects} onPress={handleBack}
variant="secondary" variant="secondary"
size="md" size="md"
> >
@@ -183,13 +151,14 @@ function NoEditableTarget() {
); );
} }
function BackToProjectsButton() { function BackToProjectsButton({ serverId }: { serverId: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const handleBack = useCallback(() => navigateBackToProjects(serverId), [serverId]);
return ( return (
<Button <Button
testID="project-settings-back-link" testID="project-settings-back-link"
accessibilityLabel={t("settings.project.backToProjects")} accessibilityLabel={t("settings.project.backToProjects")}
onPress={navigateBackToProjects} onPress={handleBack}
variant="ghost" variant="ghost"
size="sm" size="sm"
leftIcon={ArrowLeft} leftIcon={ArrowLeft}
@@ -202,21 +171,25 @@ function BackToProjectsButton() {
interface ProjectSettingsBodyProps { interface ProjectSettingsBodyProps {
project: ProjectSummary; project: ProjectSummary;
hosts: ProjectHostEntry[];
selectedHost: ProjectHostEntry; selectedHost: ProjectHostEntry;
onSelectHost: (serverId: string) => void;
client: DaemonClient; client: DaemonClient;
isHostGone: boolean; isHostGone: boolean;
} }
function ProjectSettingsBody({ function ProjectSettingsBody({
project, project,
hosts,
selectedHost, selectedHost,
onSelectHost,
client, client,
isHostGone, isHostGone,
}: ProjectSettingsBodyProps) { }: 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( const queryKey = useMemo(
() => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const, () => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const,
[selectedHost.serverId, selectedHost.repoRoot], [selectedHost.serverId, selectedHost.repoRoot],
@@ -229,20 +202,42 @@ function ProjectSettingsBody({
}); });
const data = readQuery.data; const data = readQuery.data;
const supportsCustomIcon = useHostFeature(selectedHost.serverId, "projectCustomIcon");
const customIconRevision = selectedHost.customIconRevision ?? null;
const projectIconTargets = useMemo( const projectIconTargets = useMemo(
() => [ () => [
{ {
serverId: selectedHost.serverId, serverId: selectedHost.serverId,
projectViewKey: project.viewKey, projectViewKey: project.viewKey,
projectId: selectedHost.projectId,
iconWorkingDir: selectedHost.repoRoot, 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 loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null;
const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null; const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null;
const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null;
@@ -251,30 +246,46 @@ function ProjectSettingsBody({
void readQuery.refetch(); void readQuery.refetch();
}, [readQuery]); }, [readQuery]);
const hasMultipleHosts = hosts.length > 1;
return ( return (
<View role="main" style={styles.body}> <View role="main" style={styles.body}>
<BackToProjectsButton /> <BackToProjectsButton serverId={selectedHost.serverId} />
<View style={styles.headerBlock}> <View style={styles.headerBlock}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<ProjectTitleIcon <ProjectTitleIcon
iconDataUri={projectIconDataUri} iconDataUri={projectIconDataUri}
projectName={project.projectName} projectName={selectedHost.projectName}
projectViewKey={project.viewKey} projectViewKey={project.viewKey}
/> />
<ProjectNameEditor <Text style={styles.projectTitle} numberOfLines={1}>
key={`${selectedHost.serverId}:${selectedHost.projectId}`} {selectedHost.projectName}
projectName={selectedHost.projectName} </Text>
projectCustomName={selectedHost.projectCustomName} <Pressable
projectId={selectedHost.projectId} testID="project-edit-button"
client={client} 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> </View>
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
</View> </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({ {renderContent({
readQuery, readQuery,
loadedConfig, loadedConfig,
@@ -284,7 +295,6 @@ function ProjectSettingsBody({
queryKey, queryKey,
client, client,
onReload: handleReload, onReload: handleReload,
hasMultipleHosts,
isHostGone, isHostGone,
})} })}
</View> </View>
@@ -300,7 +310,6 @@ interface RenderContentInput {
queryKey: readonly [string, string, string]; queryKey: readonly [string, string, string];
client: DaemonClient; client: DaemonClient;
onReload: () => void; onReload: () => void;
hasMultipleHosts: boolean;
isHostGone: boolean; isHostGone: boolean;
} }
@@ -313,7 +322,6 @@ function renderContent({
queryKey, queryKey,
client, client,
onReload, onReload,
hasMultipleHosts,
isHostGone, isHostGone,
}: RenderContentInput) { }: RenderContentInput) {
if (readQuery.isLoading) { if (readQuery.isLoading) {
@@ -325,29 +333,15 @@ function renderContent({
} }
if (readQuery.isError) { if (readQuery.isError) {
return ( return <ReadFailureCallout kind="transport" error={readQuery.error} onReload={onReload} />;
<ReadFailureCallout
kind="transport"
error={readQuery.error}
onReload={onReload}
hasMultipleHosts={hasMultipleHosts}
/>
);
} }
if (readError) { if (readError) {
return ( return <ReadFailureCallout kind={readError.code} error={null} onReload={onReload} />;
<ReadFailureCallout
kind={readError.code}
error={null}
onReload={onReload}
hasMultipleHosts={hasMultipleHosts}
/>
);
} }
if (isHostGone) { if (isHostGone) {
return <NoEditableTarget />; return <NoEditableTarget serverId={selectedHost.serverId} />;
} }
if (!loadedConfig) { if (!loadedConfig) {
@@ -381,15 +375,13 @@ interface ReadFailureCalloutProps {
kind: "transport" | ProjectConfigRpcError["code"]; kind: "transport" | ProjectConfigRpcError["code"];
error: unknown; error: unknown;
onReload: () => void; onReload: () => void;
hasMultipleHosts: boolean;
} }
function ReadFailureCallout({ kind, error, onReload, hasMultipleHosts }: ReadFailureCalloutProps) { function ReadFailureCallout({ kind, error, onReload }: ReadFailureCalloutProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { testID, title, description } = resolveReadFailureCopy({ const { testID, title, description } = resolveReadFailureCopy({
kind, kind,
error, error,
hasMultipleHosts,
t, t,
}); });
return ( return (
@@ -406,7 +398,6 @@ function ReadFailureCallout({ kind, error, onReload, hasMultipleHosts }: ReadFai
function resolveReadFailureCopy(input: { function resolveReadFailureCopy(input: {
kind: ReadFailureCalloutProps["kind"]; kind: ReadFailureCalloutProps["kind"];
error: unknown; error: unknown;
hasMultipleHosts: boolean;
t: TFunction; t: TFunction;
}): { testID: string; title: string; description: string } { }): { testID: string; title: string; description: string } {
if (input.kind === "invalid_project_config") { if (input.kind === "invalid_project_config") {
@@ -420,9 +411,7 @@ function resolveReadFailureCopy(input: {
return { return {
testID: "project-not-found-callout", testID: "project-not-found-callout",
title: input.t("settings.project.readFailures.missingTitle"), title: input.t("settings.project.readFailures.missingTitle"),
description: input.hasMultipleHosts description: input.t("settings.project.readFailures.missingSingleHost"),
? input.t("settings.project.readFailures.missingWithHosts")
: input.t("settings.project.readFailures.missingSingleHost"),
}; };
} }
if (input.kind === "transport") { if (input.kind === "transport") {
@@ -817,145 +806,6 @@ function ResolveSpinnerColor(): string {
return styles.spinnerColor.color; 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({ function ProjectTitleIcon({
iconDataUri, iconDataUri,
projectName, 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 { interface MetadataPromptSectionProps {
promptKey: MetadataPromptKey; promptKey: MetadataPromptKey;
value: string; value: string;
@@ -1320,37 +1102,9 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
flexShrink: 1, flexShrink: 1,
}, },
nameEditorRow: { editButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minWidth: 0,
},
nameEditorIconButton: {
padding: theme.spacing[1], 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: { titleIcon: {
width: 28, width: 28,
height: 28, height: 28,
@@ -1370,22 +1124,6 @@ const styles = StyleSheet.create((theme) => ({
iconColor: { iconColor: {
color: theme.colors.foregroundMuted, 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: { centered: {
flex: 1, flex: 1,
alignItems: "center", alignItems: "center",
@@ -94,7 +94,10 @@ vi.mock("react-native", () => {
Pressable: passthrough, Pressable: passthrough,
Image: ({ source }: { source?: { uri?: string } }) => Image: ({ source }: { source?: { uri?: string } }) =>
React.createElement("img", { src: source?.uri ?? "" }), 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, useProjects: () => projectsState.current,
})); }));
vi.mock("@/projects/project-icons", () => ({ vi.mock("@/projects/icons", () => ({
useProjectIconDataByProjectViewKey: () => new Map(), useProjectIcons: () => new Map(),
})); }));
import ProjectsScreen from "./projects-screen"; import ProjectsScreen from "./projects-screen";
@@ -293,11 +296,9 @@ describe("ProjectsScreen", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
function render( function render(serverId = "host-a") {
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string },
) {
act(() => { act(() => {
root?.render(<ProjectsScreen view={view} />); root?.render(<ProjectsScreen serverId={serverId} />);
}); });
} }
@@ -306,12 +307,12 @@ describe("ProjectsScreen", () => {
projects: [ projects: [
project({ project({
projectName: "acme/app", 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-"]') ?? []; const rows = container?.querySelectorAll('[data-testid^="project-row-"]') ?? [];
expect(rows.length).toBe(1); expect(rows.length).toBe(1);
@@ -326,7 +327,7 @@ describe("ProjectsScreen", () => {
projects: [project({ viewKey: "remote:github.com/acme/app" })], projects: [project({ viewKey: "remote:github.com/acme/app" })],
}); });
render({ kind: "projects" }); render();
const row = findRow(container!, "remote:github.com/acme/app"); const row = findRow(container!, "remote:github.com/acme/app");
act(() => { act(() => {
@@ -334,7 +335,7 @@ describe("ProjectsScreen", () => {
}); });
expect(navigate).toHaveBeenCalledTimes(1); 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", () => { it("does not render a kebab menu on the row", () => {
@@ -342,7 +343,7 @@ describe("ProjectsScreen", () => {
projects: [project({ viewKey: "remote:github.com/acme/app" })], projects: [project({ viewKey: "remote:github.com/acme/app" })],
}); });
render({ kind: "projects" }); render();
expect( expect(
container?.querySelector('[data-testid="project-row-menu-remote:github.com/acme/app"]'), 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", () => { it("renders a centered loading spinner before the first response", () => {
setProjectsState({ isLoading: true, projects: [] }); setProjectsState({ isLoading: true, projects: [] });
render({ kind: "projects" }); render();
expect(container?.querySelector('[data-testid="projects-loading-spinner"]')).not.toBeNull(); 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", () => { it("renders the empty state when there are no projects", () => {
setProjectsState({ projects: [] }); setProjectsState({ projects: [] });
render({ kind: "projects" }); render();
expect(container?.textContent).toContain("No projects yet"); expect(container?.textContent).toContain("No projects yet");
expect(container?.textContent).not.toContain("Non-GitHub remote projects aren't supported 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" }, { serverId: "b", serverName: "beta", message: "unreachable" },
]; ];
setProjectsState({ setProjectsState({
projects: [project()], projects: [project({ hosts: [hostEntry({ serverId: "a" })] })],
hostErrors, hostErrors,
}); });
render({ kind: "projects" }); render("a");
const banner = container?.querySelector('[data-testid="projects-host-errors"]'); const banner = container?.querySelector('[data-testid="projects-host-errors"]');
expect(banner).not.toBeNull(); expect(banner).not.toBeNull();
expect(banner?.textContent).toContain("alpha"); 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(); 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({ setProjectsState({
projects: [ projects: [
project({ viewKey: "remote:github.com/acme/app" }),
project({ project({
viewKey: "remote:github.com/acme/other", viewKey: "host-a-project",
projectName: "acme/other", projectName: "Host A",
githubUrl: "https://github.com/acme/other", 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"); expect(container?.textContent).not.toContain("Host A");
const other = findRow(container!, "remote:github.com/acme/other"); expect(container?.textContent).toContain("Host B");
expect(selected.getAttribute("data-selected")).toBe("true");
expect(other.getAttribute("data-selected")).toBe("false");
}); });
it("does not include the word 'checkout' anywhere in the rendered tree", () => { 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" }], hostErrors: [{ serverId: "x", serverName: "x", message: "down" }],
}); });
render({ kind: "projects" }); render("a");
const html = container?.innerHTML.toLowerCase() ?? ""; const html = container?.innerHTML.toLowerCase() ?? "";
expect(html).not.toContain("checkout"); 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 { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; 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 { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes"; import { buildProjectSettingsRoute } from "@/utils/host-routes";
import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects"; import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
interface ProjectsScreenProps { 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 { t } = useTranslation();
const { projects, hostErrors, isLoading } = useProjects(); const { projects, hostErrors, isLoading } = useProjects();
const selectedProjectViewKey = const hostProjects = useMemo<HostProject[]>(
view.kind === "project" () =>
? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.viewKey ?? null) projects.flatMap((project) =>
: null; project.hosts
.filter((host) => host.serverId === serverId)
.map((host) => ({ project, host })),
),
[projects, serverId],
);
const scopedErrors = hostErrors.filter((error) => error.serverId === serverId);
const iconTargets = useMemo( const iconTargets = useMemo(
() => () =>
projects.flatMap((project) => { hostProjects.map(({ project, host }) => ({
const host = project.hosts[0]; serverId: host.serverId,
if (!host) return []; projectViewKey: project.viewKey,
return [ projectId: host.projectId,
{ iconWorkingDir: host.repoRoot,
serverId: host.serverId, customIconRevision: host.customIconRevision,
projectViewKey: project.viewKey, })),
iconWorkingDir: host.repoRoot, [hostProjects],
},
];
}),
[projects],
); );
const iconDataByProjectViewKey = useProjectIconDataByProjectViewKey({ const iconDataByProjectViewKey = useProjectIcons({
projects: iconTargets, projects: iconTargets,
}); });
if (isLoading && projects.length === 0) { if (isLoading && hostProjects.length === 0) {
return ( return (
<View style={styles.centered} testID="projects-list"> <View style={styles.centered} testID="projects-list">
<LoadingSpinner size="large" color={styles.spinnerColor.color} /> <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 ( return (
<View style={styles.centered} testID="projects-list"> <View style={styles.centered} testID="projects-list">
<Text style={styles.emptyText}>{t("sidebar.project.empty.title")}</Text> <Text style={styles.emptyText}>{t("sidebar.project.empty.title")}</Text>
@@ -60,14 +67,14 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
return ( return (
<View testID="projects-list"> <View testID="projects-list">
{hostErrors.length > 0 ? <HostErrorsBanner errors={hostErrors} /> : null} {scopedErrors.length > 0 ? <HostErrorsBanner errors={scopedErrors} /> : null}
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
{projects.map((project, index) => ( {hostProjects.map(({ project, host }, index) => (
<ProjectRow <ProjectRow
key={project.viewKey} key={host.projectId}
project={project} project={project}
host={host}
isFirst={index === 0} isFirst={index === 0}
isSelected={selectedProjectViewKey === project.viewKey}
iconDataUri={iconDataByProjectViewKey.get(project.viewKey) ?? null} iconDataUri={iconDataByProjectViewKey.get(project.viewKey) ?? null}
/> />
))} ))}
@@ -94,30 +101,29 @@ function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) {
interface ProjectRowProps { interface ProjectRowProps {
project: ProjectSummary; project: ProjectSummary;
host: ProjectHostEntry;
isFirst: boolean; isFirst: boolean;
isSelected: boolean;
iconDataUri: string | null; iconDataUri: string | null;
} }
function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) { function ProjectRow({ project, host, isFirst, iconDataUri }: ProjectRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { viewKey, projectName } = project; const { viewKey } = project;
const target = project.hosts[0]; const { projectName } = host;
const handleNavigate = useCallback(() => { const handleNavigate = useCallback(() => {
if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId)); router.navigate(buildProjectSettingsRoute(host.serverId, host.projectId));
}, [target]); }, [host.projectId, host.serverId]);
const rowStyle = useCallback( const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row, settingsStyles.row,
!isFirst && settingsStyles.rowBorder, !isFirst && settingsStyles.rowBorder,
styles.row, styles.row,
isSelected && styles.rowSelected, hovered && styles.rowHovered,
hovered && !isSelected && styles.rowHovered,
pressed && styles.rowPressed, pressed && styles.rowPressed,
], ],
[isFirst, isSelected], [isFirst],
); );
return ( return (
@@ -127,7 +133,6 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t("settings.projectList.editProject", { projectName })} accessibilityLabel={t("settings.projectList.editProject", { projectName })}
testID={`project-row-${viewKey}`} testID={`project-row-${viewKey}`}
data-selected={isSelected ? "true" : "false"}
> >
<View style={styles.rowMain}> <View style={styles.rowMain}>
<View style={styles.leading}> <View style={styles.leading}>
@@ -207,9 +212,6 @@ const styles = StyleSheet.create((theme) => ({
rowPressed: { rowPressed: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface3,
}, },
rowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
leading: { leading: {
width: 16, width: 16,
height: 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 type { ComponentType, ReactNode } from "react";
import { import {
Alert, Alert,
@@ -112,7 +112,6 @@ import {
} from "@/desktop/hooks/use-enable-built-in-daemon-option"; } from "@/desktop/hooks/use-enable-built-in-daemon-option";
import { import {
buildOpenProjectRoute, buildOpenProjectRoute,
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute, buildSettingsHostSectionRoute,
buildSettingsSectionRoute, buildSettingsSectionRoute,
type HostSectionSlug, type HostSectionSlug,
@@ -128,7 +127,6 @@ export type SettingsView =
| { kind: "root" } | { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug } | { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string; section: HostSectionSlug } | { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "projects" }
| { kind: "project"; serverId: string; projectId: string }; | { kind: "project"; serverId: string; projectId: string };
interface SidebarSectionItem { interface SidebarSectionItem {
@@ -167,6 +165,7 @@ interface HostSectionItem {
const HOST_SECTION_ITEMS: HostSectionItem[] = [ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "host", labelKey: "settings.hostSections.host", icon: Server }, { 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: "connections", labelKey: "settings.hostSections.connections", icon: Network },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
@@ -180,6 +179,8 @@ function renderHostSettingsContent(
onHostRemoved: () => void, onHostRemoved: () => void,
): ReactNode { ): ReactNode {
switch (view.section) { switch (view.section) {
case "projects":
return <ProjectsScreen serverId={view.serverId} />;
case "connections": case "connections":
return <HostConnectionsPage serverId={view.serverId} />; return <HostConnectionsPage serverId={view.serverId} />;
case "agents": 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 { interface HostPickerProps {
activeServerId: string | null; activeServerId: string | null;
sortedHosts: HostProfile[]; 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 * 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 * <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 — * is using right now; an "Add host" row is always reachable from the list —
@@ -970,7 +939,6 @@ interface SettingsSidebarProps {
onSelectSection: (section: SettingsSectionSlug) => void; onSelectSection: (section: SettingsSectionSlug) => void;
onSelectHostSection: (section: HostSectionSlug) => void; onSelectHostSection: (section: HostSectionSlug) => void;
onSelectHost: (serverId: string) => void; onSelectHost: (serverId: string) => void;
onSelectProjects: () => void;
onAddHost: () => void; onAddHost: () => void;
onBackToWorkspace: () => void; onBackToWorkspace: () => void;
activeHostServerId: string | null; activeHostServerId: string | null;
@@ -982,7 +950,6 @@ function SettingsSidebar({
onSelectSection, onSelectSection,
onSelectHostSection, onSelectHostSection,
onSelectHost, onSelectHost,
onSelectProjects,
onAddHost, onAddHost,
onBackToWorkspace, onBackToWorkspace,
activeHostServerId, activeHostServerId,
@@ -1008,26 +975,23 @@ function SettingsSidebar({
[insets.top, isDesktop], [insets.top, isDesktop],
); );
const selectedSectionId = view.kind === "section" ? view.section : null; const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedHostSection = view.kind === "host" ? view.section : null; let selectedHostSection: HostSectionSlug | null = null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project"; if (view.kind === "host") selectedHostSection = view.section;
if (view.kind === "project") selectedHostSection = "projects";
const sidebarBody = ( const sidebarBody = (
<> <>
<View style={sidebarStyles.list}> <View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.app")}</Text> <Text style={sidebarStyles.groupLabel}>{t("settings.groups.app")}</Text>
{items.map((item) => ( {items.map((item) => (
<Fragment key={item.id}> <SidebarSectionButton
<SidebarSectionButton key={item.id}
itemId={item.id} itemId={item.id}
label={t(item.labelKey)} label={t(item.labelKey)}
icon={item.icon} icon={item.icon}
isSelected={selectedSectionId === item.id} isSelected={selectedSectionId === item.id}
onSelect={onSelectSection} onSelect={onSelectSection}
/> />
{item.id === "general" ? (
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
) : null}
</Fragment>
))} ))}
</View> </View>
<SidebarSeparator /> <SidebarSeparator />
@@ -1146,10 +1110,10 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const localServerId = useLocalDaemonServerId(); const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId); const sortedHosts = useSortedHosts(hosts, localServerId);
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>( const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
view.kind === "host" ? view.serverId : null, view.kind === "host" || view.kind === "project" ? view.serverId : null,
); );
useEffect(() => { useEffect(() => {
if (view.kind === "host") { if (view.kind === "host" || view.kind === "project") {
setSelectedSettingsHostServerId(view.serverId); setSelectedSettingsHostServerId(view.serverId);
} }
}, [view]); }, [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 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. // the picker choice, otherwise the connected local daemon, otherwise the first host.
const activeHostServerId = useMemo(() => { const activeHostServerId = useMemo(() => {
if (view.kind === "host") return view.serverId; if (view.kind === "host" || view.kind === "project") return view.serverId;
return resolveActiveHostServerId({ return resolveActiveHostServerId({
selectedServerId: selectedSettingsHostServerId, selectedServerId: selectedSettingsHostServerId,
localServerId, localServerId,
@@ -1286,6 +1250,15 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const handleSelectHost = useCallback( const handleSelectHost = useCallback(
(serverId: string) => { (serverId: string) => {
setSelectedSettingsHostServerId(serverId); 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") { if (view.kind !== "host") {
return; return;
} }
@@ -1316,15 +1289,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[activeHostServerId, handleAddHost, isCompactLayout, router], [activeHostServerId, handleAddHost, isCompactLayout, router],
); );
const handleSelectProjects = useCallback(() => {
const target = buildProjectsSettingsRoute();
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
}, [isCompactLayout, router]);
const handleScanQr = useCallback(() => { const handleScanQr = useCallback(() => {
closeAddConnectionFlow(); closeAddConnectionFlow();
router.push({ router.push({
@@ -1350,6 +1314,15 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
} }
}, [router]); }, [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(() => { const handleBackToWorkspace = useCallback(() => {
if (navigateToLastWorkspace()) { if (navigateToLastWorkspace()) {
return; return;
@@ -1372,7 +1345,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
if (!item) return null; if (!item) return null;
return { title: t(item.labelKey), Icon: item.icon }; 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 { title: t("settings.projects"), Icon: FolderGit2 };
} }
return null; return null;
@@ -1382,9 +1355,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
if (view.kind === "host") { if (view.kind === "host") {
return renderHostSettingsContent(view, handleHostRemoved); return renderHostSettingsContent(view, handleHostRemoved);
} }
if (view.kind === "projects") {
return <ProjectsScreen view={view} />;
}
if (view.kind === "project") { if (view.kind === "project") {
return <ProjectSettingsScreen serverId={view.serverId} projectId={view.projectId} />; return <ProjectSettingsScreen serverId={view.serverId} projectId={view.projectId} />;
} }
@@ -1489,7 +1459,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
onSelectSection={handleSelectSection} onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection} onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost} onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost} onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace} onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId} 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) { if (isCompactLayout) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader <BackHeader
title={detailHeader?.title} title={detailHeader?.title}
titleAccessory={detailHeader?.titleAccessory} titleAccessory={detailHeader?.titleAccessory}
onBack={detailBackHandler} onBack={handleBackFromDetail}
/> />
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}> <ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View> <View style={styles.content}>{content}</View>
@@ -1534,7 +1498,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
onSelectSection={handleSelectSection} onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection} onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost} onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost} onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace} onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId} activeHostServerId={activeHostServerId}
@@ -56,6 +56,7 @@ function projectDescriptorFromTestWorkspace(workspace: WorkspaceDescriptor): Pro
projectKey: workspace.projectId, projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName, projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null, projectCustomName: workspace.projectCustomName ?? null,
projectCustomIconRevision: workspace.projectCustomIconRevision ?? null,
projectRootPath: workspace.projectRootPath, projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind, projectKind: workspace.projectKind,
}; };
@@ -348,6 +349,7 @@ describe("workspace structure composition", () => {
projectKey: "project-a", projectKey: "project-a",
projectDisplayName: "Project A", projectDisplayName: "Project A",
projectCustomName: null, projectCustomName: null,
projectCustomIconRevision: null,
projectRootPath: "/repo/a", projectRootPath: "/repo/a",
projectKind: "git", projectKind: "git",
}; };
+4
View File
@@ -129,6 +129,7 @@ export interface WorkspaceDescriptor {
projectId: string; projectId: string;
projectDisplayName: string; projectDisplayName: string;
projectCustomName?: string | null; projectCustomName?: string | null;
projectCustomIconRevision?: string | null;
projectRootPath: string; projectRootPath: string;
workspaceDirectory: string; workspaceDirectory: string;
projectKind: WorkspaceDescriptorPayload["projectKind"]; projectKind: WorkspaceDescriptorPayload["projectKind"];
@@ -160,6 +161,7 @@ export function normalizeWorkspaceDescriptor(
projectId: payload.projectId, projectId: payload.projectId,
projectDisplayName: payload.projectDisplayName, projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null, projectCustomName: payload.projectCustomName ?? null,
projectCustomIconRevision: payload.projectCustomIconRevision ?? null,
projectRootPath: payload.projectRootPath, projectRootPath: payload.projectRootPath,
// Canonicalize the workspace directory once, at the store boundary, so every // Canonicalize the workspace directory once, at the store boundary, so every
// consumer can read workspace.workspaceDirectory directly. Empty means "no // consumer can read workspace.workspaceDirectory directly. Empty means "no
@@ -187,6 +189,7 @@ export interface ProjectDescriptor {
projectKey?: string | null; projectKey?: string | null;
projectDisplayName: string; projectDisplayName: string;
projectCustomName: string | null; projectCustomName: string | null;
projectCustomIconRevision?: string | null;
projectRootPath: string; projectRootPath: string;
projectKind: WorkspaceDescriptorPayload["projectKind"]; projectKind: WorkspaceDescriptorPayload["projectKind"];
} }
@@ -199,6 +202,7 @@ export function normalizeProjectDescriptor(
projectKey: payload.projectKey ?? null, projectKey: payload.projectKey ?? null,
projectDisplayName: payload.projectDisplayName, projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null, projectCustomName: payload.projectCustomName ?? null,
projectCustomIconRevision: payload.projectCustomIconRevision ?? null,
projectRootPath: payload.projectRootPath, projectRootPath: payload.projectRootPath,
projectKind: payload.projectKind, 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"); expect(buildSettingsAddHostRoute("retry 1")).toBe("/settings/general?addHost=retry%201");
}); });
it("buildProjectsSettingsRoute returns /settings/projects", () => { it("buildProjectsSettingsRoute scopes the list to a host", () => {
expect(buildProjectsSettingsRoute()).toBe("/settings/projects"); expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects");
}); });
it("buildProjectSettingsRoute addresses a host-local project id", () => { it("buildProjectSettingsRoute addresses a host-local project id", () => {
expect(buildProjectSettingsRoute("host a", "project/1")).toBe( 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("connections")).toBe("connections");
expect(normalizeHostSectionSlug("agents")).toBe("agents"); expect(normalizeHostSectionSlug("agents")).toBe("agents");
expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces"); expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces");
expect(normalizeHostSectionSlug("projects")).toBe("projects");
expect(normalizeHostSectionSlug("providers")).toBe("providers"); expect(normalizeHostSectionSlug("providers")).toBe("providers");
expect(normalizeHostSectionSlug("usage")).toBe("usage"); expect(normalizeHostSectionSlug("usage")).toBe("usage");
expect(normalizeHostSectionSlug("host")).toBe("host"); 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 = [ export const HOST_SECTION_SLUGS = [
"projects",
"connections", "connections",
"agents", "agents",
"workspaces", "workspaces",
@@ -562,15 +563,19 @@ export function buildSettingsHostSectionRoute(serverId: string, section: HostSec
return `/settings/hosts/${encodeSegment(normalized)}/${section}` as const; return `/settings/hosts/${encodeSegment(normalized)}/${section}` as const;
} }
export function buildProjectsSettingsRoute() { export function buildProjectsSettingsRoute(serverId: string) {
return "/settings/projects" as const; 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) { export function buildProjectSettingsRoute(serverId: string, projectId: string) {
if (!serverId.trim() || !projectId.trim()) { if (!serverId.trim() || !projectId.trim()) {
throw new Error("buildProjectSettingsRoute requires a serverId and projectId"); 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 { export function normalizeProjectSettingsRouteId(value: string | string[] | undefined): string {
+4
View File
@@ -24,6 +24,7 @@ export interface ProjectHostEntry {
workspaces: WorkspaceSummary[]; workspaces: WorkspaceSummary[];
gitRuntime?: WorkspaceDescriptor["gitRuntime"]; gitRuntime?: WorkspaceDescriptor["gitRuntime"];
githubRuntime?: WorkspaceDescriptor["githubRuntime"]; githubRuntime?: WorkspaceDescriptor["githubRuntime"];
customIconRevision?: string | null;
} }
export interface ProjectSummary { export interface ProjectSummary {
@@ -85,6 +86,7 @@ interface HostGroup {
serverName: string; serverName: string;
isOnline: boolean; isOnline: boolean;
workspaces: WorkspaceDescriptor[]; workspaces: WorkspaceDescriptor[];
customIconRevision?: string | null;
// Repo root for a project parent that has no workspaces yet. Without it the // 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. // host's repoRoot resolves to "" and the project reads as non-editable.
fallbackRepoRoot: string; fallbackRepoRoot: string;
@@ -156,6 +158,7 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
workspaces: group.workspaces.map(toWorkspaceSummary), workspaces: group.workspaces.map(toWorkspaceSummary),
gitRuntime: canonical?.gitRuntime, gitRuntime: canonical?.gitRuntime,
githubRuntime: canonical?.githubRuntime, githubRuntime: canonical?.githubRuntime,
customIconRevision: canonical?.projectCustomIconRevision ?? group.customIconRevision,
}; };
} }
@@ -219,6 +222,7 @@ function addHostProjects(
serverName: host.serverName, serverName: host.serverName,
isOnline: host.isOnline, isOnline: host.isOnline,
workspaces: [], workspaces: [],
customIconRevision: placement.customIconRevision,
fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "", fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "",
}); });
} }
@@ -4,6 +4,7 @@ export interface SidebarProjectHostTarget {
serverId: string; serverId: string;
projectId: string; projectId: string;
iconWorkingDir: string; iconWorkingDir: string;
customIconRevision?: string | null;
} }
export type SidebarProjectTrailingAction = export type SidebarProjectTrailingAction =
@@ -23,12 +24,18 @@ function hostTarget(input: {
serverId: string; serverId: string;
projectId: string; projectId: string;
iconWorkingDir: string; iconWorkingDir: string;
customIconRevision?: string | null;
}): SidebarProjectHostTarget | null { }): SidebarProjectHostTarget | null {
const iconWorkingDir = input.iconWorkingDir.trim(); const iconWorkingDir = input.iconWorkingDir.trim();
if (!input.serverId || !iconWorkingDir) { if (!input.serverId || !iconWorkingDir) {
return null; return null;
} }
return { serverId: input.serverId, projectId: input.projectId, iconWorkingDir }; return {
serverId: input.serverId,
projectId: input.projectId,
iconWorkingDir,
customIconRevision: input.customIconRevision,
};
} }
export function resolveSidebarProjectIconTarget( export function resolveSidebarProjectIconTarget(
+24
View File
@@ -62,7 +62,9 @@ import type {
DirectorySuggestionsResponse, DirectorySuggestionsResponse,
PaseoWorktreeListResponse, PaseoWorktreeListResponse,
PaseoWorktreeArchiveResponse, PaseoWorktreeArchiveResponse,
ProjectIconSource,
ProjectIconResponse, ProjectIconResponse,
ProjectIconGetResponse,
ProjectAddResponse, ProjectAddResponse,
ProjectCreateDirectoryResponse, ProjectCreateDirectoryResponse,
OpenProjectResponseMessage, OpenProjectResponseMessage,
@@ -2527,6 +2529,18 @@ export class DaemonClient {
return { customName: payload.customName }; return { customName: payload.customName };
} }
async setProjectIcon(
projectId: string,
source: ProjectIconSource,
requestId?: string,
): Promise<void> {
const payload = await this.sendNamespacedCorrelatedSessionRequest<"project.icon.set.response">({
requestId,
message: { type: "project.icon.set.request", projectId, source },
});
if (!payload.accepted) throw new Error(payload.error ?? "setProjectIcon rejected");
}
async removeProject( async removeProject(
projectId: string, projectId: string,
requestId?: string, requestId?: string,
@@ -4296,6 +4310,16 @@ export class DaemonClient {
}); });
} }
async getProjectIcon(
projectId: string,
requestId?: string,
): Promise<ProjectIconGetResponse["payload"]> {
return this.sendNamespacedCorrelatedSessionRequest<"project.icon.get.response">({
requestId,
message: { type: "project.icon.get.request", projectId },
});
}
// ============================================================================ // ============================================================================
// Provider Models / Commands // Provider Models / Commands
// ============================================================================ // ============================================================================
@@ -0,0 +1,7 @@
export function resolveDevElectronArgs(platform, forwardedArgs) {
if (platform === "linux") {
return ["--no-sandbox", ...forwardedArgs];
}
return forwardedArgs;
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { resolveDevElectronArgs } from "./dev-runner-args.mjs";
describe("resolveDevElectronArgs", () => {
it("disables the Chromium process sandbox for Linux development", () => {
expect(resolveDevElectronArgs("linux", ["--remote-debugging-port=9223"])).toEqual([
"--no-sandbox",
"--remote-debugging-port=9223",
]);
});
it.each(["darwin", "win32"])("preserves forwarded arguments on %s", (platform) => {
expect(resolveDevElectronArgs(platform, ["--remote-debugging-port=9223"])).toEqual([
"--remote-debugging-port=9223",
]);
});
});
+3 -1
View File
@@ -5,6 +5,8 @@ import { spawn } from "node:child_process";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { resolveDevElectronArgs } from "./dev-runner-args.mjs";
const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const desktopDir = path.resolve(scriptDir, ".."); const desktopDir = path.resolve(scriptDir, "..");
const rootDir = path.resolve(desktopDir, "../.."); const rootDir = path.resolve(desktopDir, "../..");
@@ -19,7 +21,7 @@ if (!Number.isInteger(expoPort) || expoPort <= 0) {
} }
const expoDevUrl = process.env.EXPO_DEV_URL || `http://localhost:${expoPort}`; const expoDevUrl = process.env.EXPO_DEV_URL || `http://localhost:${expoPort}`;
const electronArgs = process.argv.slice(2); const electronArgs = resolveDevElectronArgs(process.platform, process.argv.slice(2));
const colorEnv = { const colorEnv = {
FORCE_COLOR: process.env.FORCE_COLOR || "1", FORCE_COLOR: process.env.FORCE_COLOR || "1",
npm_config_color: process.env.npm_config_color || "always", npm_config_color: process.env.npm_config_color || "always",
+13
View File
@@ -40,6 +40,19 @@ function fetchWorkspacesResponse(workspace: Record<string, unknown>) {
}; };
} }
describe("project icon message security", () => {
test("rejects URL sources at the daemon boundary", () => {
const parsed = SessionInboundMessageSchema.safeParse({
type: "project.icon.set.request",
projectId: "project-1",
source: { type: "url", url: "http://127.0.0.1/private" },
requestId: "request-1",
});
expect(parsed.success).toBe(false);
});
});
describe("workspace descriptor message compatibility", () => { describe("workspace descriptor message compatibility", () => {
test("old-shaped fetch_workspaces_response without project still parses", () => { test("old-shaped fetch_workspaces_response without project still parses", () => {
const parsed = SessionOutboundMessageSchema.parse( const parsed = SessionOutboundMessageSchema.parse(
+56
View File
@@ -813,6 +813,13 @@ export const UpdateAgentRequestMessageSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
// The daemon accepts only image bytes chosen or acquired by the client. It must
// never fetch a user-provided URL on the host's network.
export const ProjectIconSourceSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("automatic") }),
z.object({ type: z.literal("upload"), data: z.string() }),
]);
export const ProjectRenameRequestSchema = z.object({ export const ProjectRenameRequestSchema = z.object({
type: z.literal("project.rename.request"), type: z.literal("project.rename.request"),
projectId: z.string(), projectId: z.string(),
@@ -821,6 +828,13 @@ export const ProjectRenameRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const ProjectIconSetRequestSchema = z.object({
type: z.literal("project.icon.set.request"),
projectId: z.string(),
source: ProjectIconSourceSchema,
requestId: z.string(),
});
export const ProjectRemoveRequestSchema = z.object({ export const ProjectRemoveRequestSchema = z.object({
type: z.literal("project.remove.request"), type: z.literal("project.remove.request"),
projectId: z.string(), projectId: z.string(),
@@ -1515,6 +1529,16 @@ export const ProjectRenameResponseSchema = z.object({
payload: ProjectRenameResponsePayloadSchema, payload: ProjectRenameResponsePayloadSchema,
}); });
export const ProjectIconSetResponseSchema = z.object({
type: z.literal("project.icon.set.response"),
payload: z.object({
requestId: z.string(),
projectId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const ProjectRemoveResponsePayloadSchema = z.object({ export const ProjectRemoveResponsePayloadSchema = z.object({
requestId: z.string(), requestId: z.string(),
projectId: z.string(), projectId: z.string(),
@@ -2209,6 +2233,12 @@ export const ProjectIconRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const ProjectIconGetRequestSchema = z.object({
type: z.literal("project.icon.get.request"),
projectId: z.string(),
requestId: z.string(),
});
export const FileDownloadTokenRequestSchema = z.object({ export const FileDownloadTokenRequestSchema = z.object({
type: z.literal("file_download_token_request"), type: z.literal("file_download_token_request"),
cwd: z.string(), cwd: z.string(),
@@ -2457,6 +2487,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CloseItemsRequestMessageSchema, CloseItemsRequestMessageSchema,
UpdateAgentRequestMessageSchema, UpdateAgentRequestMessageSchema,
ProjectRenameRequestSchema, ProjectRenameRequestSchema,
ProjectIconSetRequestSchema,
ProjectRemoveRequestSchema, ProjectRemoveRequestSchema,
WorkspaceTitleSetRequestSchema, WorkspaceTitleSetRequestSchema,
WorkspacePinSetRequestSchema, WorkspacePinSetRequestSchema,
@@ -2555,6 +2586,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
FileUnsubscribeRequestSchema, FileUnsubscribeRequestSchema,
FileWriteRequestSchema, FileWriteRequestSchema,
ProjectIconRequestSchema, ProjectIconRequestSchema,
ProjectIconGetRequestSchema,
FileDownloadTokenRequestSchema, FileDownloadTokenRequestSchema,
FileUploadRequestSchema, FileUploadRequestSchema,
ClearAgentAttentionMessageSchema, ClearAgentAttentionMessageSchema,
@@ -2840,6 +2872,8 @@ export const ServerInfoStatusPayloadSchema = z
stableProjectIdentity: z.boolean().optional(), stableProjectIdentity: z.boolean().optional(),
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
workspaceScriptManagement: z.boolean().optional(), workspaceScriptManagement: z.boolean().optional(),
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIcon: z.boolean().optional(),
}) })
.optional(), .optional(),
}) })
@@ -3106,6 +3140,9 @@ export const WorkspaceDescriptorPayloadSchema = z
// value (customName) and projectCustomName mirrors the raw override so the // value (customName) and projectCustomName mirrors the raw override so the
// settings UI can prefill its input and offer a "reset" action. // settings UI can prefill its input and offer a "reset" action.
projectCustomName: z.string().nullable().optional(), projectCustomName: z.string().nullable().optional(),
// Identifies the project's stored custom icon; null means automatic.
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIconRevision: z.string().nullable().optional(),
projectRootPath: z.string(), projectRootPath: z.string(),
workspaceDirectory: z.string().optional(), workspaceDirectory: z.string().optional(),
projectKind: z.enum(["git", "non_git", "directory"]), projectKind: z.enum(["git", "non_git", "directory"]),
@@ -3244,6 +3281,8 @@ export const WorkspaceProjectDescriptorPayloadSchema = z.object({
projectKey: z.string().optional(), projectKey: z.string().optional(),
projectDisplayName: z.string(), projectDisplayName: z.string(),
projectCustomName: z.string().nullable().optional(), projectCustomName: z.string().nullable().optional(),
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIconRevision: z.string().nullable().optional(),
projectRootPath: z.string(), projectRootPath: z.string(),
projectKind: z.enum(["git", "non_git", "directory"]), projectKind: z.enum(["git", "non_git", "directory"]),
}); });
@@ -4755,6 +4794,16 @@ export const ProjectIconResponseSchema = z.object({
}), }),
}); });
export const ProjectIconGetResponseSchema = z.object({
type: z.literal("project.icon.get.response"),
payload: z.object({
projectId: z.string(),
icon: ProjectIconSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
export const FileDownloadTokenResponseSchema = z.object({ export const FileDownloadTokenResponseSchema = z.object({
type: z.literal("file_download_token_response"), type: z.literal("file_download_token_response"),
payload: z.object({ payload: z.object({
@@ -5260,6 +5309,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentRewindResponseMessageSchema, AgentRewindResponseMessageSchema,
UpdateAgentResponseMessageSchema, UpdateAgentResponseMessageSchema,
ProjectRenameResponseSchema, ProjectRenameResponseSchema,
ProjectIconSetResponseSchema,
ProjectRemoveResponseSchema, ProjectRemoveResponseSchema,
WorkspaceTitleSetResponseSchema, WorkspaceTitleSetResponseSchema,
WorkspacePinSetResponseSchema, WorkspacePinSetResponseSchema,
@@ -5310,6 +5360,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FileWriteResponseSchema, FileWriteResponseSchema,
FileUpdateSchema, FileUpdateSchema,
ProjectIconResponseSchema, ProjectIconResponseSchema,
ProjectIconGetResponseSchema,
FileDownloadTokenResponseSchema, FileDownloadTokenResponseSchema,
FileUploadResponseSchema, FileUploadResponseSchema,
ListProviderModelsResponseMessageSchema, ListProviderModelsResponseMessageSchema,
@@ -5444,6 +5495,7 @@ export type AgentDetachResponseMessage = z.infer<typeof AgentDetachResponseMessa
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>; export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>; export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>; export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
export type ProjectIconSetResponse = z.infer<typeof ProjectIconSetResponseSchema>;
export type ProjectRemoveResponse = z.infer<typeof ProjectRemoveResponseSchema>; export type ProjectRemoveResponse = z.infer<typeof ProjectRemoveResponseSchema>;
export type WorkspaceTitleSetResponse = z.infer<typeof WorkspaceTitleSetResponseSchema>; export type WorkspaceTitleSetResponse = z.infer<typeof WorkspaceTitleSetResponseSchema>;
export type WorkspaceTitleSetResponsePayload = z.infer< export type WorkspaceTitleSetResponsePayload = z.infer<
@@ -5590,7 +5642,9 @@ export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>; export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>; export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>; export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
export type ProjectIconSource = z.infer<typeof ProjectIconSourceSchema>;
export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>; export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
export type ProjectIconSetRequest = z.infer<typeof ProjectIconSetRequestSchema>;
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>; export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>; export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>; export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
@@ -5728,6 +5782,8 @@ export type FileWriteResult = z.infer<typeof FileWriteResultSchema>;
export type FileUpdate = z.infer<typeof FileUpdateSchema>; export type FileUpdate = z.infer<typeof FileUpdateSchema>;
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>; export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>; export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
export type ProjectIconGetRequest = z.infer<typeof ProjectIconGetRequestSchema>;
export type ProjectIconGetResponse = z.infer<typeof ProjectIconGetResponseSchema>;
export type ProjectIcon = z.infer<typeof ProjectIconSchema>; export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>; export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>; export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
@@ -668,6 +668,7 @@ describe("project command-center RPCs", () => {
projectId: "prj_created_directory", projectId: "prj_created_directory",
projectDisplayName: "new-project", projectDisplayName: "new-project",
projectCustomName: null, projectCustomName: null,
projectCustomIconRevision: null,
projectRootPath: directoryPath, projectRootPath: directoryPath,
projectKind: "non_git", projectKind: "non_git",
}, },
@@ -4950,6 +4951,7 @@ test("project.list returns every active project descriptor", async () => {
projectKey: "remote:github.com/acme/app", projectKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app", projectDisplayName: "acme/app",
projectCustomName: null, projectCustomName: null,
projectCustomIconRevision: null,
projectRootPath: "/tmp/project-active", projectRootPath: "/tmp/project-active",
projectKind: "git", projectKind: "git",
}, },
+76
View File
@@ -138,6 +138,11 @@ import {
} from "./workspace-registry.js"; } from "./workspace-registry.js";
import { wrapSpokenInput } from "./voice-config.js"; import { wrapSpokenInput } from "./voice-config.js";
import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
import {
readProjectIcon,
removeProjectCustomIcon,
setProjectCustomIcon,
} from "../utils/project-custom-icon.js";
import { VoiceSession } from "./session/voice/voice-session.js"; import { VoiceSession } from "./session/voice/voice-session.js";
import { CheckoutSession } from "./session/checkout/checkout-session.js"; import { CheckoutSession } from "./session/checkout/checkout-session.js";
import { import {
@@ -1934,6 +1939,8 @@ export class Session {
return this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId); return this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId);
case "project.rename.request": case "project.rename.request":
return this.handleProjectRenameRequest(msg.projectId, msg.customName, msg.requestId); return this.handleProjectRenameRequest(msg.projectId, msg.customName, msg.requestId);
case "project.icon.set.request":
return this.handleProjectIconSetRequest(msg);
case "send_agent_message_request": case "send_agent_message_request":
return this.handleSendAgentMessageRequest(msg); return this.handleSendAgentMessageRequest(msg);
case "wait_for_finish_request": case "wait_for_finish_request":
@@ -2131,6 +2138,8 @@ export class Session {
return this.workspaceFilesSession.handleFileWriteRequest(msg); return this.workspaceFilesSession.handleFileWriteRequest(msg);
case "project_icon_request": case "project_icon_request":
return this.workspaceFilesSession.handleProjectIconRequest(msg); return this.workspaceFilesSession.handleProjectIconRequest(msg);
case "project.icon.get.request":
return this.handleProjectIconGetRequest(msg.projectId, msg.requestId);
case "file_download_token_request": case "file_download_token_request":
return this.workspaceFilesSession.handleFileDownloadTokenRequest(msg); return this.workspaceFilesSession.handleFileDownloadTokenRequest(msg);
case "file.upload.request": case "file.upload.request":
@@ -2665,6 +2674,61 @@ export class Session {
} }
} }
private async handleProjectIconSetRequest(
request: Extract<SessionInboundMessage, { type: "project.icon.set.request" }>,
): Promise<void> {
const { projectId, requestId } = request;
try {
const updated = await setProjectCustomIcon({
paseoHome: this.paseoHome,
projectId,
source: request.source,
projects: this.projectRegistry,
});
this.emit({
type: "project.icon.set.response",
payload: { requestId, projectId, accepted: true, error: null },
});
this.emitProjectUpdate({ kind: "upsert", project: updated });
const affectedWorkspaceIds = (await this.workspaceRegistry.list())
.filter((workspace) => workspace.projectId === projectId)
.map((workspace) => workspace.workspaceId);
if (affectedWorkspaceIds.length > 0) {
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds);
}
} catch (error) {
this.emit({
type: "project.icon.set.response",
payload: {
requestId,
projectId,
accepted: false,
error: getErrorMessageOr(error, "Failed to update project icon"),
},
});
}
}
private async handleProjectIconGetRequest(projectId: string, requestId: string): Promise<void> {
try {
const project = await this.projectRegistry.get(projectId);
if (!project) throw new Error("Project not found");
const icon = await readProjectIcon({ paseoHome: this.paseoHome, project });
this.emit({
type: "project.icon.get.response",
payload: { projectId, icon, error: null, requestId },
});
} catch (error) {
this.emit({
type: "project.icon.get.response",
payload: { projectId, icon: null, error: getErrorMessage(error), requestId },
});
}
}
private async handleProjectRemoveRequest( private async handleProjectRemoveRequest(
request: Extract<SessionInboundMessage, { type: "project.remove.request" }>, request: Extract<SessionInboundMessage, { type: "project.remove.request" }>,
): Promise<void> { ): Promise<void> {
@@ -2704,6 +2768,15 @@ export class Session {
} }
await this.projectRegistry.remove(resolvedProjectId); await this.projectRegistry.remove(resolvedProjectId);
await removeProjectCustomIcon({
paseoHome: this.paseoHome,
projectId: resolvedProjectId,
}).catch((error) => {
this.sessionLogger.warn(
{ err: error, projectId: resolvedProjectId },
"Failed to clean up removed project icon",
);
});
} finally { } finally {
if (activeWorkspaceIds.length > 0) { if (activeWorkspaceIds.length > 0) {
this.clearWorkspaceArchiving(activeWorkspaceIds); this.clearWorkspaceArchiving(activeWorkspaceIds);
@@ -4210,6 +4283,7 @@ export class Session {
? resolveProjectDisplayName(resolvedProjectRecord) ? resolveProjectDisplayName(resolvedProjectRecord)
: workspace.projectId, : workspace.projectId,
projectCustomName: resolvedProjectRecord?.customName ?? null, projectCustomName: resolvedProjectRecord?.customName ?? null,
projectCustomIconRevision: resolvedProjectRecord?.customIconRevision ?? null,
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
workspaceDirectory: workspace.cwd, workspaceDirectory: workspace.cwd,
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git", projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
@@ -4296,6 +4370,7 @@ export class Session {
? resolveProjectDisplayName(projectRecord) ? resolveProjectDisplayName(projectRecord)
: result.workspace.projectId, : result.workspace.projectId,
projectCustomName: projectRecord?.customName ?? null, projectCustomName: projectRecord?.customName ?? null,
projectCustomIconRevision: projectRecord?.customIconRevision ?? null,
projectRootPath: projectRecord?.rootPath ?? result.repoRoot, projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
workspaceDirectory: result.workspace.cwd, workspaceDirectory: result.workspace.cwd,
projectKind: projectRecord?.kind ?? "git", projectKind: projectRecord?.kind ?? "git",
@@ -4467,6 +4542,7 @@ export class Session {
...(project.projectKey ? { projectKey: project.projectKey } : {}), ...(project.projectKey ? { projectKey: project.projectKey } : {}),
projectDisplayName: resolveProjectDisplayName(project), projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null, projectCustomName: project.customName ?? null,
projectCustomIconRevision: project.customIconRevision ?? null,
projectRootPath: project.rootPath, projectRootPath: project.rootPath,
projectKind: project.kind, projectKind: project.kind,
}; };
@@ -3405,6 +3405,7 @@ test("archiving the last workspace emits a remove carrying the now-empty project
projectId: project.projectId, projectId: project.projectId,
projectDisplayName: "repo", projectDisplayName: "repo",
projectCustomName: null, projectCustomName: null,
projectCustomIconRevision: null,
projectRootPath: REPO_CWD, projectRootPath: REPO_CWD,
projectKind: "git", projectKind: "git",
}, },
@@ -7402,6 +7403,98 @@ test("a workspace leaving a filtered subscription after bootstrap emits a remova
]); ]);
}); });
const ICON_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,
]);
test("project.icon.set.request publishes a custom icon that project.icon.get serves back", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
);
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true });
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-")));
const projectRoot = path.join(tempDir, "project-without-icons");
mkdirSync(projectRoot, { recursive: true });
session.paseoHome = path.join(tempDir, "paseo-home");
const project = createPersistedProjectRecord({
projectId: "prj_icon",
rootPath: projectRoot,
kind: "git",
displayName: "repo",
createdAt: "2026-07-22T12:00:00.000Z",
updatedAt: "2026-07-22T12:00:00.000Z",
});
const projects = new Map([[project.projectId, project]]);
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
session.projectRegistry.update = async (id, updater) => {
const current = projects.get(id);
if (!current) return null;
const updated = updater(current);
projects.set(id, updated);
return updated;
};
session.workspaceRegistry.list = async () => [];
await session.handleMessage({
type: "project.icon.set.request",
projectId: project.projectId,
source: { type: "upload", data: ICON_PNG_1X1.toString("base64") },
requestId: "req-icon-set",
});
expect(findByType(emitted, "project.icon.set.response")?.payload).toEqual({
requestId: "req-icon-set",
projectId: project.projectId,
accepted: true,
error: null,
});
const revision = projects.get(project.projectId)?.customIconRevision;
expect(revision).toEqual(expect.any(String));
expect(findByType(emitted, "project.update")?.payload).toMatchObject({
kind: "upsert",
project: { projectCustomIconRevision: revision },
});
emitted.length = 0;
await session.handleMessage({
type: "project.icon.get.request",
projectId: project.projectId,
requestId: "req-icon-custom",
});
expect(findByType(emitted, "project.icon.get.response")?.payload).toEqual({
projectId: project.projectId,
icon: { data: ICON_PNG_1X1.toString("base64"), mimeType: "image/png" },
error: null,
requestId: "req-icon-custom",
});
emitted.length = 0;
await session.handleMessage({
type: "project.icon.set.request",
projectId: project.projectId,
source: { type: "automatic" },
requestId: "req-icon-automatic",
});
expect(projects.get(project.projectId)?.customIconRevision).toBeNull();
expect(findByType(emitted, "project.icon.get.response")).toBeUndefined();
await session.handleMessage({
type: "project.icon.get.request",
projectId: project.projectId,
requestId: "req-icon-scan",
});
expect(findByType(emitted, "project.icon.get.response")?.payload).toMatchObject({
icon: null,
error: null,
});
rmSync(tempDir, { recursive: true, force: true });
});
test("project.rename.request stores customName and emits an updated workspace descriptor", async () => { test("project.rename.request stores customName and emits an updated workspace descriptor", async () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const session = asTestSession( const session = asTestSession(
@@ -262,11 +262,13 @@ function createNoopProjectRegistry(): ProjectRegistry {
displayName: input.displayName, displayName: input.displayName,
projectKey: input.projectKey ?? null, projectKey: input.projectKey ?? null,
customName: null, customName: null,
customIconRevision: null,
createdAt: input.timestamp, createdAt: input.timestamp,
updatedAt: input.timestamp, updatedAt: input.timestamp,
archivedAt: null, archivedAt: null,
}), }),
upsert: async () => {}, upsert: async () => {},
update: async () => null,
archive: async () => {}, archive: async () => {},
remove: async () => {}, remove: async () => {},
}; };
@@ -1581,6 +1583,8 @@ export class VoiceAssistantWebSocketServer {
stableProjectIdentity: true, stableProjectIdentity: true,
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
workspaceScriptManagement: true, workspaceScriptManagement: true,
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIcon: true,
}, },
}; };
} }
@@ -364,6 +364,7 @@ describe("wire compatibility", () => {
projectId: "project-1", projectId: "project-1",
projectDisplayName: "Favorite project", projectDisplayName: "Favorite project",
projectCustomName: "Favorite project", projectCustomName: "Favorite project",
projectCustomIconRevision: null,
projectRootPath: "/tmp/project", projectRootPath: "/tmp/project",
projectKind: "git", projectKind: "git",
}, },
@@ -571,6 +571,7 @@ describe("WorkspaceDirectory empty projects", () => {
projectId: "empty", projectId: "empty",
projectDisplayName: "Renamed", projectDisplayName: "Renamed",
projectCustomName: "Renamed", projectCustomName: "Renamed",
projectCustomIconRevision: null,
projectRootPath: "/workspace/empty", projectRootPath: "/workspace/empty",
projectKind: "non_git", projectKind: "non_git",
}, },
@@ -505,6 +505,7 @@ export class WorkspaceDirectory {
projectKey: project.projectKey ?? undefined, projectKey: project.projectKey ?? undefined,
projectDisplayName: resolveProjectDisplayName(project), projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null, projectCustomName: project.customName ?? null,
projectCustomIconRevision: project.customIconRevision ?? null,
projectRootPath: project.rootPath, projectRootPath: project.rootPath,
projectKind: project.kind, projectKind: project.kind,
})); }));
@@ -29,6 +29,12 @@ const PersistedProjectRecordSchema = z.object({
.nullable() .nullable()
.optional() .optional()
.transform((value) => value ?? null), .transform((value) => value ?? null),
// Identifies the project's stored custom icon; null means automatic.
customIconRevision: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
archivedAt: z.string().nullable(), archivedAt: z.string().nullable(),
@@ -114,6 +120,10 @@ export interface ProjectRegistry {
timestamp: string; timestamp: string;
}): Promise<PersistedProjectRecord>; }): Promise<PersistedProjectRecord>;
upsert(record: PersistedProjectRecord): Promise<void>; upsert(record: PersistedProjectRecord): Promise<void>;
update(
projectId: string,
updater: (record: PersistedProjectRecord) => PersistedProjectRecord,
): Promise<PersistedProjectRecord | null>;
archive(projectId: string, archivedAt: string): Promise<void>; archive(projectId: string, archivedAt: string): Promise<void>;
remove(projectId: string): Promise<void>; remove(projectId: string): Promise<void>;
/** Central lifecycle seam for daemon-global project observers. */ /** Central lifecycle seam for daemon-global project observers. */
@@ -381,6 +391,16 @@ export class FileBackedProjectRegistry
await this.notifyMutation({ kind: "upsert", projectId: record.projectId, project: record }); await this.notifyMutation({ kind: "upsert", projectId: record.projectId, project: record });
} }
override async update(
projectId: string,
updater: (record: PersistedProjectRecord) => PersistedProjectRecord,
): Promise<PersistedProjectRecord | null> {
const project = await super.update(projectId, updater);
if (!project) return null;
await this.notifyMutation({ kind: "upsert", projectId, project });
return project;
}
override async archive(projectId: string, archivedAt: string): Promise<void> { override async archive(projectId: string, archivedAt: string): Promise<void> {
const project = await this.archiveIfActive(projectId, archivedAt); const project = await this.archiveIfActive(projectId, archivedAt);
if (!project) return; if (!project) return;
@@ -475,6 +495,7 @@ export function createPersistedProjectRecord(input: {
displayName: string; displayName: string;
customName?: string | null; customName?: string | null;
projectKey?: string | null; projectKey?: string | null;
customIconRevision?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
archivedAt?: string | null; archivedAt?: string | null;
@@ -483,6 +504,7 @@ export function createPersistedProjectRecord(input: {
...input, ...input,
customName: input.customName ?? null, customName: input.customName ?? null,
projectKey: input.projectKey ?? null, projectKey: input.projectKey ?? null,
customIconRevision: input.customIconRevision ?? null,
archivedAt: input.archivedAt ?? null, archivedAt: input.archivedAt ?? null,
}); });
} }
@@ -0,0 +1,137 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
import {
createPersistedProjectRecord,
type PersistedProjectRecord,
type ProjectRegistry,
} from "../server/workspace-registry.js";
import {
readProjectIcon,
removeProjectCustomIcon,
setProjectCustomIcon,
} from "./project-custom-icon.js";
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,
]);
const PNG_1X1_ICON = { data: PNG_1X1.toString("base64"), mimeType: "image/png" };
const cleanup: Array<() => Promise<void>> = [];
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()));
});
async function tempDir(prefix: string): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), prefix));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
return directory;
}
/** A project whose root holds no discoverable icon, over an in-memory registry. */
async function project() {
const rootPath = await tempDir("paseo-project-root-");
const paseoHome = await tempDir("paseo-home-");
let record = createPersistedProjectRecord({
projectId: "project-a",
rootPath,
kind: "git",
displayName: "repo",
createdAt: "2026-07-22T12:00:00.000Z",
updatedAt: "2026-07-22T12:00:00.000Z",
});
const projects = {
get: async () => record,
update: async (_id: string, updater: (r: PersistedProjectRecord) => PersistedProjectRecord) => {
record = updater(record);
return record;
},
} as unknown as ProjectRegistry;
return {
paseoHome,
rootPath,
set: (source: ProjectIconSource) =>
setProjectCustomIcon({ paseoHome, projectId: "project-a", source, projects }),
read: () => readProjectIcon({ paseoHome, project: record }),
revision: () => record.customIconRevision,
remove: () => removeProjectCustomIcon({ paseoHome, projectId: "project-a" }),
};
}
function nonSquarePng(): Buffer {
const wide = Buffer.from(PNG_1X1);
wide.writeUInt32BE(2, 16);
return wide;
}
describe("project custom icon", () => {
it("stores uploaded bytes as the custom icon", async () => {
const target = await project();
await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
expect(target.revision()).toEqual(expect.any(String));
await expect(target.read()).resolves.toEqual(PNG_1X1_ICON);
});
it("scans the project directory when no custom icon is stored", async () => {
const target = await project();
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
await expect(target.read()).resolves.toEqual(PNG_1X1_ICON);
});
it("drops the stored image when the project goes back to automatic", async () => {
const target = await project();
await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
await target.set({ type: "automatic" });
expect(target.revision()).toBeNull();
await expect(target.read()).resolves.toBeNull();
});
it("drops the stored image when the project is removed", async () => {
const target = await project();
const stored = await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
await target.remove();
await expect(
readProjectIcon({ paseoHome: target.paseoHome, project: stored }),
).resolves.toBeNull();
});
it.each([
["data that is not an image", Buffer.from("nope"), "Unsupported or invalid icon file"],
["a non-square image", nonSquarePng(), "square image up to 1024×1024"],
["a file over 512 KB", Buffer.alloc(512 * 1024 + 1), "512 KB or smaller"],
])("rejects %s", async (_name, bytes, message) => {
const target = await project();
await expect(target.set({ type: "upload", data: bytes.toString("base64") })).rejects.toThrow(
message,
);
expect(target.revision()).toBeNull();
});
it("serializes concurrent saves for one project", async () => {
const target = await project();
const [first, second] = await Promise.all([
target.set({ type: "upload", data: PNG_1X1.toString("base64") }),
target.set({ type: "automatic" }),
]);
expect(first.customIconRevision).toEqual(expect.any(String));
expect(second.customIconRevision).toBeNull();
await expect(target.read()).resolves.toBeNull();
});
});
@@ -0,0 +1,123 @@
import { createHash, randomUUID } from "node:crypto";
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
import { writeFileAtomic } from "../server/atomic-file.js";
import type { PersistedProjectRecord, ProjectRegistry } from "../server/workspace-registry.js";
import { getImageDimensions, getProjectIcon, type ProjectIcon } from "./project-icon.js";
const MAX_ICON_BYTES = 512 * 1024;
const ICON_TOO_LARGE_ERROR = "Icon must be 512 KB or smaller";
// A save validates, writes bytes, and updates the registry. Two concurrent
// saves for one project would interleave those steps.
const inFlightSaves = new Map<string, Promise<unknown>>();
/**
* The only writer of custom project icons. The client owns image acquisition;
* this boundary accepts bytes, validates them, and replaces whatever was stored.
*/
export async function setProjectCustomIcon(input: {
paseoHome: string;
projectId: string;
source: ProjectIconSource;
projects: ProjectRegistry;
}): Promise<PersistedProjectRecord> {
return serialize(input.projectId, async () => {
if (!(await input.projects.get(input.projectId))) throw new Error("Project not found");
let customIconRevision: string | null = null;
if (input.source.type === "automatic") {
await removeProjectCustomIcon(input);
} else {
const bytes = Buffer.from(input.source.data, "base64");
validateIcon(bytes);
await writeFileAtomic(cachePath(input.paseoHome, input.projectId), bytes);
customIconRevision = randomUUID();
}
const updated = await input.projects.update(input.projectId, (current) => ({
...current,
customIconRevision,
updatedAt: new Date().toISOString(),
}));
if (!updated) {
// The project was removed mid-save, so its own cleanup missed these bytes.
await removeProjectCustomIcon(input);
throw new Error("Project not found");
}
return updated;
});
}
/** Resolves the icon a project renders with, whichever mode it is in. */
export async function readProjectIcon(input: {
paseoHome: string;
project: PersistedProjectRecord;
}): Promise<ProjectIcon | null> {
if (!input.project.customIconRevision) return getProjectIcon(input.project.rootPath);
try {
return validateIcon(await readFile(cachePath(input.paseoHome, input.project.projectId)));
} catch {
return null;
}
}
export async function removeProjectCustomIcon(input: {
paseoHome: string;
projectId: string;
}): Promise<void> {
await rm(cachePath(input.paseoHome, input.projectId), { force: true });
}
async function serialize<T>(projectId: string, save: () => Promise<T>): Promise<T> {
const previous = inFlightSaves.get(projectId) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(save);
inFlightSaves.set(projectId, next);
try {
return await next;
} finally {
if (inFlightSaves.get(projectId) === next) inFlightSaves.delete(projectId);
}
}
function cachePath(paseoHome: string, projectId: string): string {
const key = createHash("sha256").update(projectId).digest("hex");
return join(paseoHome, "projects", "icons", `${key}.bin`);
}
function detectMimeType(buffer: Buffer): string | null {
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
return "image/png";
}
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
const header = buffer.toString("ascii", 0, 6);
if (header === "GIF87a" || header === "GIF89a") return "image/gif";
if (buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
return "image/webp";
}
if (
buffer.length >= 22 &&
buffer.readUInt16LE(0) === 0 &&
buffer.readUInt16LE(2) === 1 &&
buffer.readUInt16LE(4) > 0
) {
return "image/x-icon";
}
return null;
}
function validateIcon(buffer: Buffer): ProjectIcon {
if (buffer.length === 0 || buffer.length > MAX_ICON_BYTES) {
throw new Error(ICON_TOO_LARGE_ERROR);
}
const mimeType = detectMimeType(buffer);
if (!mimeType) throw new Error("Unsupported or invalid icon file");
const size = getImageDimensions(buffer, mimeType);
if (!size || size.width <= 0 || size.width !== size.height || size.width > 1024) {
throw new Error("Icon must be a square image up to 1024×1024");
}
return { data: buffer.toString("base64"), mimeType };
}
+2 -2
View File
@@ -60,7 +60,7 @@ export interface ProjectIcon {
const MAX_ICON_SIZE = 32 * 1024; // 32KB max const MAX_ICON_SIZE = 32 * 1024; // 32KB max
interface ImageDimensions { export interface ImageDimensions {
width: number; width: number;
height: number; height: number;
} }
@@ -136,7 +136,7 @@ function getWebpDimensions(buffer: Buffer): ImageDimensions | null {
return null; return null;
} }
function getImageDimensions(buffer: Buffer, mimeType: string): ImageDimensions | null { export function getImageDimensions(buffer: Buffer, mimeType: string): ImageDimensions | null {
switch (mimeType) { switch (mimeType) {
case "image/png": case "image/png":
return getPngDimensions(buffer); return getPngDimensions(buffer);