Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"] });
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user