Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-06-04 14:23:00 +07:00
28 changed files with 1392 additions and 424 deletions
+31 -5
View File
@@ -124,14 +124,30 @@ export async function openNewWorkspaceComposer(
}); });
} }
export async function clickNewWorkspaceButton( export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
await page.getByTestId("sidebar-new-workspace").click();
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
export async function expectNewWorkspaceProjectSelected(
page: Page, page: Page,
input: { projectKey: string; projectDisplayName: string; prompt?: string }, projectDisplayName: string,
): Promise<void> {
const projectPicker = page.getByRole("button", { name: "Workspace project" });
await expect(projectPicker).toBeVisible({ timeout: 30_000 });
await expect(projectPicker).toContainText(projectDisplayName);
}
export async function submitNewWorkspacePrompt(
page: Page,
prompt = "Hello from e2e",
): Promise<void> { ): Promise<void> {
await openNewWorkspaceComposer(page, input);
const composer = page.getByRole("textbox", { name: "Message agent..." }); const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 }); await expect(composer).toBeVisible({ timeout: 30_000 });
await composer.fill(input.prompt ?? "Hello from e2e"); await composer.fill(prompt);
const createButton = page const createButton = page
.getByTestId("message-input-root") .getByTestId("message-input-root")
.getByRole("button", { name: "Create" }); .getByRole("button", { name: "Create" });
@@ -139,6 +155,14 @@ export async function clickNewWorkspaceButton(
await createButton.click(); await createButton.click();
} }
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string; prompt?: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
await submitNewWorkspacePrompt(page, input.prompt);
}
export async function openStartingRefPicker(page: Page): Promise<void> { export async function openStartingRefPicker(page: Page): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger"); const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 }); await expect(trigger).toBeVisible({ timeout: 30_000 });
@@ -177,7 +201,9 @@ export async function selectPickerOptionByKeyboard(page: Page, label: string): P
const searchInput = page.getByPlaceholder("Search branches and PRs"); const searchInput = page.getByPlaceholder("Search branches and PRs");
await expect(searchInput).toBeVisible({ timeout: 30_000 }); await expect(searchInput).toBeVisible({ timeout: 30_000 });
await page.keyboard.type(label); await page.keyboard.type(label);
await page.keyboard.press("ArrowDown"); await expect(page.getByTestId(`new-workspace-ref-picker-branch-${label}`)).toBeVisible({
timeout: 10_000,
});
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
} }
+8 -7
View File
@@ -7,16 +7,17 @@ import {
archiveWorkspaceFromDaemon, archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon, archiveLocalWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader, assertNewWorkspaceSidebarAndHeader,
clickNewWorkspaceButton,
closeBranchPicker, closeBranchPicker,
connectNewWorkspaceDaemonClient, connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon, createWorktreeViaDaemon,
delayBrowserAgentCreatedStatus, delayBrowserAgentCreatedStatus,
expectComposerGithubAttachmentPill, expectComposerGithubAttachmentPill,
expectNewWorkspaceProjectSelected,
expectPickerClosed, expectPickerClosed,
expectPickerOpen, expectPickerOpen,
expectPickerSelected, expectPickerSelected,
expectStartingRefPickerTriggerPr, expectStartingRefPickerTriggerPr,
openGlobalNewWorkspaceComposer,
openBranchPicker, openBranchPicker,
openNewWorkspaceComposer, openNewWorkspaceComposer,
openProjectViaDaemon, openProjectViaDaemon,
@@ -24,6 +25,7 @@ import {
selectBranchInPicker, selectBranchInPicker,
selectGitHubPrInPicker, selectGitHubPrInPicker,
selectPickerOptionByKeyboard, selectPickerOptionByKeyboard,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace"; } from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
@@ -193,7 +195,7 @@ test.describe("New workspace flow", () => {
} }
}); });
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({ test("global new workspace uses the last active project and creates one agent tab", async ({
page, page,
}) => { }) => {
const serverId = getServerId(); const serverId = getServerId();
@@ -217,10 +219,9 @@ test.describe("New workspace flow", () => {
subtitle: openedProject.projectDisplayName, subtitle: openedProject.projectDisplayName,
}); });
await clickNewWorkspaceButton(page, { await openGlobalNewWorkspaceComposer(page);
projectKey: openedProject.projectKey, await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
projectDisplayName: openedProject.projectDisplayName, await submitNewWorkspacePrompt(page);
});
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId, serverId,
@@ -400,7 +401,7 @@ test.describe("New workspace flow", () => {
} }
}); });
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({ test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
page, page,
}) => { }) => {
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] }); const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
+1 -5
View File
@@ -9,14 +9,10 @@ export default function HostNewWorkspaceRoute() {
projectId?: string; projectId?: string;
}>(); }>();
const serverId = typeof params.serverId === "string" ? params.serverId : ""; const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : ""; const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
const displayName = typeof params.name === "string" ? params.name : undefined; const displayName = typeof params.name === "string" ? params.name : undefined;
const projectId = typeof params.projectId === "string" ? params.projectId : undefined; const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
if (!sourceDirectory) {
return null;
}
return ( return (
<NewWorkspaceScreen <NewWorkspaceScreen
serverId={serverId} serverId={serverId}
+31 -1
View File
@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
import { FolderPlus, Home, MessagesSquare, Search, Settings, X } from "lucide-react-native"; import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
import { import {
type Dispatch, type Dispatch,
memo, memo,
@@ -66,6 +66,7 @@ import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
import { import {
buildHostOpenProjectRoute, buildHostOpenProjectRoute,
buildHostNewWorkspaceRoute,
buildHostSessionsRoute, buildHostSessionsRoute,
buildSettingsRoute, buildSettingsRoute,
mapPathnameToServer, mapPathnameToServer,
@@ -962,6 +963,12 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center"); const commandCenterKeys = useShortcutKeys("toggle-command-center");
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]); const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
const handleNewWorkspacePress = useCallback(() => {
if (!serverId) {
return;
}
router.push(buildHostNewWorkspaceRoute(serverId));
}, [serverId]);
const searchButtonStyle = useCallback( const searchButtonStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.workspacesHeaderIconButton, styles.workspacesHeaderIconButton,
@@ -974,6 +981,29 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
<View style={styles.workspacesSectionHeader}> <View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text> <Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}> <View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="New workspace"
testID="sidebar-new-workspace"
style={searchButtonStyle}
onPress={handleNewWorkspacePress}
>
{({ hovered, pressed }) => (
<Plus
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="New workspace" />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}> <Tooltip delayDuration={300}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Pressable <Pressable
@@ -11,7 +11,7 @@ import {
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug"; import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
import { ProjectIconView } from "@/components/project-icon-view"; import { ProjectIconView } from "@/components/project-icon-view";
import { AdaptiveRenameModal } from "@/components/rename-modal"; import { AdaptiveRenameModal } from "@/components/rename-modal";
@@ -57,9 +57,9 @@ import {
import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types"; import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { import {
buildHostNewWorkspaceRoute, buildHostNewWorkspaceRoute,
buildProjectSettingsRoute, buildProjectSettingsRoute,
@@ -181,17 +181,6 @@ function getPrIconUniMapping(state: PrHint["state"]) {
} }
} }
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;
}
function isWorkspaceSelected(input: { function isWorkspaceSelected(input: {
selection: ActiveWorkspaceSelection | null; selection: ActiveWorkspaceSelection | null;
serverId: string | null; serverId: string | null;
@@ -2484,74 +2473,8 @@ function ProjectModeList({
: undefined, : undefined,
[parentGestureRef], [parentGestureRef],
); );
const projectIconRequests = useMemo(() => {
if (!serverId) {
return [];
}
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const project of projects) {
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
continue;
}
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
}
return Array.from(unique.values());
}, [projects, serverId]);
const projectIconQueries = useQueries({ const projectIconByProjectKey = useProjectIconDataByProjectKey({ serverId, projects });
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,
);
const projectIconByProjectKey = 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 projects) {
const cwd = project.iconWorkingDir.trim();
if (!cwd || !serverId) {
byProject.set(project.projectKey, null);
continue;
}
byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null);
}
return byProject;
}, [projectIconData, projectIconRequests, projects, serverId]);
useEffect(() => { useEffect(() => {
const timeouts = creatingWorkspaceTimeoutsRef.current; const timeouts = creatingWorkspaceTimeoutsRef.current;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import { import {
appendMissingOrderKeys, appendMissingOrderKeys,
applyStoredOrdering, applyStoredOrdering,
@@ -1,6 +1,10 @@
import type { PrHint } from "@/git/use-pr-status-query"; import type { PrHint } from "@/git/use-pr-status-query";
import {
canCreateWorktreeForProjectKind,
type HostProjectListItem,
} from "@/projects/host-project-model";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
const EMPTY_PROJECTS: SidebarProjectEntry[] = []; const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
@@ -32,12 +36,13 @@ export interface SidebarProjectEntry {
projectName: string; projectName: string;
projectKind: WorkspaceDescriptor["projectKind"]; projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string; iconWorkingDir: string;
canCreateWorktree: boolean;
workspaces: SidebarWorkspaceEntry[]; workspaces: SidebarWorkspaceEntry[];
} }
function createStructuralWorkspaceEntry(input: { function createStructuralWorkspaceEntry(input: {
serverId: string; serverId: string;
project: WorkspaceStructureProject; project: HostProjectListItem;
workspaceId: string; workspaceId: string;
}): SidebarWorkspaceEntry { }): SidebarWorkspaceEntry {
return { return {
@@ -65,6 +70,22 @@ function createStructuralWorkspaceEntry(input: {
export function buildSidebarProjectsFromStructure(input: { export function buildSidebarProjectsFromStructure(input: {
serverId: string; serverId: string;
projects: WorkspaceStructureProject[]; projects: WorkspaceStructureProject[];
}): SidebarProjectEntry[] {
return buildSidebarProjectsFromHostProjects({
projects: input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
})),
});
}
export function buildSidebarProjectsFromHostProjects(input: {
projects: readonly HostProjectListItem[];
}): SidebarProjectEntry[] { }): SidebarProjectEntry[] {
if (input.projects.length === 0) { if (input.projects.length === 0) {
return EMPTY_PROJECTS; return EMPTY_PROJECTS;
@@ -75,9 +96,10 @@ export function buildSidebarProjectsFromStructure(input: {
projectName: project.projectName, projectName: project.projectName,
projectKind: project.projectKind, projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir, iconWorkingDir: project.iconWorkingDir,
canCreateWorktree: project.canCreateWorktree,
workspaces: project.workspaceKeys.map((workspaceId) => workspaces: project.workspaceKeys.map((workspaceId) =>
createStructuralWorkspaceEntry({ createStructuralWorkspaceEntry({
serverId: input.serverId, serverId: project.serverId,
project, project,
workspaceId, workspaceId,
}), }),
+1 -1
View File
@@ -6,7 +6,7 @@ import {
type ProjectsHostInput, type ProjectsHostInput,
type ProjectsRuntime, type ProjectsRuntime,
type ProjectsRuntimeSnapshot, type ProjectsRuntimeSnapshot,
} from "./use-projects"; } from "@/projects/aggregated-projects";
type FetchWorkspaces = DaemonClient["fetchWorkspaces"]; type FetchWorkspaces = DaemonClient["fetchWorkspaces"];
type FetchWorkspacesResult = Awaited<ReturnType<FetchWorkspaces>>; type FetchWorkspacesResult = Awaited<ReturnType<FetchWorkspaces>>;
+12 -119
View File
@@ -1,18 +1,21 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; import type { ProjectSummary } from "@/utils/projects";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects"; import {
fetchAggregatedProjects,
type ProjectHostError,
type ProjectsHostInput,
} from "@/projects/aggregated-projects";
export type {
ProjectHostError,
ProjectsHostInput,
ProjectsRuntime,
} from "@/projects/aggregated-projects";
export const projectsQueryKey = ["projects"] as const; export const projectsQueryKey = ["projects"] as const;
export interface ProjectHostError {
serverId: string;
serverName: string;
message: string;
}
export interface UseProjectsResult { export interface UseProjectsResult {
projects: ProjectSummary[]; projects: ProjectSummary[];
hostErrors: ProjectHostError[]; hostErrors: ProjectHostError[];
@@ -21,116 +24,6 @@ export interface UseProjectsResult {
refetch: () => void; refetch: () => void;
} }
export interface ProjectsRuntimeSnapshot {
connectionStatus: string;
}
export interface ProjectsRuntime {
getClient(serverId: string): Pick<DaemonClient, "fetchWorkspaces"> | null;
getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined;
}
export interface ProjectsHostInput {
serverId: string;
serverName: string;
}
export interface FetchAggregatedProjectsInput {
hosts: ProjectsHostInput[];
runtime: ProjectsRuntime;
}
export interface FetchAggregatedProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
}
interface HostWorkspacesResult {
host: ProjectHost;
error: ProjectHostError | null;
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function fetchAllWorkspaceDescriptors(
client: Pick<DaemonClient, "fetchWorkspaces">,
): Promise<WorkspaceDescriptor[]> {
const entries: WorkspaceDescriptor[] = [];
let cursor: string | null = null;
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: "name", direction: "asc" }],
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
}
return entries;
}
export async function fetchAggregatedProjects(
input: FetchAggregatedProjectsInput,
): Promise<FetchAggregatedProjectsResult> {
const results = await Promise.all(
input.hosts.map(async (host): Promise<HostWorkspacesResult> => {
const snapshot = input.runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = input.runtime.getClient(host.serverId);
if (!client || !isOnline) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: null,
};
}
try {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: await fetchAllWorkspaceDescriptors(client),
},
error: null,
};
} catch (error) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: {
serverId: host.serverId,
serverName: host.serverName,
message: toErrorMessage(error),
},
};
}
}),
);
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
return {
...buildProjects({ hosts: results.map((result) => result.host) }),
hostErrors,
};
}
export function useProjects(): UseProjectsResult { export function useProjects(): UseProjectsResult {
const hosts = useHosts(); const hosts = useHosts();
const runtime = getHostRuntimeStore(); const runtime = getHostRuntimeStore();
@@ -1,17 +1,13 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { selectPrHintFromStatus } from "@/git/use-pr-status-query"; import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
import { useWorkspaceStructure } from "@/stores/session-store-hooks"; import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts"; import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import { import {
buildSidebarProjectsFromStructure, buildSidebarProjectsFromHostProjects,
computeSidebarOrderUpdates, computeSidebarOrderUpdates,
deriveSidebarLoadingState, deriveSidebarLoadingState,
type SidebarProjectEntry, type SidebarProjectEntry,
@@ -21,6 +17,7 @@ import {
export { export {
appendMissingOrderKeys, appendMissingOrderKeys,
applyStoredOrdering, applyStoredOrdering,
buildSidebarProjectsFromHostProjects,
buildSidebarProjectsFromStructure, buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates, computeSidebarOrderUpdates,
deriveSidebarLoadingState, deriveSidebarLoadingState,
@@ -68,10 +65,6 @@ export interface SidebarWorkspacesListResult {
refreshAll: () => void; refreshAll: () => void;
} }
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload);
}
export function useSidebarWorkspacesList(options?: { export function useSidebarWorkspacesList(options?: {
serverId?: string | null; serverId?: string | null;
enabled?: boolean; enabled?: boolean;
@@ -89,7 +82,7 @@ export function useSidebarWorkspacesList(options?: {
const hasHydratedWorkspaces = useSessionStore((state) => const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false, isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
); );
const workspaceStructure = useWorkspaceStructure(isActive ? serverId : null); const hostProjects = useHostProjects(isActive ? serverId : null);
const connectionStatus = useSyncExternalStore( const connectionStatus = useSyncExternalStore(
(onStoreChange) => (onStoreChange) =>
@@ -111,14 +104,13 @@ export function useSidebarWorkspacesList(options?: {
); );
const projects = useMemo(() => { const projects = useMemo(() => {
if (!serverId || workspaceStructure.projects.length === 0) { if (!serverId || hostProjects.length === 0) {
return EMPTY_PROJECTS; return EMPTY_PROJECTS;
} }
return buildSidebarProjectsFromStructure({ return buildSidebarProjectsFromHostProjects({
serverId, projects: hostProjects,
projects: workspaceStructure.projects,
}); });
}, [serverId, workspaceStructure]); }, [hostProjects, serverId]);
useEffect(() => { useEffect(() => {
if (!serverId) { if (!serverId) {
@@ -156,24 +148,16 @@ export function useSidebarWorkspacesList(options?: {
} }
void (async () => { void (async () => {
const next = new Map<string, WorkspaceDescriptor>(); const next = new Map<string, WorkspaceDescriptor>();
let cursor: string | null = null;
try { try {
while (true) { const workspaces = await fetchAllWorkspaceDescriptors({
const payload = await client.fetchWorkspaces({ client,
sort: [{ key: "activity_at", direction: "desc" }], sort: [{ key: "activity_at", direction: "desc" }],
page: cursor ? { limit: 200, cursor } : { limit: 200 }, });
}); for (const workspace of workspaces) {
for (const entry of payload.entries) { if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
const workspace = toWorkspaceDescriptor(entry); continue;
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
} }
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { next.set(workspace.id, workspace);
break;
}
cursor = payload.pageInfo.nextCursor;
} }
const store = useSessionStore.getState(); const store = useSessionStore.getState();
store.setWorkspaces(serverId, next); store.setWorkspaces(serverId, next);
@@ -181,7 +165,6 @@ export function useSidebarWorkspacesList(options?: {
} catch (error) { } catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", { console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId, serverId,
cursor,
error, error,
}); });
// ignore explicit refresh failures; hook keeps existing data // ignore explicit refresh failures; hook keeps existing data
@@ -0,0 +1,103 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
export interface ProjectHostError {
serverId: string;
serverName: string;
message: string;
}
export interface ProjectsRuntimeSnapshot {
connectionStatus: string;
}
export interface ProjectsRuntime {
getClient(serverId: string): Pick<DaemonClient, "fetchWorkspaces"> | null;
getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined;
}
export interface ProjectsHostInput {
serverId: string;
serverName: string;
}
export interface FetchAggregatedProjectsInput {
hosts: ProjectsHostInput[];
runtime: ProjectsRuntime;
}
export interface FetchAggregatedProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
}
interface HostWorkspacesResult {
host: ProjectHost;
error: ProjectHostError | null;
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function fetchAggregatedProjects(
input: FetchAggregatedProjectsInput,
): Promise<FetchAggregatedProjectsResult> {
const results = await Promise.all(
input.hosts.map(async (host): Promise<HostWorkspacesResult> => {
const snapshot = input.runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = input.runtime.getClient(host.serverId);
if (!client || !isOnline) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: null,
};
}
try {
const workspaces: WorkspaceDescriptor[] = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "name", direction: "asc" }],
});
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces,
},
error: null,
};
} catch (error) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: {
serverId: host.serverId,
serverName: host.serverName,
message: toErrorMessage(error),
},
};
}
}),
);
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
return {
...buildProjects({ hosts: results.map((result) => result.host) }),
hostErrors,
};
}
@@ -0,0 +1,117 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
export interface HostProjectListItem {
serverId: string;
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
workspaceKeys: string[];
canCreateWorktree: boolean;
}
export interface HostProjectRouteContext {
serverId: string;
projectId?: string;
displayName?: string;
sourceDirectory?: string;
}
function trimOptional(value: string | undefined): string | undefined {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : undefined;
}
export function canCreateWorktreeForProjectKind(
projectKind: WorkspaceDescriptor["projectKind"],
): boolean {
return projectKind === "git";
}
export function buildHostProjectList(input: {
serverId: string;
projects: readonly WorkspaceStructureProject[];
}): HostProjectListItem[] {
return input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
}));
}
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
const projectKey = trimOptional(route.projectId);
const iconWorkingDir = trimOptional(route.sourceDirectory);
if (!projectKey || !iconWorkingDir) {
return null;
}
return {
serverId: route.serverId,
projectKey,
projectName: trimOptional(route.displayName) ?? projectKey,
projectKind: "git",
iconWorkingDir,
workspaceKeys: [],
canCreateWorktree: true,
};
}
export function hostProjectFromWorkspace(input: {
serverId: string;
workspace: WorkspaceDescriptor | null;
}): HostProjectListItem | null {
if (!input.workspace) {
return null;
}
const projectKey = input.workspace.projectId.trim();
const iconWorkingDir = input.workspace.projectRootPath.trim();
if (!projectKey || !iconWorkingDir) {
return null;
}
return {
serverId: input.serverId,
projectKey,
projectName: input.workspace.projectDisplayName || projectKey,
projectKind: input.workspace.projectKind,
iconWorkingDir,
workspaceKeys: [input.workspace.id],
canCreateWorktree: canCreateWorktreeForProjectKind(input.workspace.projectKind),
};
}
export function resolveInitialWorktreeProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
}): HostProjectListItem | null {
if (input.routeProject?.canCreateWorktree) {
return input.routeProject;
}
if (input.lastActiveProject?.canCreateWorktree) {
return input.lastActiveProject;
}
return input.projects.find((project) => project.canCreateWorktree) ?? null;
}
export function resolveSelectedHostProject(input: {
selectedProjectKey: string | null;
projects: readonly HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
}): HostProjectListItem | null {
const selectedProjectKey = input.selectedProjectKey?.trim() ?? "";
if (!selectedProjectKey) {
return null;
}
return (
input.projects.find((project) => project.projectKey === selectedProjectKey) ??
(input.routeProject?.projectKey === selectedProjectKey ? input.routeProject : null) ??
(input.lastActiveProject?.projectKey === selectedProjectKey ? input.lastActiveProject : null)
);
}
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
buildHostProjectList,
canCreateWorktreeForProjectKind,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
} from "./host-project-model";
function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceStructureProject {
return {
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
};
}
function hostProject(input: Partial<HostProjectListItem>): HostProjectListItem {
return {
serverId: input.serverId ?? "host-a",
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
canCreateWorktree: input.canCreateWorktree ?? true,
};
}
function workspace(input: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return {
id: input.id ?? "workspace-a",
projectId: input.projectId ?? "project-a",
projectDisplayName: input.projectDisplayName ?? "Project A",
projectRootPath: input.projectRootPath ?? "/repo/a",
workspaceDirectory: input.workspaceDirectory ?? "/repo/a",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
const routeProject = hostProject({
projectKey: "route-project",
projectName: "Route Project",
iconWorkingDir: "/repo/route",
});
const lastActiveProject = hostProject({
projectKey: "last-project",
projectName: "Last Project",
iconWorkingDir: "/repo/last",
});
const firstProject = hostProject({
projectKey: "first-project",
projectName: "First Project",
iconWorkingDir: "/repo/first",
});
describe("host project list", () => {
it("preserves workspace-structure order and project metadata", () => {
expect(
buildHostProjectList({
serverId: "host-a",
projects: [
structureProject({
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
workspaceKeys: ["workspace-b"],
}),
structureProject({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
}),
],
}),
).toEqual([
{
serverId: "host-a",
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
workspaceKeys: ["workspace-b"],
canCreateWorktree: false,
},
{
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
canCreateWorktree: true,
},
]);
});
it("keeps worktree capability separate from project listability", () => {
expect(canCreateWorktreeForProjectKind("git")).toBe(true);
expect(canCreateWorktreeForProjectKind("directory")).toBe(false);
});
it("uses route project before last active project when it can create worktrees", () => {
expect(
resolveInitialWorktreeProject({
routeProject,
lastActiveProject,
projects: [firstProject],
}),
).toEqual(routeProject);
});
it("skips non-worktree route and last-active projects", () => {
expect(
resolveInitialWorktreeProject({
routeProject: { ...routeProject, projectKind: "directory", canCreateWorktree: false },
lastActiveProject: {
...lastActiveProject,
projectKind: "directory",
canCreateWorktree: false,
},
projects: [
{ ...firstProject, projectKind: "directory", canCreateWorktree: false },
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
],
}),
).toMatchObject({ projectKey: "git-project" });
});
it("leaves the project empty when no worktree-capable project is available", () => {
expect(
resolveInitialWorktreeProject({
routeProject: null,
lastActiveProject: null,
projects: [{ ...firstProject, projectKind: "directory", canCreateWorktree: false }],
}),
).toBeNull();
});
it("keeps a selected route project available before project hydration", () => {
expect(
resolveSelectedHostProject({
selectedProjectKey: routeProject.projectKey,
projects: [],
routeProject,
lastActiveProject: null,
}),
).toEqual(routeProject);
});
it("converts route project only when it has a key and source directory", () => {
expect(
hostProjectFromRoute({
serverId: "host-a",
projectId: "project-a",
displayName: "Project A",
sourceDirectory: "/repo/a",
}),
).toEqual({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: [],
canCreateWorktree: true,
});
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
});
it("converts last active workspaces with matching worktree capability", () => {
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
canCreateWorktree: true,
});
expect(
hostProjectFromWorkspace({
serverId: "host-a",
workspace: workspace({ projectKind: "directory" }),
}),
).toMatchObject({ projectKind: "directory", canCreateWorktree: false });
});
});
@@ -0,0 +1,24 @@
import { useMemo } from "react";
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
export {
buildHostProjectList,
canCreateWorktreeForProjectKind,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
type HostProjectRouteContext,
} from "@/projects/host-project-model";
export function useHostProjects(serverId: string | null): HostProjectListItem[] {
const workspaceStructure = useWorkspaceStructure(serverId);
return useMemo(() => {
if (!serverId || workspaceStructure.projects.length === 0) {
return [];
}
return buildHostProjectList({ serverId, projects: workspaceStructure.projects });
}, [serverId, workspaceStructure.projects]);
}
@@ -0,0 +1,97 @@
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 | null;
projectKey: 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 useProjectIconDataByProjectKey(input: {
serverId: string | null;
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 serverId = project.serverId || input.serverId;
if (!serverId) {
continue;
}
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
continue;
}
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
}
return Array.from(unique.values());
}, [input.projects, input.serverId]);
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 serverId = project.serverId || input.serverId;
const cwd = project.iconWorkingDir.trim();
if (!cwd || !serverId) {
byProject.set(project.projectKey, null);
continue;
}
byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null);
}
return byProject;
}, [input.projects, input.serverId, projectIconData, projectIconRequests]);
}
@@ -0,0 +1,29 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
export type FetchWorkspacesClient = Pick<DaemonClient, "fetchWorkspaces">;
export type FetchWorkspacesSort = NonNullable<
Parameters<DaemonClient["fetchWorkspaces"]>[0]
>["sort"];
export async function fetchAllWorkspaceDescriptors(input: {
client: FetchWorkspacesClient;
sort: FetchWorkspacesSort;
}): Promise<WorkspaceDescriptor[]> {
const entries: WorkspaceDescriptor[] = [];
let cursor: string | null = null;
while (true) {
const payload = await input.client.fetchWorkspaces({
sort: input.sort,
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
}
return entries;
}
@@ -0,0 +1,96 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
export interface WorkspaceStructureProject {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
workspaceKeys: string[];
}
export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
): number {
const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, {
numeric: true,
sensitivity: "base",
});
if (nameDelta !== 0) {
return nameDelta;
}
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
sensitivity: "base",
});
}
function compareWorkspaceStructureProjects(
left: WorkspaceStructureProject,
right: WorkspaceStructureProject,
): number {
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
});
}
export function buildWorkspaceStructureProjects(input: {
serverId: string;
workspaces: Iterable<WorkspaceDescriptor>;
}): WorkspaceStructureProject[] {
const workspaceList = Array.from(input.workspaces);
if (workspaceList.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE.projects;
}
const byProject = new Map<
string,
WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
>();
for (const workspace of workspaceList) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
workspaceKeys: [],
workspaces: [],
} satisfies WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
});
project.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${input.serverId}:${workspace.id}`,
});
byProject.set(workspace.projectId, project);
}
const projects = Array.from(byProject.values()).map(
({ workspaces: projectWorkspaces, ...project }) => {
const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems);
return Object.assign({}, project, {
workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId),
});
},
);
projects.sort(compareWorkspaceStructureProjects);
return projects;
}
+450 -39
View File
@@ -5,11 +5,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Check, ChevronDown, GitBranch, GitPullRequest, X } from "lucide-react-native"; import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native";
import { Composer } from "@/composer"; import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import { ProjectIconView } from "@/components/project-icon-view";
import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox"; import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -21,15 +22,29 @@ import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useGithubSearchQuery } from "@/git/use-github-search-query"; import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import {
navigateToWorkspace,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import {
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
useHostProjects,
type HostProjectListItem,
} from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/composer/types"; import type { ImageAttachment, MessagePayload } from "@/composer/types";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages"; import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
@@ -58,7 +73,7 @@ function resolveCheckoutRequest(
interface NewWorkspaceScreenProps { interface NewWorkspaceScreenProps {
serverId: string; serverId: string;
sourceDirectory: string; sourceDirectory?: string;
projectId?: string; projectId?: string;
displayName?: string; displayName?: string;
} }
@@ -68,13 +83,38 @@ interface PickerOptionData {
itemById: Map<string, PickerItem>; itemById: Map<string, PickerItem>;
} }
interface ProjectOptionData {
options: ComboboxOptionType[];
projectByOptionId: Map<string, HostProjectListItem>;
}
interface PickerSelection { interface PickerSelection {
item: PickerItem; item: PickerItem;
attachedPrNumber: number | null; attachedPrNumber: number | null;
} }
interface NewWorkspaceProjectPickerInput {
serverId: string;
sourceDirectory?: string;
projectId?: string;
displayName?: string;
}
interface NewWorkspaceProjectPickerState {
projects: HostProjectListItem[];
selectedProject: HostProjectListItem | null;
selectedSourceDirectory: string | null;
selectedDisplayName: string;
projectPickerOptions: ComboboxOptionType[];
projectByOptionId: Map<string, HostProjectListItem>;
selectedProjectOptionId: string;
projectTriggerLabel: string;
handleSelectProjectOption: (id: string) => void;
}
const BRANCH_OPTION_PREFIX = "branch:"; const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:"; const PR_OPTION_PREFIX = "github-pr:";
const PROJECT_OPTION_PREFIX = "project:";
function RefPickerBadgeContent({ function RefPickerBadgeContent({
selectedItem, selectedItem,
@@ -150,6 +190,68 @@ function RefPickerTrigger({
); );
} }
function ProjectPickerTrigger({
pickerAnchorRef,
onPress,
disabled,
badgePressableStyle,
label,
projectKey,
iconDataUri,
iconColor,
iconSize,
}: {
pickerAnchorRef: React.RefObject<View | null>;
onPress: () => void;
disabled: boolean;
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
label: string;
projectKey: string | null;
iconDataUri: string | null;
iconColor: string;
iconSize: number;
}) {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?";
return (
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={pickerAnchorRef}
testID="new-workspace-project-picker-trigger"
onPress={onPress}
disabled={disabled}
style={badgePressableStyle}
accessibilityRole="button"
accessibilityLabel="Workspace project"
>
<View style={styles.badgeIconBox}>
{projectKey ? (
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
imageStyle={styles.badgeProjectIcon}
fallbackStyle={styles.badgeProjectIconFallback}
textStyle={styles.badgeProjectIconFallbackText}
/>
) : (
<Folder size={iconSize} color={iconColor} />
)}
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{label}
</Text>
<ChevronDown size={iconSize} color={iconColor} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Choose project</Text>
</TooltipContent>
</Tooltip>
);
}
function CheckoutHintBadge({ function CheckoutHintBadge({
prNumber, prNumber,
onAccept, onAccept,
@@ -239,6 +341,59 @@ function PickerOptionItem({
); );
} }
function ProjectOptionItem({
testID,
projectKey,
iconDataUri,
label,
description,
selected,
active,
disabled,
onPress,
}: {
testID: string;
projectKey: string;
iconDataUri: string | null;
label: string;
description: string | undefined;
selected: boolean;
active: boolean;
disabled: boolean;
onPress: () => void;
}) {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?";
const leadingSlot = useMemo(
() => (
<View style={styles.rowIconBox}>
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
imageStyle={styles.projectOptionIcon}
fallbackStyle={styles.projectOptionIconFallback}
textStyle={styles.projectOptionIconFallbackText}
/>
</View>
),
[iconDataUri, placeholderInitial, projectKey],
);
return (
<ComboboxItem
testID={testID}
label={label}
description={description}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
function branchOptionId(name: string): string { function branchOptionId(name: string): string {
return `${BRANCH_OPTION_PREFIX}${name}`; return `${BRANCH_OPTION_PREFIX}${name}`;
} }
@@ -247,6 +402,10 @@ function prOptionId(number: number): string {
return `${PR_OPTION_PREFIX}${number}`; return `${PR_OPTION_PREFIX}${number}`;
} }
function projectOptionId(projectId: string): string {
return `${PROJECT_OPTION_PREFIX}${projectId}`;
}
function formatPrLabel(item: { number: number; title: string }): string { function formatPrLabel(item: { number: number; title: string }): string {
return `#${item.number} ${item.title}`; return `#${item.number} ${item.title}`;
} }
@@ -256,7 +415,7 @@ function pickerItemLabel(item: PickerItem): string {
} }
function pickerItemTriggerLabel(item: PickerItem): string { function pickerItemTriggerLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item); return pickerItemLabel(item);
} }
function computePickerOptionData( function computePickerOptionData(
@@ -292,6 +451,108 @@ function computePickerOptionData(
return { options: timedOptions.map((t) => t.option), itemById: idMap }; return { options: timedOptions.map((t) => t.option), itemById: idMap };
} }
function computeProjectOptionData(projects: readonly HostProjectListItem[]): ProjectOptionData {
const projectByOptionId = new Map<string, HostProjectListItem>();
const options = projects.map((project) => {
const id = projectOptionId(project.projectKey);
projectByOptionId.set(id, project);
return { id, label: project.projectName };
});
return { options, projectByOptionId };
}
function useNewWorkspaceProjectPicker({
serverId,
sourceDirectory,
projectId,
displayName: displayNameProp,
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
const [manualProjectKey, setManualProjectKey] = useState<string | null>(null);
const displayName = displayNameProp?.trim() ?? "";
const projects = useHostProjects(serverId || null);
const lastWorkspaceSelection = useLastWorkspaceSelection();
const lastWorkspaceServerId = lastWorkspaceSelection?.serverId === serverId ? serverId : null;
const lastWorkspaceId =
lastWorkspaceSelection?.serverId === serverId ? lastWorkspaceSelection.workspaceId : null;
const lastWorkspace = useWorkspace(lastWorkspaceServerId, lastWorkspaceId);
const routeProject = useMemo(
() =>
hostProjectFromRoute({
serverId,
projectId,
displayName,
sourceDirectory,
}),
[displayName, projectId, serverId, sourceDirectory],
);
const lastActiveProject = useMemo(
() => hostProjectFromWorkspace({ serverId, workspace: lastWorkspace }),
[lastWorkspace, serverId],
);
const initialProject = useMemo(
() =>
resolveInitialWorktreeProject({
routeProject,
lastActiveProject,
projects,
}),
[lastActiveProject, projects, routeProject],
);
const worktreeProjects = useMemo(
() => projects.filter((project) => project.canCreateWorktree),
[projects],
);
const selectedProjectKey = manualProjectKey ?? initialProject?.projectKey ?? null;
const selectedProject = useMemo(
() =>
resolveSelectedHostProject({
selectedProjectKey,
projects,
routeProject,
lastActiveProject,
}),
[lastActiveProject, projects, routeProject, selectedProjectKey],
);
const { options: projectPickerOptions, projectByOptionId }: ProjectOptionData = useMemo(
() => computeProjectOptionData(worktreeProjects),
[worktreeProjects],
);
const handleSelectProjectOption = useCallback(
(id: string) => {
const project = projectByOptionId.get(id);
if (!project?.canCreateWorktree) return;
setManualProjectKey(project.projectKey);
},
[projectByOptionId],
);
return {
projects,
selectedProject,
selectedSourceDirectory: selectedProject?.iconWorkingDir ?? null,
selectedDisplayName: selectedProject?.projectName ?? displayName,
projectPickerOptions,
projectByOptionId,
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "",
projectTriggerLabel: selectedProject?.projectName ?? "Choose project",
handleSelectProjectOption,
};
}
function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
if (input.isCompact) {
return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }];
}
return [styles.content, styles.contentCentered];
}
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
if (!selection) return null;
return selection.item;
}
function normalizeBranchDetails( function normalizeBranchDetails(
data: data:
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] } | { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
@@ -378,32 +639,31 @@ function buildComposerConfig(input: {
serverId: string; serverId: string;
isConnected: boolean; isConnected: boolean;
workspaceDirectory: string | null; workspaceDirectory: string | null;
sourceDirectory: string; sourceDirectory: string | null;
}): Parameters<typeof useAgentInputDraft>[0]["composer"] { }): Parameters<typeof useAgentInputDraft>[0]["composer"] {
const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input; const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input;
const workingDir = workspaceDirectory || sourceDirectory || undefined;
return { return {
initialServerId: serverId || null, initialServerId: serverId || null,
initialValues: initialValues: workingDir ? { workingDir } : undefined,
workspaceDirectory || sourceDirectory
? { workingDir: workspaceDirectory || sourceDirectory }
: undefined,
isVisible: true, isVisible: true,
onlineServerIds: isConnected && serverId ? [serverId] : [], onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined, lockedWorkingDir: workingDir,
}; };
} }
function computeWorkspaceTitle( function computeWorkspaceTitle(
workspace: ReturnType<typeof normalizeWorkspaceDescriptor> | null, workspace: ReturnType<typeof normalizeWorkspaceDescriptor> | null,
displayName: string, displayName: string,
sourceDirectory: string, sourceDirectory: string | null,
): string { ): string {
const fallbackDirectoryName = sourceDirectory?.split(/[\\/]/).findLast(Boolean) ?? null;
return ( return (
workspace?.name || workspace?.name ||
workspace?.projectDisplayName || workspace?.projectDisplayName ||
displayName || displayName ||
sourceDirectory.split(/[\\/]/).findLast(Boolean) || fallbackDirectoryName ||
sourceDirectory "Choose project"
); );
} }
@@ -503,7 +763,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
export function NewWorkspaceScreen({ export function NewWorkspaceScreen({
serverId, serverId,
sourceDirectory, sourceDirectory: sourceDirectoryProp,
projectId, projectId,
displayName: displayNameProp, displayName: displayNameProp,
}: NewWorkspaceScreenProps) { }: NewWorkspaceScreenProps) {
@@ -519,9 +779,11 @@ export function NewWorkspaceScreen({
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null); const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null);
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null); const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const [pickerSearchQuery, setPickerSearchQuery] = useState(""); const [pickerSearchQuery, setPickerSearchQuery] = useState("");
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState(""); const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
const pickerAnchorRef = useRef<View>(null); const pickerAnchorRef = useRef<View>(null);
const projectPickerAnchorRef = useRef<View>(null);
useEffect(() => { useEffect(() => {
const trimmed = pickerSearchQuery.trim(); const trimmed = pickerSearchQuery.trim();
@@ -529,26 +791,42 @@ export function NewWorkspaceScreen({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [pickerSearchQuery]); }, [pickerSearchQuery]);
const displayName = displayNameProp?.trim() ?? "";
const workspace = createdWorkspace; const workspace = createdWorkspace;
const isPending = pendingAction !== null; const isPending = pendingAction !== null;
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const draftKey = `new-workspace:${serverId}:${sourceDirectory}`; const {
projects,
selectedProject,
selectedSourceDirectory,
selectedDisplayName,
projectPickerOptions,
projectByOptionId,
selectedProjectOptionId,
projectTriggerLabel,
handleSelectProjectOption: selectProjectOption,
} = useNewWorkspaceProjectPicker({
serverId,
sourceDirectory: sourceDirectoryProp,
projectId,
displayName: displayNameProp,
});
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ serverId, projects });
const draftKey = `new-workspace:${serverId}:${selectedSourceDirectory ?? "choose-project"}`;
const chatDraft = useAgentInputDraft({ const chatDraft = useAgentInputDraft({
draftKey, draftKey,
composer: buildComposerConfig({ composer: buildComposerConfig({
serverId, serverId,
isConnected, isConnected,
workspaceDirectory: workspace?.workspaceDirectory ?? null, workspaceDirectory: workspace?.workspaceDirectory ?? null,
sourceDirectory, sourceDirectory: selectedSourceDirectory,
}), }),
}); });
const composerState = chatDraft.composerState; const composerState = chatDraft.composerState;
const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] = const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] =
useCheckoutHintDismissals(chatDraft.attachments); useCheckoutHintDismissals(chatDraft.attachments);
const selectedItem = manualPickerSelection?.item ?? null; const selectedItem = getSelectedPickerItem(manualPickerSelection);
const withConnectedClient = useCallback(() => { const withConnectedClient = useCallback(() => {
if (!client || !isConnected) { if (!client || !isConnected) {
@@ -558,15 +836,19 @@ export function NewWorkspaceScreen({
}, [client, isConnected]); }, [client, isConnected]);
const clientReady = isConnected && Boolean(client); const clientReady = isConnected && Boolean(client);
const pickerQueryEnabled = pickerOpen && clientReady; const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
const pickerQueryEnabled = pickerOpen && clientReady && hasSelectedSourceDirectory;
const checkoutStatusQuery = useQuery({ const checkoutStatusQuery = useQuery({
queryKey: ["checkout-status", serverId, sourceDirectory], queryKey: ["checkout-status", serverId, selectedSourceDirectory],
queryFn: async () => { queryFn: async () => {
if (!selectedSourceDirectory) {
throw new Error("Choose a project");
}
const connectedClient = withConnectedClient(); const connectedClient = withConnectedClient();
return connectedClient.getCheckoutStatus(sourceDirectory); return connectedClient.getCheckoutStatus(selectedSourceDirectory);
}, },
enabled: clientReady, enabled: clientReady && hasSelectedSourceDirectory,
staleTime: Infinity, staleTime: Infinity,
refetchOnMount: false, refetchOnMount: false,
refetchOnReconnect: false, refetchOnReconnect: false,
@@ -576,11 +858,14 @@ export function NewWorkspaceScreen({
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null; const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
const branchSuggestionsQuery = useQuery({ const branchSuggestionsQuery = useQuery({
queryKey: ["branch-suggestions", serverId, sourceDirectory, debouncedPickerSearchQuery], queryKey: ["branch-suggestions", serverId, selectedSourceDirectory, debouncedPickerSearchQuery],
queryFn: async () => { queryFn: async () => {
if (!selectedSourceDirectory) {
throw new Error("Choose a project");
}
const connectedClient = withConnectedClient(); const connectedClient = withConnectedClient();
return connectedClient.getBranchSuggestions({ return connectedClient.getBranchSuggestions({
cwd: sourceDirectory, cwd: selectedSourceDirectory,
query: debouncedPickerSearchQuery || undefined, query: debouncedPickerSearchQuery || undefined,
limit: 20, limit: 20,
}); });
@@ -592,7 +877,7 @@ export function NewWorkspaceScreen({
const githubPrSearchQuery = useGithubSearchQuery({ const githubPrSearchQuery = useGithubSearchQuery({
client, client,
serverId, serverId,
cwd: sourceDirectory, cwd: selectedSourceDirectory ?? "",
query: debouncedPickerSearchQuery, query: debouncedPickerSearchQuery,
kinds: ["github-pr"], kinds: ["github-pr"],
enabled: pickerQueryEnabled, enabled: pickerQueryEnabled,
@@ -612,7 +897,6 @@ export function NewWorkspaceScreen({
() => computePickerOptionData(branchDetails, prItems), () => computePickerOptionData(branchDetails, prItems),
[branchDetails, prItems], [branchDetails, prItems],
); );
const triggerLabel = useMemo(() => { const triggerLabel = useMemo(() => {
if (selectedItem) return pickerItemTriggerLabel(selectedItem); if (selectedItem) return pickerItemTriggerLabel(selectedItem);
return currentBranch ?? "main"; return currentBranch ?? "main";
@@ -624,7 +908,6 @@ export function NewWorkspaceScreen({
? branchOptionId(selectedItem.name) ? branchOptionId(selectedItem.name)
: prOptionId(selectedItem.item.number); : prOptionId(selectedItem.item.number);
}, [selectedItem]); }, [selectedItem]);
const selectPickerItem = useCallback( const selectPickerItem = useCallback(
(item: PickerItem) => { (item: PickerItem) => {
const next = syncPickerPrAttachment({ const next = syncPickerPrAttachment({
@@ -654,6 +937,17 @@ export function NewWorkspaceScreen({
[itemById, selectPickerItem], [itemById, selectPickerItem],
); );
const handleSelectProjectOption = useCallback(
(id: string) => {
const project = projectByOptionId.get(id);
if (!project?.canCreateWorktree) return;
selectProjectOption(id);
setProjectPickerOpen(false);
setManualPickerSelection(null);
},
[projectByOptionId, selectProjectOption],
);
const checkoutHintPrAttachment = useMemo( const checkoutHintPrAttachment = useMemo(
() => () =>
findCheckoutHintPrAttachment({ findCheckoutHintPrAttachment({
@@ -684,6 +978,10 @@ export function NewWorkspaceScreen({
setPickerOpen(true); setPickerOpen(true);
}, []); }, []);
const openProjectPicker = useCallback(() => {
setProjectPickerOpen(true);
}, []);
const handleClearDraft = useCallback(() => { const handleClearDraft = useCallback(() => {
// No-op: screen navigates away on success, text should stay for retry on error // No-op: screen navigates away on success, text should stay for retry on error
}, []); }, []);
@@ -705,19 +1003,26 @@ export function NewWorkspaceScreen({
} }
}, []); }, []);
const handleProjectPickerOpenChange = useCallback((nextOpen: boolean) => {
setProjectPickerOpen(nextOpen);
}, []);
const buildCreateWorktreeInput = useCallback( const buildCreateWorktreeInput = useCallback(
(input: { (input: {
cwd: string; cwd: string;
prompt: string; prompt: string;
attachments: AgentAttachment[]; attachments: AgentAttachment[];
}): CreatePaseoWorktreeInput => { }): CreatePaseoWorktreeInput => {
if (!selectedProject) {
throw new Error("Choose a project");
}
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch); const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
const trimmedPrompt = input.prompt.trim(); const trimmedPrompt = input.prompt.trim();
const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0; const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0;
return { return {
cwd: input.cwd, cwd: selectedProject.iconWorkingDir,
...(projectId ? { projectId } : {}), projectId: selectedProject.projectKey,
worktreeSlug: createNameId(), worktreeSlug: createNameId(),
...(hasFirstAgentContext ...(hasFirstAgentContext
? { ? {
@@ -730,7 +1035,7 @@ export function NewWorkspaceScreen({
...checkoutRequest, ...checkoutRequest,
}; };
}, },
[currentBranch, projectId, selectedItem], [currentBranch, selectedItem, selectedProject],
); );
const ensureWorkspace = useCallback( const ensureWorkspace = useCallback(
@@ -783,7 +1088,11 @@ export function NewWorkspaceScreen({
[composerState, draftKey, ensureWorkspace, serverId, toast], [composerState, draftKey, ensureWorkspace, serverId, toast],
); );
const workspaceTitle = computeWorkspaceTitle(workspace, displayName, sourceDirectory); const workspaceTitle = computeWorkspaceTitle(
workspace,
selectedDisplayName,
selectedSourceDirectory,
);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
@@ -835,12 +1144,40 @@ export function NewWorkspaceScreen({
[isPending, itemById, theme.colors.foregroundMuted, theme.iconSize.sm], [isPending, itemById, theme.colors.foregroundMuted, theme.iconSize.sm],
); );
const renderProjectOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOptionType;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const project = projectByOptionId.get(option.id);
if (!project) return <View key={option.id} />;
return (
<ProjectOptionItem
testID={`new-workspace-project-picker-option-${project.projectKey}`}
projectKey={project.projectKey}
iconDataUri={projectIconDataByProjectKey.get(project.projectKey) ?? null}
label={project.projectName}
description={project.iconWorkingDir}
selected={selected}
active={active}
disabled={isPending || !project.canCreateWorktree}
onPress={onPress}
/>
);
},
[isPending, projectByOptionId, projectIconDataByProjectKey],
);
const contentStyle = useMemo( const contentStyle = useMemo(
() => [ () => getContentStyle({ isCompact, insetBottom: insets.bottom }),
styles.content,
isCompact ? styles.contentCompact : styles.contentCentered,
isCompact ? { paddingBottom: insets.bottom } : null,
],
[isCompact, insets.bottom], [isCompact, insets.bottom],
); );
@@ -863,11 +1200,42 @@ export function NewWorkspaceScreen({
const composerFooter = useMemo( const composerFooter = useMemo(
() => ( () => (
<View testID="new-workspace-ref-picker-row" style={styles.optionsRow}> <View testID="new-workspace-ref-picker-row" style={styles.optionsRow}>
<View>
<ProjectPickerTrigger
pickerAnchorRef={projectPickerAnchorRef}
onPress={openProjectPicker}
disabled={isPending || projectPickerOptions.length === 0}
badgePressableStyle={badgePressableStyle}
label={projectTriggerLabel}
projectKey={selectedProject?.projectKey ?? null}
iconDataUri={
selectedProject
? (projectIconDataByProjectKey.get(selectedProject.projectKey) ?? null)
: null
}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={projectPickerOptions}
value={selectedProjectOptionId}
onSelect={handleSelectProjectOption}
searchable
searchPlaceholder="Search projects"
title="Project"
open={projectPickerOpen}
onOpenChange={handleProjectPickerOpenChange}
desktopPlacement="bottom-start"
anchorRef={projectPickerAnchorRef}
emptyText="No projects available."
renderOption={renderProjectOption}
/>
</View>
<View> <View>
<RefPickerTrigger <RefPickerTrigger
pickerAnchorRef={pickerAnchorRef} pickerAnchorRef={pickerAnchorRef}
onPress={openPicker} onPress={openPicker}
disabled={isPending} disabled={isPending || !selectedSourceDirectory}
badgePressableStyle={badgePressableStyle} badgePressableStyle={badgePressableStyle}
selectedItem={selectedItem} selectedItem={selectedItem}
triggerLabel={triggerLabel} triggerLabel={triggerLabel}
@@ -910,15 +1278,26 @@ export function NewWorkspaceScreen({
checkoutHintPrAttachment, checkoutHintPrAttachment,
dismissCheckoutHint, dismissCheckoutHint,
handlePickerOpenChange, handlePickerOpenChange,
handleProjectPickerOpenChange,
handleSelectOption, handleSelectOption,
handleSelectProjectOption,
isPending, isPending,
openPicker, openPicker,
openProjectPicker,
options, options,
pickerEmptyText, pickerEmptyText,
pickerOpen, pickerOpen,
projectPickerOpen,
projectPickerOptions,
projectTriggerLabel,
projectIconDataByProjectKey,
renderPickerOption, renderPickerOption,
renderProjectOption,
selectedItem, selectedItem,
selectedOptionId, selectedOptionId,
selectedProject,
selectedProjectOptionId,
selectedSourceDirectory,
setPickerSearchQuery, setPickerSearchQuery,
agentControlsWithDisabled, agentControlsWithDisabled,
theme.colors.foregroundMuted, theme.colors.foregroundMuted,
@@ -951,7 +1330,7 @@ export function NewWorkspaceScreen({
<TitlebarDragRegion /> <TitlebarDragRegion />
<View style={styles.centered}> <View style={styles.centered}>
<Composer <Composer
agentId={`new-workspace:${serverId}:${sourceDirectory}`} agentId={draftKey}
serverId={serverId} serverId={serverId}
isPaneFocused={true} isPaneFocused={true}
onSubmitMessage={handleSubmitNewWorkspace} onSubmitMessage={handleSubmitNewWorkspace}
@@ -965,7 +1344,7 @@ export function NewWorkspaceScreen({
onChangeText={chatDraft.setText} onChangeText={chatDraft.setText}
attachments={chatDraft.attachments} attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments} onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory} cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft} clearDraft={handleClearDraft}
autoFocus autoFocus
commandDraftConfig={composerState?.commandDraftConfig} commandDraftConfig={composerState?.commandDraftConfig}
@@ -1089,10 +1468,42 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center", justifyContent: "center",
flexShrink: 0, flexShrink: 0,
}, },
badgeProjectIcon: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
},
badgeProjectIconFallback: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
},
badgeProjectIconFallbackText: {
fontSize: 10,
fontWeight: "600",
},
rowIconBox: { rowIconBox: {
width: theme.iconSize.md, width: theme.iconSize.md,
height: theme.iconSize.md, height: theme.iconSize.md,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
projectOptionIcon: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
},
projectOptionIconFallback: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
},
projectOptionIconFallbackText: {
fontSize: 10,
fontWeight: "600",
},
})); }));
@@ -5,7 +5,6 @@ import { StyleSheet, useUnistyles } 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, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view"; import { ProjectIconView } from "@/components/project-icon-view";
import { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query";
import type { import type {
PaseoConfigRaw, PaseoConfigRaw,
PaseoConfigRevision, PaseoConfigRevision,
@@ -29,6 +28,7 @@ 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 { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
@@ -219,6 +219,21 @@ function ProjectSettingsBody({
}); });
const data = readQuery.data; const data = readQuery.data;
const projectIconTargets = useMemo(
() => [
{
serverId: selectedHost.serverId,
projectKey: project.projectKey,
iconWorkingDir: selectedHost.repoRoot,
},
],
[project.projectKey, selectedHost.repoRoot, selectedHost.serverId],
);
const projectIconDataByKey = useProjectIconDataByProjectKey({
serverId: null,
projects: projectIconTargets,
});
const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? 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;
@@ -236,7 +251,7 @@ function ProjectSettingsBody({
<View style={styles.headerBlock}> <View style={styles.headerBlock}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<ProjectTitleIcon <ProjectTitleIcon
host={selectedHost} iconDataUri={projectIconDataUri}
projectName={project.projectName} projectName={project.projectName}
projectKey={project.projectKey} projectKey={project.projectKey}
/> />
@@ -886,19 +901,18 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
} }
function ProjectTitleIcon({ function ProjectTitleIcon({
host, iconDataUri,
projectName, projectName,
projectKey, projectKey,
}: { }: {
host: ProjectHostEntry; iconDataUri: string | null;
projectName: string; projectName: string;
projectKey: string; projectKey: string;
}) { }) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?"; const initial = projectName.trim().charAt(0).toUpperCase() || "?";
const { icon } = useProjectIconQuery({ serverId: host.serverId, cwd: host.repoRoot });
return ( return (
<ProjectIconView <ProjectIconView
iconDataUri={projectIconToDataUri(icon)} iconDataUri={iconDataUri}
initial={initial} initial={initial}
projectKey={projectKey} projectKey={projectKey}
imageStyle={styles.titleIcon} imageStyle={styles.titleIcon}
@@ -188,9 +188,8 @@ vi.mock("@/hooks/use-projects", () => ({
useProjects: () => projectsState.current, useProjects: () => projectsState.current,
})); }));
vi.mock("@/hooks/use-project-icon-query", () => ({ vi.mock("@/projects/project-icons", () => ({
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }), useProjectIconDataByProjectKey: () => new Map(),
projectIconToDataUri: () => null,
})); }));
import ProjectsScreen from "./projects-screen"; import ProjectsScreen from "./projects-screen";
+34 -14
View File
@@ -1,15 +1,15 @@
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native"; import { ChevronRight } from "lucide-react-native";
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 { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes"; import { buildProjectSettingsRoute } from "@/utils/host-routes";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects"; import type { ProjectSummary } from "@/utils/projects";
interface ProjectsScreenProps { interface ProjectsScreenProps {
view: { kind: "projects" } | { kind: "project"; projectKey: string }; view: { kind: "projects" } | { kind: "project"; projectKey: string };
@@ -18,6 +18,25 @@ interface ProjectsScreenProps {
export default function ProjectsScreen({ view }: ProjectsScreenProps) { export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { projects, hostErrors, isLoading } = useProjects(); const { projects, hostErrors, isLoading } = useProjects();
const selectedProjectKey = view.kind === "project" ? view.projectKey : null; const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
const iconTargets = useMemo(
() =>
projects.flatMap((project) => {
const host = project.hosts[0];
if (!host) return [];
return [
{
serverId: host.serverId,
projectKey: project.projectKey,
iconWorkingDir: host.repoRoot,
},
];
}),
[projects],
);
const iconDataByProjectKey = useProjectIconDataByProjectKey({
serverId: null,
projects: iconTargets,
});
if (isLoading && projects.length === 0) { if (isLoading && projects.length === 0) {
return ( return (
@@ -45,6 +64,7 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
project={project} project={project}
isFirst={index === 0} isFirst={index === 0}
isSelected={selectedProjectKey === project.projectKey} isSelected={selectedProjectKey === project.projectKey}
iconDataUri={iconDataByProjectKey.get(project.projectKey) ?? null}
/> />
))} ))}
</View> </View>
@@ -68,12 +88,12 @@ interface ProjectRowProps {
project: ProjectSummary; project: ProjectSummary;
isFirst: boolean; isFirst: boolean;
isSelected: boolean; isSelected: boolean;
iconDataUri: string | null;
} }
function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) { function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { hosts, projectKey, projectName } = project; const { projectKey, projectName } = project;
const leadingHost = hosts[0];
const handleNavigate = useCallback(() => { const handleNavigate = useCallback(() => {
router.navigate(buildProjectSettingsRoute(projectKey)); router.navigate(buildProjectSettingsRoute(projectKey));
@@ -102,7 +122,11 @@ function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
> >
<View style={styles.rowMain}> <View style={styles.rowMain}>
<View style={styles.leading}> <View style={styles.leading}>
<ProjectRowIcon host={leadingHost} projectName={projectName} projectKey={projectKey} /> <ProjectRowIcon
iconDataUri={iconDataUri}
projectName={projectName}
projectKey={projectKey}
/>
</View> </View>
<Text style={settingsStyles.rowTitle} numberOfLines={1}> <Text style={settingsStyles.rowTitle} numberOfLines={1}>
{projectName} {projectName}
@@ -114,22 +138,18 @@ function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
} }
function ProjectRowIcon({ function ProjectRowIcon({
host, iconDataUri,
projectName, projectName,
projectKey, projectKey,
}: { }: {
host: ProjectHostEntry | undefined; iconDataUri: string | null;
projectName: string; projectName: string;
projectKey: string; projectKey: string;
}) { }) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?"; const initial = projectName.trim().charAt(0).toUpperCase() || "?";
const { icon } = useProjectIconQuery({
serverId: host?.serverId ?? "",
cwd: host?.repoRoot ?? "",
});
return ( return (
<ProjectIconView <ProjectIconView
iconDataUri={projectIconToDataUri(icon)} iconDataUri={iconDataUri}
initial={initial} initial={initial}
projectKey={projectKey} projectKey={projectKey}
imageStyle={styles.iconImage} imageStyle={styles.iconImage}
@@ -1,5 +1,9 @@
import equal from "fast-deep-equal"; import equal from "fast-deep-equal";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; import {
buildWorkspaceStructureProjects,
type WorkspaceStructure,
type WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import { import {
getWorkspaceExecutionAuthority, getWorkspaceExecutionAuthority,
@@ -10,18 +14,7 @@ import {
import type { WorkspaceDescriptor } from "../session-store"; import type { WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
export interface WorkspaceStructureProject {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
workspaceKeys: string[];
}
export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
export interface SessionsSnapshot { export interface SessionsSnapshot {
sessions: Record<string, { workspaces: Map<string, WorkspaceDescriptor> }>; sessions: Record<string, { workspaces: Map<string, WorkspaceDescriptor> }>;
@@ -44,33 +37,6 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
return `${serverId.trim()}::${projectKey.trim()}`; return `${serverId.trim()}::${projectKey.trim()}`;
} }
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
): number {
const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, {
numeric: true,
sensitivity: "base",
});
if (nameDelta !== 0) {
return nameDelta;
}
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
sensitivity: "base",
});
}
function compareWorkspaceStructureProjects(
left: WorkspaceStructureProject,
right: WorkspaceStructureProject,
): number {
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
});
}
function applyStoredOrdering<T>(input: { function applyStoredOrdering<T>(input: {
items: T[]; items: T[];
storedOrder: readonly string[]; storedOrder: readonly string[];
@@ -171,48 +137,7 @@ export function selectWorkspaceStructureProjects(
return EMPTY_WORKSPACE_STRUCTURE.projects; return EMPTY_WORKSPACE_STRUCTURE.projects;
} }
const byProject = new Map< return buildWorkspaceStructureProjects({ serverId, workspaces: workspaces.values() });
string,
WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
>();
for (const workspace of workspaces.values()) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
workspaceKeys: [],
workspaces: [],
} satisfies WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
});
project.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${serverId}:${workspace.id}`,
});
byProject.set(workspace.projectId, project);
}
const projects = Array.from(byProject.values()).map(
({ workspaces: projectWorkspaces, ...project }) => {
const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems);
return Object.assign({}, project, {
workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId),
});
},
);
projects.sort(compareWorkspaceStructureProjects);
return projects;
} }
export function selectProjectOrder(state: SidebarOrderSnapshot, serverId: string | null): string[] { export function selectProjectOrder(state: SidebarOrderSnapshot, serverId: string | null): string[] {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildHostAgentDetailRoute, buildHostAgentDetailRoute,
buildHostNewWorkspaceRoute,
buildHostRootRoute, buildHostRootRoute,
buildHostWorkspaceOpenRoute, buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute, buildHostWorkspaceRoute,
@@ -120,6 +121,19 @@ describe("workspace route parsing", () => {
); );
}); });
it("builds a global new workspace route without a source directory", () => {
expect(buildHostNewWorkspaceRoute("local")).toBe("/h/local/new");
});
it("builds a project shortcut new workspace route with initial project context", () => {
expect(
buildHostNewWorkspaceRoute("local", "/repo/project", {
displayName: "Project",
projectId: "project-1",
}),
).toBe("/h/local/new?dir=%2Frepo%2Fproject&name=Project&projectId=project-1");
});
it("round-trips URL-safe IDs through encode/decode", () => { it("round-trips URL-safe IDs through encode/decode", () => {
const ids = ["1", "40", "164", "9999", "workspace-1", "opaque_id.v2~test"]; const ids = ["1", "40", "164", "9999", "workspace-1", "opaque_id.v2~test"];
for (const id of ids) { for (const id of ids) {
+9 -3
View File
@@ -357,7 +357,7 @@ export function buildHostOpenProjectRoute(serverId: string) {
export function buildHostNewWorkspaceRoute( export function buildHostNewWorkspaceRoute(
serverId: string, serverId: string,
sourceDirectory: string, sourceDirectory?: string,
options?: { displayName?: string; projectId?: string }, options?: { displayName?: string; projectId?: string },
) { ) {
const base = buildHostRootRoute(serverId); const base = buildHostRootRoute(serverId);
@@ -365,14 +365,20 @@ export function buildHostNewWorkspaceRoute(
return "/" as const; return "/" as const;
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("dir", sourceDirectory); if (sourceDirectory) {
params.set("dir", sourceDirectory);
}
if (options?.displayName) { if (options?.displayName) {
params.set("name", options.displayName); params.set("name", options.displayName);
} }
if (options?.projectId) { if (options?.projectId) {
params.set("projectId", options.projectId); params.set("projectId", options.projectId);
} }
return `${base}/new?${params.toString()}` as const; const query = params.toString();
if (!query) {
return `${base}/new` as const;
}
return `${base}/new?${query}` as const;
} }
export const SETTINGS_SECTION_SLUGS = [ export const SETTINGS_SECTION_SLUGS = [
+47 -16
View File
@@ -1,4 +1,6 @@
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
export interface WorkspaceSummary { export interface WorkspaceSummary {
id: string; id: string;
@@ -61,6 +63,31 @@ interface ProjectGroup {
hostsByServerId: Map<string, HostGroup>; hostsByServerId: Map<string, HostGroup>;
} }
function findProjectCustomName(
workspaces: WorkspaceDescriptor[],
projectKey: string,
): { customName: string; displayName: string } | null {
for (const workspace of workspaces) {
if (workspace.projectId === projectKey && workspace.projectCustomName) {
return {
customName: workspace.projectCustomName,
displayName: workspace.projectDisplayName,
};
}
}
return null;
}
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
return buildHostProjectList({
serverId: host.serverId,
projects: buildWorkspaceStructureProjects({
serverId: host.serverId,
workspaces: host.workspaces,
}),
});
}
function deriveGithubUrl(projectKey: string): string | undefined { function deriveGithubUrl(projectKey: string): string | undefined {
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN); const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
if (!match) { if (!match) {
@@ -134,33 +161,37 @@ 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) {
for (const workspace of host.workspaces) { const hostProjects = buildHostProjectEntries(host);
const projectKey = workspace.projectId; for (const hostProject of hostProjects) {
const customName = findProjectCustomName(host.workspaces, hostProject.projectKey);
let group = groups.get(projectKey); let group = groups.get(hostProject.projectKey);
if (!group) { if (!group) {
group = { group = {
projectKey, projectKey: hostProject.projectKey,
projectName: workspace.projectDisplayName, projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: workspace.projectCustomName ?? null, projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(), hostsByServerId: new Map(),
}; };
groups.set(projectKey, group); groups.set(hostProject.projectKey, group);
} else if (workspace.projectCustomName && !group.projectCustomName) { } else if (customName && !group.projectCustomName) {
group.projectCustomName = workspace.projectCustomName; group.projectCustomName = customName.customName;
group.projectName = workspace.projectDisplayName; group.projectName = customName.displayName;
} }
let hostGroup = group.hostsByServerId.get(host.serverId); if (!group.hostsByServerId.has(host.serverId)) {
if (!hostGroup) { group.hostsByServerId.set(host.serverId, {
hostGroup = {
serverId: host.serverId, serverId: host.serverId,
serverName: host.serverName, serverName: host.serverName,
isOnline: host.isOnline, isOnline: host.isOnline,
workspaces: [], workspaces: [],
}; });
group.hostsByServerId.set(host.serverId, hostGroup);
} }
}
for (const workspace of host.workspaces) {
const group = groups.get(workspace.projectId);
const hostGroup = group?.hostsByServerId.get(host.serverId);
if (!hostGroup) continue;
hostGroup.workspaces.push(workspace); hostGroup.workspaces.push(workspace);
} }
} }
@@ -32,11 +32,13 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
} }
function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry { function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry {
const projectKind = overrides.projectKind ?? "git";
return { return {
projectKey: "project-1", projectKey: "project-1",
projectName: "paseo", projectName: "paseo",
projectKind: "git", projectKind,
iconWorkingDir: "/repo", iconWorkingDir: "/repo",
canCreateWorktree: overrides.canCreateWorktree ?? projectKind === "git",
workspaces: [workspace()], workspaces: [workspace()],
...overrides, ...overrides,
}; };
@@ -37,7 +37,7 @@ export function buildSidebarProjectRowModel(input: {
kind: "workspace_link", kind: "workspace_link",
workspace: flattenedWorkspace, workspace: flattenedWorkspace,
chevron: null, chevron: null,
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none", trailingAction: input.project.canCreateWorktree ? "new_worktree" : "none",
}; };
} }
@@ -51,6 +51,6 @@ export function buildSidebarProjectRowModel(input: {
return { return {
kind: "project_section", kind: "project_section",
chevron, chevron,
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none", trailingAction: input.project.canCreateWorktree ? "new_worktree" : "none",
}; };
} }
@@ -46,6 +46,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
projectName: projectKey, projectName: projectKey,
projectKind: "git", projectKind: "git",
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
canCreateWorktree: true,
workspaces, workspaces,
}; };
} }