fix(projects): make freshly-added projects editable

Make workspace-less projects flow through aggregated project fetching and keep the settings screen editable immediately after adding one.
This commit is contained in:
huiliaoning
2026-06-27 12:44:16 +08:00
committed by GitHub
parent a908384e5a
commit 26a8a04651
10 changed files with 1531 additions and 22 deletions
+1353 -2
View File
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -11,6 +11,8 @@ import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory"; import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork"; import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
interface WaitForServerOptions { interface WaitForServerOptions {
host?: string; host?: string;
timeoutMs?: number; timeoutMs?: number;
@@ -573,8 +575,18 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false }; const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
relayProcess = spawn( relayProcess = spawn(
"npx", process.execPath,
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)], [
wranglerCliPath,
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
{ {
cwd: relayDir, cwd: relayDir,
env: { ...process.env }, env: { ...process.env },
+61 -2
View File
@@ -1,7 +1,7 @@
import { chmod, readFile } from "node:fs/promises"; import { chmod, readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { expect, test as base } from "./fixtures"; import { expect, test as base, type Page } from "./fixtures";
import { seedWorkspace } from "./helpers/seed-client"; import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { import {
blockPaseoConfigWrites, blockPaseoConfigWrites,
bumpPaseoConfigOnDisk, bumpPaseoConfigOnDisk,
@@ -30,6 +30,8 @@ import {
restorePaseoConfig, restorePaseoConfig,
unblockPaseoConfigWrites, unblockPaseoConfigWrites,
} from "./helpers/project-settings"; } from "./helpers/project-settings";
import { gotoAppShell } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
const updatedSetup = ["npm install", "npm run build"]; const updatedSetup = ["npm install", "npm run build"];
@@ -131,7 +133,64 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
return readFile(path.join(project.path, "paseo.json"), "utf8"); return readFile(path.join(project.path, "paseo.json"), "utf8");
} }
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPath);
await page.keyboard.press("Enter");
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: path.basename(projectPath) })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
}
async function openProjectSettingsFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
const openSettingsItem = page.getByTestId(`sidebar-project-menu-open-settings-${projectId}`);
await expect(openSettingsItem).toBeVisible({ timeout: 10_000 });
await openSettingsItem.click();
}
test.describe("Projects settings", () => { test.describe("Projects settings", () => {
test("freshly-added project with no workspace is editable from the sidebar without a reload", async ({
page,
}) => {
const repo = await createTempGitRepo("projects-settings-empty-");
const client = await connectSeedClient();
let projectId: string | null = null;
try {
await gotoAppShell(page);
projectId = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectId);
await expectProjectSettingsFormVisible(page);
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
} finally {
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => { test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
await openProjects(page); await openProjects(page);
await openProjectSettings(page, editableProject.name); await openProjectSettings(page, editableProject.name);
+1 -1
View File
@@ -133,7 +133,7 @@
"serve-sim": "^0.1.40", "serve-sim": "^0.1.40",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"vitest": "^4.1.6", "vitest": "^4.1.6",
"wrangler": "^4.75.0", "wrangler": "^4.105.0",
"ws": "^8.20.0" "ws": "^8.20.0"
} }
} }
+13 -1
View File
@@ -1,5 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { projectsQueryKey } from "@/hooks/use-projects";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project"; import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
@@ -9,6 +11,7 @@ export function useOpenProject(
const normalizedServerId = serverId?.trim() ?? ""; const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId); const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const queryClient = useQueryClient();
const canAddProject = useSessionStore((state) => const canAddProject = useSessionStore((state) =>
normalizedServerId normalizedServerId
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true ? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
@@ -19,7 +22,7 @@ export function useOpenProject(
return useCallback( return useCallback(
async (path: string) => { async (path: string) => {
return openProjectDirectly({ const result = await openProjectDirectly({
serverId: normalizedServerId, serverId: normalizedServerId,
projectPath: path, projectPath: path,
isConnected, isConnected,
@@ -28,6 +31,14 @@ export function useOpenProject(
addEmptyProject, addEmptyProject,
setHasHydratedWorkspaces, setHasHydratedWorkspaces,
}); });
// The aggregated projects query derives the project list from a fetch
// that now includes empty projects; refetch so a freshly-added project
// (no workspace yet) is immediately editable instead of only after a
// restart.
if (result.ok) {
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
}
return result;
}, },
[ [
addEmptyProject, addEmptyProject,
@@ -35,6 +46,7 @@ export function useOpenProject(
client, client,
isConnected, isConnected,
normalizedServerId, normalizedServerId,
queryClient,
setHasHydratedWorkspaces, setHasHydratedWorkspaces,
], ],
); );
@@ -169,7 +169,7 @@ export function useSidebarWorkspacesList(options?: {
void (async () => { void (async () => {
const next = new Map<string, WorkspaceDescriptor>(); const next = new Map<string, WorkspaceDescriptor>();
try { try {
const workspaces = await fetchAllWorkspaceDescriptors({ const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
client, client,
sort: [{ key: "activity_at", direction: "desc" }], sort: [{ key: "activity_at", direction: "desc" }],
}); });
@@ -181,6 +181,9 @@ export function useSidebarWorkspacesList(options?: {
} }
const store = useSessionStore.getState(); const store = useSessionStore.getState();
store.setWorkspaces(serverId, next); store.setWorkspaces(serverId, next);
// Keep parents with no workspaces yet, so a manual refresh doesn't drop
// a freshly-added project from the sidebar.
store.setEmptyProjects(serverId, emptyProjects);
store.setHasHydratedWorkspaces(serverId, true); store.setHasHydratedWorkspaces(serverId, true);
} catch (error) { } catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", { console.error("[WorkspaceFetch][sidebar-refresh] failed", {
@@ -1,5 +1,4 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching"; import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects"; import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
@@ -58,13 +57,14 @@ export async function fetchAggregatedProjects(
serverName: host.serverName, serverName: host.serverName,
isOnline, isOnline,
workspaces: [], workspaces: [],
emptyProjects: [],
}, },
error: null, error: null,
}; };
} }
try { try {
const workspaces: WorkspaceDescriptor[] = await fetchAllWorkspaceDescriptors({ const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
client, client,
sort: [{ key: "name", direction: "asc" }], sort: [{ key: "name", direction: "asc" }],
}); });
@@ -74,6 +74,7 @@ export async function fetchAggregatedProjects(
serverName: host.serverName, serverName: host.serverName,
isOnline, isOnline,
workspaces, workspaces,
emptyProjects,
}, },
error: null, error: null,
}; };
@@ -84,6 +85,7 @@ export async function fetchAggregatedProjects(
serverName: host.serverName, serverName: host.serverName,
isOnline, isOnline,
workspaces: [], workspaces: [],
emptyProjects: [],
}, },
error: { error: {
serverId: host.serverId, serverId: host.serverId,
@@ -1,16 +1,33 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; import {
type EmptyProjectDescriptor,
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
export type FetchWorkspacesClient = Pick<DaemonClient, "fetchWorkspaces">; export type FetchWorkspacesClient = Pick<DaemonClient, "fetchWorkspaces">;
export type FetchWorkspacesSort = NonNullable< export type FetchWorkspacesSort = NonNullable<
Parameters<DaemonClient["fetchWorkspaces"]>[0] Parameters<DaemonClient["fetchWorkspaces"]>[0]
>["sort"]; >["sort"];
export interface FetchWorkspaceDescriptorsResult {
workspaces: WorkspaceDescriptor[];
/**
* Project parents with no active workspaces. The daemon only rides these on
* the first page of `fetchWorkspaces`, so a freshly-added project that has no
* workspace yet shows up here and nowhere else — drop it and the project is
* invisible to anything that derives projects from workspaces alone.
*/
emptyProjects: EmptyProjectDescriptor[];
}
export async function fetchAllWorkspaceDescriptors(input: { export async function fetchAllWorkspaceDescriptors(input: {
client: FetchWorkspacesClient; client: FetchWorkspacesClient;
sort: FetchWorkspacesSort; sort: FetchWorkspacesSort;
}): Promise<WorkspaceDescriptor[]> { }): Promise<FetchWorkspaceDescriptorsResult> {
const entries: WorkspaceDescriptor[] = []; const workspaces: WorkspaceDescriptor[] = [];
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
let cursor: string | null = null; let cursor: string | null = null;
while (true) { while (true) {
@@ -18,12 +35,16 @@ export async function fetchAllWorkspaceDescriptors(input: {
sort: input.sort, sort: input.sort,
page: cursor ? { limit: 200, cursor } : { limit: 200 }, page: cursor ? { limit: 200, cursor } : { limit: 200 },
}); });
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry))); workspaces.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
for (const project of payload.emptyProjects ?? []) {
const descriptor = normalizeEmptyProjectDescriptor(project);
emptyProjects.set(descriptor.projectId, descriptor);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break; break;
} }
cursor = payload.pageInfo.nextCursor; cursor = payload.pageInfo.nextCursor;
} }
return entries; return { workspaces, emptyProjects: Array.from(emptyProjects.values()) };
} }
+32
View File
@@ -514,4 +514,36 @@ describe("buildProjects", () => {
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy"); expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
expect(summary?.hosts[0]?.workspaceCount).toBe(1); expect(summary?.hosts[0]?.workspaceCount).toBe(1);
}); });
it("surfaces a project with no workspaces yet and gives its host an editable repoRoot", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [],
emptyProjects: [
{
projectId: "/repo/fresh",
projectDisplayName: "fresh",
projectCustomName: null,
projectRootPath: "/repo/fresh",
projectKind: "git",
},
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("/repo/fresh");
expect(summary?.totalWorkspaceCount).toBe(0);
expect(summary?.hosts).toHaveLength(1);
// repoRoot must be non-empty or the project settings screen treats the host
// as non-editable and shows its empty state.
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/fresh");
expect(summary?.hosts[0]?.workspaceCount).toBe(0);
});
}); });
+23 -6
View File
@@ -1,4 +1,4 @@
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model"; import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"; import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
@@ -37,6 +37,8 @@ export interface ProjectHost {
serverName: string; serverName: string;
isOnline: boolean; isOnline: boolean;
workspaces: WorkspaceDescriptor[]; workspaces: WorkspaceDescriptor[];
/** Project parents with no active workspaces, so they still surface as projects. */
emptyProjects?: EmptyProjectDescriptor[];
} }
export interface BuildProjectsInput { export interface BuildProjectsInput {
@@ -54,6 +56,9 @@ interface HostGroup {
serverName: string; serverName: string;
isOnline: boolean; isOnline: boolean;
workspaces: WorkspaceDescriptor[]; workspaces: WorkspaceDescriptor[];
// Repo root for a project parent that has no workspaces yet. Without it the
// host's repoRoot resolves to "" and the project reads as non-editable.
fallbackRepoRoot: string;
} }
interface ProjectGroup { interface ProjectGroup {
@@ -81,7 +86,13 @@ function findProjectCustomName(
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] { function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
return buildHostProjectList({ return buildHostProjectList({
projects: buildWorkspaceStructureProjects({ projects: buildWorkspaceStructureProjects({
sessions: [{ serverId: host.serverId, workspaces: host.workspaces }], sessions: [
{
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
},
],
}), }),
}); });
} }
@@ -94,14 +105,14 @@ function deriveGithubUrl(projectKey: string): string | undefined {
return `https://github.com/${match[1]}/${match[2]}`; return `https://github.com/${match[1]}/${match[2]}`;
} }
function resolveHostRepoRoot(workspaces: WorkspaceDescriptor[]): string { function resolveHostRepoRoot(group: HostGroup): string {
for (const workspace of workspaces) { for (const workspace of group.workspaces) {
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot; const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
if (mainRepoRoot) { if (mainRepoRoot) {
return mainRepoRoot; return mainRepoRoot;
} }
} }
return workspaces[0]?.projectRootPath ?? ""; return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot;
} }
function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary { function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
@@ -115,7 +126,7 @@ function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
} }
function toHostEntry(group: HostGroup): ProjectHostEntry { function toHostEntry(group: HostGroup): ProjectHostEntry {
const repoRoot = resolveHostRepoRoot(group.workspaces); const repoRoot = resolveHostRepoRoot(group);
const canonical = const canonical =
group.workspaces.find((workspace) => workspace.projectRootPath === repoRoot) ?? group.workspaces.find((workspace) => workspace.projectRootPath === repoRoot) ??
group.workspaces[0]; group.workspaces[0];
@@ -159,6 +170,11 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
const groups = new Map<string, ProjectGroup>(); const groups = new Map<string, ProjectGroup>();
for (const host of input.hosts) { for (const host of input.hosts) {
const emptyRepoRootByProjectKey = new Map<string, string>();
for (const emptyProject of host.emptyProjects ?? []) {
emptyRepoRootByProjectKey.set(emptyProject.projectId, emptyProject.projectRootPath);
}
const hostProjects = buildHostProjectEntries(host); const hostProjects = buildHostProjectEntries(host);
for (const hostProject of hostProjects) { for (const hostProject of hostProjects) {
const customName = findProjectCustomName(host.workspaces, hostProject.projectKey); const customName = findProjectCustomName(host.workspaces, hostProject.projectKey);
@@ -182,6 +198,7 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
serverName: host.serverName, serverName: host.serverName,
isOnline: host.isOnline, isOnline: host.isOnline,
workspaces: [], workspaces: [],
fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "",
}); });
} }
} }