feat: project settings page with paseo.json editor
Adds a Projects settings flow for inspecting and editing per-project paseo.json (worktree setup/teardown, scripts, terminals) directly from the app. Reads/writes go through new daemon RPCs with revision-based optimistic concurrency, and the screen always keeps its header and host picker visible so a hung host doesn't trap the user.
This commit is contained in:
@@ -830,6 +830,8 @@ function RootStack() {
|
||||
<Stack.Screen name="welcome" />
|
||||
<Stack.Screen name="settings/index" />
|
||||
<Stack.Screen name="settings/[section]" />
|
||||
<Stack.Screen name="settings/projects/index" />
|
||||
<Stack.Screen name="settings/projects/[projectKey]" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
{/*
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default function SettingsProjectDetailRoute() {
|
||||
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
|
||||
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
|
||||
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
|
||||
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
|
||||
|
||||
return <SettingsScreen view={view} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
const PROJECTS_VIEW = { kind: "projects" as const };
|
||||
|
||||
export default function SettingsProjectsIndexRoute() {
|
||||
return <SettingsScreen view={PROJECTS_VIEW} />;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Settings,
|
||||
SquareTerminal,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
@@ -53,6 +54,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||
import {
|
||||
buildHostNewWorkspaceRoute,
|
||||
buildProjectSettingsRoute,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
@@ -558,6 +560,10 @@ function ProjectKebabMenu({
|
||||
() => <Trash2 size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const settingsLeadingIcon = useMemo(
|
||||
() => <Settings size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const renderTriggerIcon = useCallback(
|
||||
({ hovered }: { hovered?: boolean }) => (
|
||||
<MoreVertical
|
||||
@@ -567,6 +573,11 @@ function ProjectKebabMenu({
|
||||
),
|
||||
[theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
);
|
||||
const handleOpenProjectSettings = useCallback(() => {
|
||||
if (projectKey.trim().length === 0) return;
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
const canOpenProjectSettings = projectKey.trim().length > 0;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -579,6 +590,15 @@ function ProjectKebabMenu({
|
||||
{renderTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
{canOpenProjectSettings ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-open-settings-${projectKey}`}
|
||||
leading={settingsLeadingIcon}
|
||||
onSelect={handleOpenProjectSettings}
|
||||
>
|
||||
Open project settings
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||
leading={removeProjectLeadingIcon}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import type {
|
||||
PressableProps,
|
||||
PressableStateCallbackType,
|
||||
@@ -104,6 +104,7 @@ export function Button({
|
||||
style,
|
||||
textStyle,
|
||||
disabled,
|
||||
loading = false,
|
||||
accessibilityRole,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
@@ -113,10 +114,12 @@ export function Button({
|
||||
leftIcon?: LeftIcon;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
loading?: boolean;
|
||||
}
|
||||
>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { theme } = useUnistyles();
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
let variantStyle: ViewStyle;
|
||||
if (variant === "default") {
|
||||
@@ -150,10 +153,10 @@ export function Button({
|
||||
sizeStyle,
|
||||
variantStyle,
|
||||
pressed ? styles.pressed : null,
|
||||
disabled ? styles.disabled : null,
|
||||
isDisabled ? styles.disabled : null,
|
||||
style,
|
||||
],
|
||||
[sizeStyle, variantStyle, disabled, style],
|
||||
[sizeStyle, variantStyle, isDisabled, style],
|
||||
);
|
||||
|
||||
const resolvedTextStyle = useMemo(
|
||||
@@ -168,7 +171,30 @@ export function Button({
|
||||
[variant, textStyle, isGhostHovered],
|
||||
);
|
||||
|
||||
const accessibilityState = useMemo(
|
||||
() => ({ disabled: isDisabled, busy: loading }),
|
||||
[isDisabled, loading],
|
||||
);
|
||||
|
||||
function resolveIconColor(): string {
|
||||
if (variant === "default") {
|
||||
return theme.colors.accentForeground;
|
||||
}
|
||||
if (variant === "ghost") {
|
||||
return isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
}
|
||||
return theme.colors.foreground;
|
||||
}
|
||||
|
||||
function renderIcon() {
|
||||
if (loading) {
|
||||
return (
|
||||
<View>
|
||||
<ActivityIndicator size="small" color={resolveIconColor()} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!leftIcon) return null;
|
||||
|
||||
// Pre-rendered element — pass through
|
||||
@@ -176,14 +202,7 @@ export function Button({
|
||||
return <View>{leftIcon}</View>;
|
||||
}
|
||||
|
||||
let color: string;
|
||||
if (variant === "default") {
|
||||
color = theme.colors.accentForeground;
|
||||
} else if (variant === "ghost") {
|
||||
color = isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
} else {
|
||||
color = theme.colors.foreground;
|
||||
}
|
||||
const color = resolveIconColor();
|
||||
const iconSize = ICON_SIZE[size];
|
||||
|
||||
// Render function
|
||||
@@ -208,7 +227,8 @@ export function Button({
|
||||
<Pressable
|
||||
{...props}
|
||||
accessibilityRole={accessibilityRole ?? "button"}
|
||||
disabled={disabled}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={isDisabled}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={pressableStyle}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
import { useProjects } from "./use-projects";
|
||||
|
||||
interface MockDaemonClient {
|
||||
fetchWorkspaces: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const { hostState, clientsByServerId, runtimeStore } = vi.hoisted(() => {
|
||||
const state = {
|
||||
hosts: [
|
||||
{ serverId: "local", label: "Local" },
|
||||
{ serverId: "laptop", label: "Laptop" },
|
||||
],
|
||||
snapshots: new Map<string, { connectionStatus: string }>(),
|
||||
};
|
||||
const clients = new Map<string, { fetchWorkspaces: ReturnType<typeof vi.fn> }>();
|
||||
return {
|
||||
hostState: state,
|
||||
clientsByServerId: clients,
|
||||
runtimeStore: {
|
||||
getClient: (serverId: string) => clients.get(serverId) ?? null,
|
||||
getSnapshot: (serverId: string) => state.snapshots.get(serverId) ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => runtimeStore,
|
||||
useHosts: () => hostState.hosts,
|
||||
}));
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderProjectsHook() {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
return renderHook(() => useProjects(), { wrapper });
|
||||
}
|
||||
|
||||
function makeClient(entries: WorkspaceDescriptorPayload[] | Error): MockDaemonClient {
|
||||
return {
|
||||
fetchWorkspaces: vi.fn(async () => {
|
||||
if (entries instanceof Error) {
|
||||
throw entries;
|
||||
}
|
||||
return {
|
||||
requestId: "req-workspaces",
|
||||
entries,
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
}): WorkspaceDescriptorPayload {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectKey,
|
||||
projectDisplayName: input.projectName,
|
||||
projectRootPath: input.cwd,
|
||||
workspaceDirectory: input.cwd,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: input.id,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: {
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
},
|
||||
githubRuntime: null,
|
||||
project: {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName,
|
||||
checkout: {
|
||||
cwd: input.cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
worktreeRoot: input.cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clientsByServerId.clear();
|
||||
hostState.hosts = [
|
||||
{ serverId: "local", label: "Local" },
|
||||
{ serverId: "laptop", label: "Laptop" },
|
||||
];
|
||||
hostState.snapshots = new Map<string, { connectionStatus: string }>();
|
||||
});
|
||||
|
||||
describe("useProjects", () => {
|
||||
it("calls all known daemon clients and returns aggregated projects sorted by display name", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "z-main",
|
||||
projectKey: "remote:github.com/acme/zeta",
|
||||
projectName: "acme/zeta",
|
||||
cwd: "/repo/zeta",
|
||||
remoteUrl: "https://github.com/acme/zeta.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
clientsByServerId.set(
|
||||
"laptop",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "a-main",
|
||||
projectKey: "remote:github.com/acme/alpha",
|
||||
projectName: "acme/alpha",
|
||||
cwd: "/repo/alpha",
|
||||
remoteUrl: "https://github.com/acme/alpha.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(clientsByServerId.get("local")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(clientsByServerId.get("laptop")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.projects.map((project) => project.projectName)).toEqual([
|
||||
"acme/alpha",
|
||||
"acme/zeta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces per-host fetch failures without dropping successful hosts", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
clientsByServerId.set("laptop", makeClient(new Error("laptop unavailable")));
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(result.current.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
expect(result.current.hostErrors).toEqual([
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
message: "laptop unavailable",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips disconnected hosts silently without surfacing them as failures", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "offline" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(result.current.hostErrors).toEqual([]);
|
||||
expect(result.current.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only the stable public project and host entry shapes", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
|
||||
"githubUrl",
|
||||
"hiddenUnsupportedRemoteCount",
|
||||
"hostCount",
|
||||
"hosts",
|
||||
"onlineHostCount",
|
||||
"projectKey",
|
||||
"projectName",
|
||||
"totalWorkspaceCount",
|
||||
]);
|
||||
expect(Object.keys(result.current.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
|
||||
"gitRuntime",
|
||||
"githubRuntime",
|
||||
"isOnline",
|
||||
"repoRoot",
|
||||
"serverId",
|
||||
"serverName",
|
||||
"workspaceCount",
|
||||
"workspaces",
|
||||
]);
|
||||
expect(Object.keys(result.current.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
|
||||
"currentBranch",
|
||||
"id",
|
||||
"name",
|
||||
"status",
|
||||
"workspaceKind",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
||||
|
||||
export const projectsQueryKey = ["projects"] as const;
|
||||
|
||||
export interface ProjectHostError {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UseProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
hostErrors: ProjectHostError[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface FetchAllWorkspaceDescriptorsInput {
|
||||
client: Pick<DaemonClient, "fetchWorkspaces">;
|
||||
}
|
||||
|
||||
interface HostWorkspacesResult {
|
||||
host: ProjectHost;
|
||||
error: ProjectHostError | null;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function fetchAllWorkspaceDescriptors(
|
||||
input: FetchAllWorkspaceDescriptorsInput,
|
||||
): Promise<WorkspaceDescriptor[]> {
|
||||
const entries: WorkspaceDescriptor[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
while (true) {
|
||||
const payload = await input.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 function useProjects(): UseProjectsResult {
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const hostInputs = useMemo(
|
||||
() =>
|
||||
hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
})),
|
||||
[hosts],
|
||||
);
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: projectsQueryKey,
|
||||
queryFn: async () => {
|
||||
const results = await Promise.all(
|
||||
hostInputs.map(async (host): Promise<HostWorkspacesResult> => {
|
||||
const snapshot = runtime.getSnapshot(host.serverId);
|
||||
const isOnline = snapshot?.connectionStatus === "online";
|
||||
const client = 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
projects: projectsQuery.data?.projects ?? [],
|
||||
hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0,
|
||||
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
||||
isLoading: projectsQuery.isLoading,
|
||||
isFetching: projectsQuery.isFetching,
|
||||
refetch: () => {
|
||||
void projectsQuery.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface SidebarWorkspaceEntry {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
projectKey: string;
|
||||
projectRootPath?: string;
|
||||
workspaceDirectory?: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
@@ -57,6 +58,7 @@ function createStructuralWorkspaceEntry(input: {
|
||||
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
projectKey: input.project.projectKey,
|
||||
projectRootPath: input.project.iconWorkingDir,
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: input.project.projectKind,
|
||||
@@ -77,6 +79,7 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
projectKey: input.workspace.project?.projectKey ?? input.workspace.projectId,
|
||||
projectRootPath: input.workspace.projectRootPath,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
projectKind: input.workspace.projectKind,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects";
|
||||
import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects";
|
||||
|
||||
const { theme, projectsState, navigate } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||
iconSize: { sm: 14, md: 20 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400" as const, medium: "500" as const },
|
||||
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
surface0: "#000",
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
surface3: "#333",
|
||||
surfaceSidebarHover: "#1a1a1a",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
border: "#444",
|
||||
accent: "#0a84ff",
|
||||
palette: { red: { 300: "#ff6b6b" } },
|
||||
},
|
||||
},
|
||||
projectsState: {
|
||||
current: {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as UseProjectsResult,
|
||||
},
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-native", () => {
|
||||
const passthrough = ({
|
||||
children,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
accessibilityRole,
|
||||
onPress,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
...rest
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
accessibilityRole?: string;
|
||||
onPress?: (event: { stopPropagation: () => void }) => void;
|
||||
onHoverIn?: () => void;
|
||||
onHoverOut?: () => void;
|
||||
} & Record<string, unknown>) => {
|
||||
const dataAttrs: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (key.startsWith("data-")) {
|
||||
dataAttrs[key] = value;
|
||||
}
|
||||
}
|
||||
return React.createElement(
|
||||
"div",
|
||||
{
|
||||
role: accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
onClick: onPress
|
||||
? (event: React.MouseEvent) => {
|
||||
onPress({ stopPropagation: () => event.stopPropagation() });
|
||||
}
|
||||
: undefined,
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
...dataAttrs,
|
||||
},
|
||||
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
||||
React.createElement("div", { "data-testid": testID }, children),
|
||||
Text: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("span", null, children),
|
||||
Pressable: passthrough,
|
||||
Image: ({ source }: { source?: { uri?: string } }) =>
|
||||
React.createElement("img", { src: source?.uri ?? "" }),
|
||||
Platform: { OS: "web" },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => {
|
||||
const Icon = () => React.createElement("span", { "data-icon": name });
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
ChevronRight: icon("ChevronRight"),
|
||||
MoreVertical: icon("MoreVertical"),
|
||||
ExternalLink: icon("ExternalLink"),
|
||||
Pencil: icon("Pencil"),
|
||||
FolderGit2: icon("FolderGit2"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: { navigate },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: ({ size }: { size?: string | number }) =>
|
||||
React.createElement("span", {
|
||||
"data-testid": "projects-loading-spinner",
|
||||
"data-size": size,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "dropdown-menu" }, children),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
onClick: (event: React.MouseEvent) => event.stopPropagation(),
|
||||
},
|
||||
typeof children === "function"
|
||||
? children({ pressed: false, hovered: false, open: false })
|
||||
: children,
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "dropdown-menu-content" }, children),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
testID,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": testID,
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
},
|
||||
},
|
||||
children,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-projects", () => ({
|
||||
useProjects: () => projectsState.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-project-icon-query", () => ({
|
||||
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
|
||||
}));
|
||||
|
||||
import ProjectsScreen from "./projects-screen";
|
||||
|
||||
function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceSummary {
|
||||
return {
|
||||
id: "ws-1",
|
||||
name: "main",
|
||||
workspaceKind: "directory",
|
||||
status: "done",
|
||||
currentBranch: "main",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
|
||||
return {
|
||||
serverId: "host-a",
|
||||
serverName: "alpha",
|
||||
isOnline: true,
|
||||
repoRoot: "/home/me/proj",
|
||||
workspaceCount: 1,
|
||||
workspaces: [workspaceSummary()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
||||
const hosts = overrides.hosts ?? [hostEntry()];
|
||||
const totalWorkspaceCount =
|
||||
overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
|
||||
const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length;
|
||||
return {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
hosts,
|
||||
totalWorkspaceCount,
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: "https://github.com/acme/app",
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
||||
projectsState.current = {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function findRow(container: HTMLElement, projectKey: string): HTMLElement {
|
||||
const row = container.querySelector<HTMLElement>(`[data-testid="project-row-${projectKey}"]`);
|
||||
if (!row) throw new Error(`Expected row for ${projectKey}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("ProjectsScreen", () => {
|
||||
let container: HTMLElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
setProjectsState({});
|
||||
navigate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
|
||||
act(() => {
|
||||
root?.render(<ProjectsScreen view={view} />);
|
||||
});
|
||||
}
|
||||
|
||||
it("renders a row whose visible content is the project name only", () => {
|
||||
setProjectsState({
|
||||
projects: [
|
||||
project({
|
||||
projectName: "acme/app",
|
||||
hosts: [hostEntry({ serverName: "alpha", workspaceCount: 5 })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const rows = container?.querySelectorAll('[data-testid^="project-row-"]') ?? [];
|
||||
expect(rows.length).toBe(1);
|
||||
expect(container?.textContent).toContain("acme/app");
|
||||
expect(container?.textContent).not.toContain("workspace");
|
||||
expect(container?.textContent).not.toContain("offline");
|
||||
expect(container?.textContent).not.toContain("github.com");
|
||||
});
|
||||
|
||||
it("navigates to the project detail route when the row is pressed", () => {
|
||||
setProjectsState({
|
||||
projects: [project({ projectKey: "remote:github.com/acme/app" })],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const row = findRow(container!, "remote:github.com/acme/app");
|
||||
act(() => {
|
||||
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
|
||||
});
|
||||
|
||||
it("does not render a kebab menu on the row", () => {
|
||||
setProjectsState({
|
||||
projects: [project({ projectKey: "remote:github.com/acme/app" })],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(
|
||||
container?.querySelector('[data-testid="project-row-menu-remote:github.com/acme/app"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a centered loading spinner before the first response", () => {
|
||||
setProjectsState({ isLoading: true, projects: [] });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.querySelector('[data-testid="projects-loading-spinner"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the empty state when there are no projects", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("No projects yet");
|
||||
});
|
||||
|
||||
it("renders the unsupported empty state when only non-GitHub remotes were filtered", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 3 });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("Non-GitHub remote projects aren't supported yet");
|
||||
});
|
||||
|
||||
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
|
||||
const hostErrors: ProjectHostError[] = [
|
||||
{ serverId: "a", serverName: "alpha", message: "timed out" },
|
||||
{ serverId: "b", serverName: "beta", message: "unreachable" },
|
||||
];
|
||||
setProjectsState({
|
||||
projects: [project()],
|
||||
hostErrors,
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const banner = container?.querySelector('[data-testid="projects-host-errors"]');
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner?.textContent).toContain("alpha");
|
||||
expect(banner?.textContent).toContain("beta");
|
||||
expect(container?.querySelector('[data-testid^="project-row-"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("highlights the selected row when the active view targets a project", () => {
|
||||
setProjectsState({
|
||||
projects: [
|
||||
project({ projectKey: "remote:github.com/acme/app" }),
|
||||
project({
|
||||
projectKey: "remote:github.com/acme/other",
|
||||
projectName: "acme/other",
|
||||
githubUrl: "https://github.com/acme/other",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
|
||||
|
||||
const selected = findRow(container!, "remote:github.com/acme/app");
|
||||
const other = findRow(container!, "remote:github.com/acme/other");
|
||||
expect(selected.getAttribute("data-selected")).toBe("true");
|
||||
expect(other.getAttribute("data-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("does not include the word 'checkout' anywhere in the rendered tree", () => {
|
||||
setProjectsState({
|
||||
projects: [
|
||||
project({
|
||||
hosts: [
|
||||
hostEntry({ serverId: "a", serverName: "alpha", workspaceCount: 3 }),
|
||||
hostEntry({ serverId: "b", serverName: "beta", workspaceCount: 2 }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
hostErrors: [{ serverId: "x", serverName: "x", message: "down" }],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const html = container?.innerHTML.toLowerCase() ?? "";
|
||||
expect(html).not.toContain("checkout");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
|
||||
|
||||
interface ProjectsScreenProps {
|
||||
view: { kind: "projects" } | { kind: "project"; projectKey: string };
|
||||
}
|
||||
|
||||
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects();
|
||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
||||
|
||||
if (isLoading && projects.length === 0) {
|
||||
return (
|
||||
<View style={styles.centered} testID="projects-list">
|
||||
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
const message =
|
||||
hiddenUnsupportedRemoteCount > 0
|
||||
? "Non-GitHub remote projects aren't supported yet"
|
||||
: "No projects yet";
|
||||
return (
|
||||
<View style={styles.centered} testID="projects-list">
|
||||
<Text style={styles.emptyText}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="projects-list">
|
||||
{hostErrors.length > 0 ? <HostErrorsBanner errors={hostErrors} /> : null}
|
||||
<View style={settingsStyles.card}>
|
||||
{projects.map((project, index) => (
|
||||
<ProjectRow
|
||||
key={project.projectKey}
|
||||
project={project}
|
||||
isFirst={index === 0}
|
||||
isSelected={selectedProjectKey === project.projectKey}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) {
|
||||
return (
|
||||
<View style={styles.errorsBanner} testID="projects-host-errors">
|
||||
{errors.map((error) => (
|
||||
<Text key={error.serverId} style={styles.errorsBannerText}>
|
||||
{`Couldn't load projects from host ${error.serverName}: ${error.message}`}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectRowProps {
|
||||
project: ProjectSummary;
|
||||
isFirst: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { hosts, projectKey, projectName } = project;
|
||||
const leadingHost = hosts[0];
|
||||
|
||||
const handleNavigate = useCallback(() => {
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
settingsStyles.row,
|
||||
!isFirst && settingsStyles.rowBorder,
|
||||
styles.row,
|
||||
isSelected && styles.rowSelected,
|
||||
hovered && !isSelected && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
[isFirst, isSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={rowStyle}
|
||||
onPress={handleNavigate}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Edit ${projectName}`}
|
||||
testID={`project-row-${projectKey}`}
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.leading}>
|
||||
<ProjectRowIcon host={leadingHost} projectName={projectName} />
|
||||
</View>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{projectName}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectRowIcon({
|
||||
host,
|
||||
projectName,
|
||||
}: {
|
||||
host: ProjectHostEntry | undefined;
|
||||
projectName: string;
|
||||
}) {
|
||||
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
|
||||
const { icon } = useProjectIconQuery({
|
||||
serverId: host?.serverId ?? "",
|
||||
cwd: host?.repoRoot ?? "",
|
||||
});
|
||||
const iconDataUri =
|
||||
icon && icon.data && icon.mimeType ? `data:${icon.mimeType};base64,${icon.data}` : null;
|
||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||
|
||||
if (iconDataUri) {
|
||||
return <Image source={imageSource} style={styles.iconImage} />;
|
||||
}
|
||||
return (
|
||||
<View style={styles.iconFallback}>
|
||||
<Text style={styles.iconFallbackText}>{initial}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
errorsBanner: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[3],
|
||||
marginBottom: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
errorsBannerText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
row: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
rowSelected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
leading: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconImage: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
iconFallback: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconFallbackText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
spinnerColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Shield,
|
||||
Puzzle,
|
||||
Plus,
|
||||
FolderGit2,
|
||||
} from "lucide-react-native";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
@@ -70,11 +71,14 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { HostPage, HostRenameButton } from "@/screens/settings/host-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
buildSettingsHostRoute,
|
||||
buildSettingsSectionRoute,
|
||||
type SettingsSectionSlug,
|
||||
@@ -88,7 +92,9 @@ import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-ac
|
||||
export type SettingsView =
|
||||
| { kind: "root" }
|
||||
| { kind: "section"; section: SettingsSectionSlug }
|
||||
| { kind: "host"; serverId: string };
|
||||
| { kind: "host"; serverId: string }
|
||||
| { kind: "projects" }
|
||||
| { kind: "project"; projectKey: string };
|
||||
|
||||
interface SidebarSectionItem {
|
||||
id: SettingsSectionSlug;
|
||||
@@ -544,6 +550,37 @@ function SidebarSectionButton({
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProjectsButtonProps {
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
const labelStyle = useMemo(
|
||||
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
|
||||
[isSelected, theme.colors.foreground],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={onSelect}
|
||||
testID="settings-projects"
|
||||
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
|
||||
>
|
||||
<FolderGit2
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
Projects
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarHostItemProps {
|
||||
serverId: string;
|
||||
label: string;
|
||||
@@ -590,6 +627,7 @@ interface SettingsSidebarProps {
|
||||
view: SettingsView;
|
||||
onSelectSection: (section: SettingsSectionSlug) => void;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
onSelectProjects: () => void;
|
||||
onAddHost: () => void;
|
||||
onBackToWorkspace: () => void;
|
||||
layout: "desktop" | "mobile";
|
||||
@@ -599,6 +637,7 @@ function SettingsSidebar({
|
||||
view,
|
||||
onSelectSection,
|
||||
onSelectHost,
|
||||
onSelectProjects,
|
||||
onAddHost,
|
||||
onBackToWorkspace,
|
||||
layout,
|
||||
@@ -626,6 +665,7 @@ function SettingsSidebar({
|
||||
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
|
||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
|
||||
return (
|
||||
@@ -655,6 +695,7 @@ function SettingsSidebar({
|
||||
onSelect={onSelectSection}
|
||||
/>
|
||||
))}
|
||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||
</View>
|
||||
<SidebarSeparator />
|
||||
<View style={sidebarStyles.list}>
|
||||
@@ -818,6 +859,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[isCompactLayout, router],
|
||||
);
|
||||
|
||||
const handleSelectProjects = useCallback(() => {
|
||||
const target = buildProjectsSettingsRoute();
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
router.replace(target);
|
||||
}
|
||||
}, [isCompactLayout, router]);
|
||||
|
||||
const handleScanQr = useCallback(() => {
|
||||
closeAddConnectionFlow();
|
||||
router.push({
|
||||
@@ -844,17 +894,11 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
}, [router]);
|
||||
|
||||
const handleBackToWorkspace = useCallback(() => {
|
||||
if (!isCompactLayout) {
|
||||
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
|
||||
if (lastWorkspaceRoute) {
|
||||
router.replace(
|
||||
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
|
||||
if (lastWorkspaceRoute) {
|
||||
router.replace(
|
||||
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (anyOnlineServerId) {
|
||||
@@ -862,7 +906,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
return;
|
||||
}
|
||||
router.replace("/");
|
||||
}, [anyOnlineServerId, isCompactLayout, router]);
|
||||
}, [anyOnlineServerId, router]);
|
||||
|
||||
const detailHeader = ((): {
|
||||
title: string;
|
||||
@@ -883,6 +927,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (!item) return null;
|
||||
return { title: item.label, Icon: item.icon };
|
||||
}
|
||||
if (view.kind === "project" || view.kind === "projects") {
|
||||
return { title: "Projects", Icon: FolderGit2 };
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
@@ -890,6 +937,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (view.kind === "host") {
|
||||
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
||||
}
|
||||
if (view.kind === "projects") {
|
||||
return <ProjectsScreen view={view} />;
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return <ProjectSettingsScreen projectKey={view.projectKey} />;
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
switch (view.section) {
|
||||
case "general":
|
||||
@@ -964,6 +1017,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
layout="mobile"
|
||||
@@ -974,14 +1028,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile detail: full-screen content with a back header that returns to the list.
|
||||
// Mobile detail: full-screen content with a back header. Project detail uses
|
||||
// an app-level back (out of settings, to the workspace) since the in-body
|
||||
// "Back to projects" ghost button handles list-level back; other detail views
|
||||
// step back to the settings root.
|
||||
const detailBackHandler = view.kind === "project" ? handleBackToWorkspace : handleBackToRoot;
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader
|
||||
title={detailHeader?.title}
|
||||
titleAccessory={detailHeader?.titleAccessory}
|
||||
onBack={handleBackToRoot}
|
||||
onBack={detailBackHandler}
|
||||
/>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
@@ -1001,6 +1059,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
layout="desktop"
|
||||
@@ -1094,6 +1153,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
placeholder: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[8],
|
||||
},
|
||||
placeholderText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
const desktopStyles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -115,6 +115,50 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
|
||||
expect(workspace.scripts).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves project placement from workspace descriptor payloads", () => {
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
id: "1",
|
||||
projectId: "remote:github.com/acme/app",
|
||||
projectDisplayName: "acme/app",
|
||||
projectRootPath: "/repo/app",
|
||||
workspaceDirectory: "/repo/app",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
project: {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(workspace.project).toEqual({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWorkspaces", () => {
|
||||
|
||||
@@ -119,6 +119,9 @@ export interface WorkspaceDescriptor {
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
scripts: WorkspaceDescriptorPayload["scripts"];
|
||||
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
|
||||
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
|
||||
project?: ProjectPlacementPayload;
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDescriptor(
|
||||
@@ -136,6 +139,9 @@ export function normalizeWorkspaceDescriptor(
|
||||
status: payload.status,
|
||||
diffStat: payload.diffStat ?? null,
|
||||
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
|
||||
gitRuntime: payload.gitRuntime,
|
||||
githubRuntime: payload.githubRuntime,
|
||||
project: payload.project,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildProjectSettingsRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
decodeFilePathFromPathSegment,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
encodeFilePathForPathSegment,
|
||||
@@ -133,3 +135,28 @@ describe("workspace route parsing", () => {
|
||||
expect(decodeWorkspaceIdFromPathSegment(encoded)).toBe("team/setup:id#1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projects settings routes", () => {
|
||||
it("buildProjectsSettingsRoute returns /settings/projects", () => {
|
||||
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
|
||||
});
|
||||
|
||||
it("buildProjectSettingsRoute encodes a remote project key as a single segment", () => {
|
||||
expect(buildProjectSettingsRoute("remote:github.com/acme/app")).toBe(
|
||||
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildProjectSettingsRoute encodes a local repo-root key", () => {
|
||||
expect(buildProjectSettingsRoute("/Users/me/dev/paseo")).toBe(
|
||||
"/settings/projects/%2FUsers%2Fme%2Fdev%2Fpaseo",
|
||||
);
|
||||
});
|
||||
|
||||
it("project keys round-trip through decodeURIComponent", () => {
|
||||
const projectKey = "remote:github.com/acme/app";
|
||||
const route = buildProjectSettingsRoute(projectKey);
|
||||
const segment = route.slice("/settings/projects/".length);
|
||||
expect(decodeURIComponent(segment)).toBe(projectKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,6 +403,18 @@ export function buildSettingsHostRoute(serverId: string) {
|
||||
return `/settings/hosts/${encodeSegment(normalized)}` as const;
|
||||
}
|
||||
|
||||
export function buildProjectsSettingsRoute() {
|
||||
return "/settings/projects" as const;
|
||||
}
|
||||
|
||||
export function buildProjectSettingsRoute(projectKey: string) {
|
||||
const normalized = trimNonEmpty(projectKey);
|
||||
if (!normalized) {
|
||||
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
|
||||
}
|
||||
return `/settings/projects/${encodeSegment(normalized)}` as const;
|
||||
}
|
||||
|
||||
export function mapPathnameToServer(pathname: string, nextServerId: string) {
|
||||
const normalized = trimNonEmpty(nextServerId);
|
||||
if (!normalized) {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PaseoConfigRaw } from "@server/shared/messages";
|
||||
import { applyDraftToConfig, configToDraft, type ProjectConfigDraft } from "./project-config-form";
|
||||
|
||||
function emptyDraft(): ProjectConfigDraft {
|
||||
return {
|
||||
setupText: "",
|
||||
setupOriginalKind: "missing",
|
||||
teardownText: "",
|
||||
teardownOriginalKind: "missing",
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("configToDraft", () => {
|
||||
it("returns an empty draft for null config", () => {
|
||||
expect(configToDraft(null)).toEqual(emptyDraft());
|
||||
});
|
||||
|
||||
it("renders a string lifecycle command as a single textarea text and remembers the kind", () => {
|
||||
const draft = configToDraft({
|
||||
worktree: { setup: "npm install" },
|
||||
});
|
||||
expect(draft.setupText).toBe("npm install");
|
||||
expect(draft.setupOriginalKind).toBe("string");
|
||||
expect(draft.teardownText).toBe("");
|
||||
expect(draft.teardownOriginalKind).toBe("missing");
|
||||
});
|
||||
|
||||
it("renders an array lifecycle command as newline-separated text", () => {
|
||||
const draft = configToDraft({
|
||||
worktree: { teardown: ["docker compose down", "rm -rf .cache"] },
|
||||
});
|
||||
expect(draft.teardownText).toBe("docker compose down\nrm -rf .cache");
|
||||
expect(draft.teardownOriginalKind).toBe("array");
|
||||
});
|
||||
|
||||
it("converts a scripts record into draft rows with stable local ids", () => {
|
||||
const draft = configToDraft({
|
||||
scripts: {
|
||||
dev: { type: "long-running", command: "npm run dev", port: 3000 },
|
||||
build: { command: ["npm", "run", "build"] },
|
||||
},
|
||||
});
|
||||
expect(draft.scripts).toHaveLength(2);
|
||||
const [devRow, buildRow] = draft.scripts;
|
||||
expect(devRow.name).toBe("dev");
|
||||
expect(devRow.commandText).toBe("npm run dev");
|
||||
expect(devRow.commandOriginalKind).toBe("string");
|
||||
expect(devRow.type).toBe("long-running");
|
||||
expect(devRow.portText).toBe("3000");
|
||||
expect(devRow.id).toBeTruthy();
|
||||
expect(buildRow.name).toBe("build");
|
||||
expect(buildRow.commandText).toBe("npm\nrun\nbuild");
|
||||
expect(buildRow.commandOriginalKind).toBe("array");
|
||||
expect(buildRow.portText).toBe("");
|
||||
expect(buildRow.id).not.toBe(devRow.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDraftToConfig", () => {
|
||||
it("preserves the original string kind when editing an existing setup field", () => {
|
||||
const base: PaseoConfigRaw = { worktree: { setup: "npm install" } };
|
||||
const draft = configToDraft(base);
|
||||
draft.setupText = "npm install\nnpm run prepare";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
expect(next.worktree?.setup).toBe("npm install\nnpm run prepare");
|
||||
});
|
||||
|
||||
it("preserves the original array kind when editing an existing teardown field", () => {
|
||||
const base: PaseoConfigRaw = {
|
||||
worktree: { teardown: ["docker compose down"] },
|
||||
};
|
||||
const draft = configToDraft(base);
|
||||
draft.teardownText = "docker compose down\nrm -rf .cache";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
expect(next.worktree?.teardown).toEqual(["docker compose down", "rm -rf .cache"]);
|
||||
});
|
||||
|
||||
it("writes a string for a newly added lifecycle field with one non-empty line", () => {
|
||||
const base: PaseoConfigRaw = {};
|
||||
const draft = configToDraft(base);
|
||||
draft.setupText = "npm install";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
expect(next.worktree?.setup).toBe("npm install");
|
||||
});
|
||||
|
||||
it("writes an array for a newly added lifecycle field with multiple non-empty lines", () => {
|
||||
const base: PaseoConfigRaw = {};
|
||||
const draft = configToDraft(base);
|
||||
draft.setupText = "npm install\nnpm run prepare";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
expect(next.worktree?.setup).toEqual(["npm install", "npm run prepare"]);
|
||||
});
|
||||
|
||||
it("omits a lifecycle field whose draft text is empty", () => {
|
||||
const base: PaseoConfigRaw = { worktree: { setup: "npm install" } };
|
||||
const draft = configToDraft(base);
|
||||
draft.setupText = "";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
expect(next.worktree?.setup).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves unknown top-level, worktree, and script entry fields on round-trip", () => {
|
||||
const base = {
|
||||
worktree: {
|
||||
setup: "npm install",
|
||||
terminals: [{ name: "dev", command: "npm run dev" }],
|
||||
customWorktreeField: "keep",
|
||||
},
|
||||
scripts: {
|
||||
dev: {
|
||||
type: "long-running",
|
||||
command: "npm run dev",
|
||||
port: 3000,
|
||||
customScriptField: { nested: true },
|
||||
},
|
||||
},
|
||||
customTopLevel: "preserved",
|
||||
} as unknown as PaseoConfigRaw;
|
||||
|
||||
const draft = configToDraft(base);
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
|
||||
expect((next as unknown as Record<string, unknown>).customTopLevel).toBe("preserved");
|
||||
expect((next.worktree as unknown as Record<string, unknown>).customWorktreeField).toBe("keep");
|
||||
expect((next.worktree as unknown as Record<string, unknown>).terminals).toEqual([
|
||||
{ name: "dev", command: "npm run dev" },
|
||||
]);
|
||||
const devEntry = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
|
||||
expect(devEntry.customScriptField).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it("preserves all scripts on round-trip, including ones never edited in this session", () => {
|
||||
const base = {
|
||||
scripts: {
|
||||
dev: { type: "long-running", command: "npm run dev", port: 3000, customDevField: "keep" },
|
||||
build: { command: ["npm", "run", "build"], customBuildField: { nested: 1 } },
|
||||
lint: { command: "npm run lint", type: "task" },
|
||||
},
|
||||
} as unknown as PaseoConfigRaw;
|
||||
|
||||
const draft = configToDraft(base);
|
||||
// Edit only "dev". Leave "build" and "lint" untouched.
|
||||
const devRow = draft.scripts.find((row) => row.name === "dev");
|
||||
if (!devRow) throw new Error("expected dev row in draft");
|
||||
devRow.commandText = "npm run dev -- --watch";
|
||||
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
const scripts = next.scripts ?? {};
|
||||
expect(Object.keys(scripts).sort()).toEqual(["build", "dev", "lint"]);
|
||||
|
||||
const devEntry = scripts.dev as unknown as Record<string, unknown>;
|
||||
expect(devEntry.command).toBe("npm run dev -- --watch");
|
||||
expect(devEntry.type).toBe("long-running");
|
||||
expect(devEntry.port).toBe(3000);
|
||||
expect(devEntry.customDevField).toBe("keep");
|
||||
|
||||
const buildEntry = scripts.build as unknown as Record<string, unknown>;
|
||||
expect(buildEntry.command).toEqual(["npm", "run", "build"]);
|
||||
expect(buildEntry.customBuildField).toEqual({ nested: 1 });
|
||||
|
||||
const lintEntry = scripts.lint as unknown as Record<string, unknown>;
|
||||
expect(lintEntry.command).toBe("npm run lint");
|
||||
expect(lintEntry.type).toBe("task");
|
||||
});
|
||||
|
||||
it("normalizes script command text into the original command kind", () => {
|
||||
const base = {
|
||||
scripts: {
|
||||
build: { command: ["npm", "run", "build"] },
|
||||
},
|
||||
} as unknown as PaseoConfigRaw;
|
||||
const draft = configToDraft(base);
|
||||
const buildRow = draft.scripts[0];
|
||||
buildRow.commandText = "npm run build";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
const buildEntry = (next.scripts ?? {}).build as unknown as Record<string, unknown>;
|
||||
expect(buildEntry.command).toEqual(["npm run build"]);
|
||||
});
|
||||
|
||||
it("parses script port as a number when numeric and writes string for non-numeric input", () => {
|
||||
const base: PaseoConfigRaw = {};
|
||||
const draft = configToDraft(base);
|
||||
draft.scripts = [
|
||||
{
|
||||
id: "row-1",
|
||||
name: "dev",
|
||||
commandText: "npm run dev",
|
||||
commandOriginalKind: "missing",
|
||||
type: "long-running",
|
||||
portText: "3000",
|
||||
rawEntry: {},
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
name: "tunnel",
|
||||
commandText: "ngrok",
|
||||
commandOriginalKind: "missing",
|
||||
type: "long-running",
|
||||
portText: "auto",
|
||||
rawEntry: {},
|
||||
},
|
||||
];
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
const dev = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
|
||||
const tunnel = (next.scripts ?? {}).tunnel as unknown as Record<string, unknown>;
|
||||
expect(dev.port).toBe(3000);
|
||||
expect(tunnel.port).toBe("auto");
|
||||
});
|
||||
|
||||
it("drops scripts with an empty name and removes scripts no longer present in the draft", () => {
|
||||
const base = {
|
||||
scripts: {
|
||||
dev: { command: "npm run dev" },
|
||||
build: { command: "npm run build" },
|
||||
},
|
||||
} as unknown as PaseoConfigRaw;
|
||||
const draft = configToDraft(base);
|
||||
// remove build, add a row with empty name.
|
||||
draft.scripts = draft.scripts
|
||||
.filter((row) => row.name !== "build")
|
||||
.concat({
|
||||
id: "row-empty",
|
||||
name: " ",
|
||||
commandText: "echo hi",
|
||||
commandOriginalKind: "missing",
|
||||
type: "",
|
||||
portText: "",
|
||||
rawEntry: {},
|
||||
});
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
const scripts = next.scripts ?? {};
|
||||
expect(Object.keys(scripts)).toEqual(["dev"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@server/shared/messages";
|
||||
|
||||
export type LifecycleOriginalKind = "string" | "array" | "missing";
|
||||
|
||||
export interface ProjectScriptDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
commandText: string;
|
||||
commandOriginalKind: LifecycleOriginalKind;
|
||||
type: string;
|
||||
portText: string;
|
||||
rawEntry: PaseoScriptEntryRaw;
|
||||
}
|
||||
|
||||
export interface ProjectConfigDraft {
|
||||
setupText: string;
|
||||
setupOriginalKind: LifecycleOriginalKind;
|
||||
teardownText: string;
|
||||
teardownOriginalKind: LifecycleOriginalKind;
|
||||
scripts: ProjectScriptDraft[];
|
||||
}
|
||||
|
||||
interface LifecycleProjection {
|
||||
text: string;
|
||||
kind: LifecycleOriginalKind;
|
||||
}
|
||||
|
||||
function projectLifecycle(value: unknown): LifecycleProjection {
|
||||
if (typeof value === "string") {
|
||||
return { text: value, kind: "string" };
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const lines = value.filter((entry): entry is string => typeof entry === "string");
|
||||
return { text: lines.join("\n"), kind: "array" };
|
||||
}
|
||||
return { text: "", kind: "missing" };
|
||||
}
|
||||
|
||||
function lifecycleFromText(
|
||||
text: string,
|
||||
kind: LifecycleOriginalKind,
|
||||
): string | string[] | undefined {
|
||||
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
||||
if (lines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (kind === "string") {
|
||||
return lines.join("\n");
|
||||
}
|
||||
if (kind === "array") {
|
||||
return lines;
|
||||
}
|
||||
return lines.length === 1 ? lines[0] : lines;
|
||||
}
|
||||
|
||||
function projectScriptType(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function projectScriptPort(value: unknown): string {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseScriptPort(value: string): number | string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^[0-9]+$/.test(trimmed)) {
|
||||
const parsed = Number(trimmed);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
let scriptDraftIdCounter = 0;
|
||||
|
||||
function nextScriptDraftId(): string {
|
||||
scriptDraftIdCounter += 1;
|
||||
return `script-draft-${scriptDraftIdCounter}`;
|
||||
}
|
||||
|
||||
export function configToDraft(config: PaseoConfigRaw | null | undefined): ProjectConfigDraft {
|
||||
const worktree = config?.worktree ?? {};
|
||||
const setup = projectLifecycle(worktree.setup);
|
||||
const teardown = projectLifecycle(worktree.teardown);
|
||||
const scripts: ProjectScriptDraft[] = [];
|
||||
|
||||
const scriptsRecord = config?.scripts ?? {};
|
||||
for (const [name, entry] of Object.entries(scriptsRecord)) {
|
||||
const command = projectLifecycle(entry.command);
|
||||
scripts.push({
|
||||
id: nextScriptDraftId(),
|
||||
name,
|
||||
commandText: command.text,
|
||||
commandOriginalKind: command.kind,
|
||||
type: projectScriptType(entry.type),
|
||||
portText: projectScriptPort(entry.port),
|
||||
rawEntry: entry,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
setupText: setup.text,
|
||||
setupOriginalKind: setup.kind,
|
||||
teardownText: teardown.text,
|
||||
teardownOriginalKind: teardown.kind,
|
||||
scripts,
|
||||
};
|
||||
}
|
||||
|
||||
interface ApplyDraftInput {
|
||||
draft: ProjectConfigDraft;
|
||||
base: PaseoConfigRaw | null | undefined;
|
||||
}
|
||||
|
||||
export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
|
||||
const baseConfig = input.base ?? {};
|
||||
const baseWorktree = baseConfig.worktree ?? {};
|
||||
|
||||
const nextWorktree: Record<string, unknown> = { ...baseWorktree };
|
||||
const nextSetup = lifecycleFromText(input.draft.setupText, input.draft.setupOriginalKind);
|
||||
if (nextSetup === undefined) {
|
||||
delete nextWorktree.setup;
|
||||
} else {
|
||||
nextWorktree.setup = nextSetup;
|
||||
}
|
||||
const nextTeardown = lifecycleFromText(
|
||||
input.draft.teardownText,
|
||||
input.draft.teardownOriginalKind,
|
||||
);
|
||||
if (nextTeardown === undefined) {
|
||||
delete nextWorktree.teardown;
|
||||
} else {
|
||||
nextWorktree.teardown = nextTeardown;
|
||||
}
|
||||
|
||||
const nextScripts: Record<string, PaseoScriptEntryRaw> = {};
|
||||
for (const row of input.draft.scripts) {
|
||||
const trimmedName = row.name.trim();
|
||||
if (trimmedName.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const baseEntry = row.rawEntry;
|
||||
const nextEntry: Record<string, unknown> = { ...baseEntry };
|
||||
const nextCommand = lifecycleFromText(row.commandText, row.commandOriginalKind);
|
||||
if (nextCommand === undefined) {
|
||||
delete nextEntry.command;
|
||||
} else {
|
||||
nextEntry.command = nextCommand;
|
||||
}
|
||||
const trimmedType = row.type.trim();
|
||||
if (trimmedType.length === 0) {
|
||||
delete nextEntry.type;
|
||||
} else {
|
||||
nextEntry.type = trimmedType;
|
||||
}
|
||||
const nextPort = parseScriptPort(row.portText);
|
||||
if (nextPort === undefined) {
|
||||
delete nextEntry.port;
|
||||
} else {
|
||||
nextEntry.port = nextPort;
|
||||
}
|
||||
nextScripts[trimmedName] = nextEntry as PaseoScriptEntryRaw;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = { ...baseConfig };
|
||||
if (Object.keys(nextWorktree).length === 0) {
|
||||
delete result.worktree;
|
||||
} else {
|
||||
result.worktree = nextWorktree;
|
||||
}
|
||||
if (Object.keys(nextScripts).length === 0) {
|
||||
delete result.scripts;
|
||||
} else {
|
||||
result.scripts = nextScripts;
|
||||
}
|
||||
return result as PaseoConfigRaw;
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProjectPlacementPayload } from "@server/shared/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildProjects } from "./projects";
|
||||
|
||||
function placement(input: {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
mainRepoRoot?: string | null;
|
||||
}): ProjectPlacementPayload {
|
||||
return {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName,
|
||||
checkout: {
|
||||
cwd: input.cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
worktreeRoot: input.cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: input.mainRepoRoot ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
repoRoot: string;
|
||||
project?: ProjectPlacementPayload;
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
remoteUrl?: string | null;
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
|
||||
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
|
||||
projectRootPath: input.repoRoot,
|
||||
workspaceDirectory: input.repoRoot,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: input.id,
|
||||
status: "done",
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: {
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
},
|
||||
githubRuntime: null,
|
||||
project: input.project,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildProjects", () => {
|
||||
it("groups two daemons with the same GitHub project key into one project with one host entry per daemon", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "feature-a",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app/feature-a",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "feature-b",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app/feature-b",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
repoRoot: "/work/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/work/app",
|
||||
remoteUrl: "git@github.com:acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "feature",
|
||||
repoRoot: "/work/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/work/app/feature",
|
||||
remoteUrl: "git@github.com:acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
const summary = result.projects[0];
|
||||
expect(summary?.projectKey).toBe("remote:github.com/acme/app");
|
||||
expect(summary?.projectName).toBe("acme/app");
|
||||
expect(summary?.hostCount).toBe(2);
|
||||
expect(summary?.onlineHostCount).toBe(2);
|
||||
expect(summary?.totalWorkspaceCount).toBe(5);
|
||||
expect(summary?.githubUrl).toBe("https://github.com/acme/app");
|
||||
expect(summary?.hosts).toHaveLength(2);
|
||||
const local = summary?.hosts.find((host) => host.serverId === "local");
|
||||
const laptop = summary?.hosts.find((host) => host.serverId === "laptop");
|
||||
expect(local?.workspaceCount).toBe(3);
|
||||
expect(laptop?.workspaceCount).toBe(2);
|
||||
expect(local?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature-a", "feature-b"]);
|
||||
expect(laptop?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature"]);
|
||||
});
|
||||
|
||||
it("collapses five workspaces on one host into a single host entry whose workspaceCount is five", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: Array.from({ length: 5 }, (_, index) =>
|
||||
workspace({
|
||||
id: `ws-${index}`,
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: `/repo/app/ws-${index}`,
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(5);
|
||||
expect(result.projects[0]?.totalWorkspaceCount).toBe(5);
|
||||
expect(result.projects[0]?.hostCount).toBe(1);
|
||||
});
|
||||
|
||||
it("prefers placement mainRepoRoot for the host repoRoot and falls back to projectRootPath when placement is absent", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
repoRoot: "/worktrees/app/main",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/worktrees/app/main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
mainRepoRoot: "/repo/app",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
serverId: "legacy",
|
||||
serverName: "Legacy",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "legacy",
|
||||
repoRoot: "/repo/legacy",
|
||||
projectId: "legacy-project",
|
||||
projectName: "Legacy",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const acme = result.projects.find(
|
||||
(project) => project.projectKey === "remote:github.com/acme/app",
|
||||
);
|
||||
const legacy = result.projects.find((project) => project.projectKey === "legacy-project");
|
||||
|
||||
expect(acme?.hosts[0]?.repoRoot).toBe("/repo/app");
|
||||
expect(legacy?.hosts[0]?.repoRoot).toBe("/repo/legacy");
|
||||
});
|
||||
|
||||
it("derives githubUrl only when projectKey matches remote:github.com/{owner}/{repo}", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "github",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "local",
|
||||
repoRoot: "/repo/local",
|
||||
project: placement({
|
||||
projectKey: "/repo/local",
|
||||
projectName: "local",
|
||||
cwd: "/repo/local",
|
||||
remoteUrl: null,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const github = result.projects.find(
|
||||
(project) => project.projectKey === "remote:github.com/acme/app",
|
||||
);
|
||||
const local = result.projects.find((project) => project.projectKey === "/repo/local");
|
||||
|
||||
expect(github?.githubUrl).toBe("https://github.com/acme/app");
|
||||
expect(local?.githubUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("totals hostCount across all hosts and counts only online ones in onlineHostCount", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "online",
|
||||
serverName: "Online",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "ws",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
serverId: "offline",
|
||||
serverName: "Offline",
|
||||
isOnline: false,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "ws",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.hostCount).toBe(2);
|
||||
expect(result.projects[0]?.onlineHostCount).toBe(1);
|
||||
expect(result.projects[0]?.hosts.find((host) => host.serverId === "online")?.isOnline).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.projects[0]?.hosts.find((host) => host.serverId === "offline")?.isOnline).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not merge fallback repo-root-keyed projects with different roots", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "one",
|
||||
repoRoot: "/repo/one",
|
||||
project: placement({
|
||||
projectKey: "/repo/one",
|
||||
projectName: "one",
|
||||
cwd: "/repo/one",
|
||||
remoteUrl: null,
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "two",
|
||||
repoRoot: "/repo/two",
|
||||
project: placement({
|
||||
projectKey: "/repo/two",
|
||||
projectName: "two",
|
||||
cwd: "/repo/two",
|
||||
remoteUrl: null,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects.map((project) => project.projectKey)).toEqual([
|
||||
"/repo/one",
|
||||
"/repo/two",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters non-GitHub remote projects while keeping GitHub and local projects", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "github",
|
||||
repoRoot: "/repo/github",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/github",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "gitlab",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "app",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "local",
|
||||
repoRoot: "/repo/local",
|
||||
project: placement({
|
||||
projectKey: "/repo/local",
|
||||
projectName: "local",
|
||||
cwd: "/repo/local",
|
||||
remoteUrl: null,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.hiddenUnsupportedRemoteCount).toBe(1);
|
||||
expect(result.projects.map((project) => project.projectKey)).toEqual([
|
||||
"remote:github.com/acme/app",
|
||||
"/repo/local",
|
||||
]);
|
||||
});
|
||||
|
||||
it("produces the unsupported empty-state signal when only non-GitHub remote projects are present", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "gitlab",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "app",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toEqual([]);
|
||||
expect(result.hiddenUnsupportedRemoteCount).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "old-daemon",
|
||||
serverName: "Old daemon",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "legacy",
|
||||
repoRoot: "/repo/legacy",
|
||||
projectId: "legacy-project",
|
||||
projectName: "Legacy",
|
||||
remoteUrl: "https://gitlab.com/acme/legacy.git",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
const summary = result.projects[0];
|
||||
expect(summary?.projectKey).toBe("legacy-project");
|
||||
expect(summary?.projectName).toBe("Legacy");
|
||||
expect(summary?.githubUrl).toBeUndefined();
|
||||
expect(summary?.hosts).toHaveLength(1);
|
||||
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
|
||||
expect(summary?.hosts[0]?.workspaceCount).toBe(1);
|
||||
expect(result.hiddenUnsupportedRemoteCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export interface WorkspaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
status: WorkspaceDescriptor["status"];
|
||||
currentBranch: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectHostEntry {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
repoRoot: string;
|
||||
workspaceCount: number;
|
||||
workspaces: WorkspaceSummary[];
|
||||
gitRuntime?: WorkspaceDescriptor["gitRuntime"];
|
||||
githubRuntime?: WorkspaceDescriptor["githubRuntime"];
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
hosts: ProjectHostEntry[];
|
||||
totalWorkspaceCount: number;
|
||||
hostCount: number;
|
||||
onlineHostCount: number;
|
||||
githubUrl?: string;
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}
|
||||
|
||||
export interface ProjectHost {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
}
|
||||
|
||||
export interface BuildProjectsInput {
|
||||
hosts: ProjectHost[];
|
||||
}
|
||||
|
||||
export interface BuildProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}
|
||||
|
||||
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
|
||||
|
||||
interface HostGroup {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
}
|
||||
|
||||
interface ProjectGroup {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
hostsByServerId: Map<string, HostGroup>;
|
||||
}
|
||||
|
||||
function isSupportedProjectKey(projectKey: string): boolean {
|
||||
return !projectKey.startsWith("remote:") || projectKey.startsWith("remote:github.com/");
|
||||
}
|
||||
|
||||
function deriveGithubUrl(projectKey: string): string | undefined {
|
||||
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return `https://github.com/${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
function resolveHostRepoRoot(workspaces: WorkspaceDescriptor[]): string {
|
||||
for (const workspace of workspaces) {
|
||||
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
|
||||
if (mainRepoRoot) {
|
||||
return mainRepoRoot;
|
||||
}
|
||||
}
|
||||
return workspaces[0]?.projectRootPath ?? "";
|
||||
}
|
||||
|
||||
function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
status: workspace.status,
|
||||
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toHostEntry(group: HostGroup): ProjectHostEntry {
|
||||
const repoRoot = resolveHostRepoRoot(group.workspaces);
|
||||
const canonical =
|
||||
group.workspaces.find((workspace) => workspace.projectRootPath === repoRoot) ??
|
||||
group.workspaces[0];
|
||||
return {
|
||||
serverId: group.serverId,
|
||||
serverName: group.serverName,
|
||||
isOnline: group.isOnline,
|
||||
repoRoot,
|
||||
workspaceCount: group.workspaces.length,
|
||||
workspaces: group.workspaces.map(toWorkspaceSummary),
|
||||
gitRuntime: canonical?.gitRuntime,
|
||||
githubRuntime: canonical?.githubRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
function compareHosts(left: ProjectHostEntry, right: ProjectHostEntry): number {
|
||||
const name = left.serverName.localeCompare(right.serverName);
|
||||
if (name !== 0) {
|
||||
return name;
|
||||
}
|
||||
return left.serverId.localeCompare(right.serverId);
|
||||
}
|
||||
|
||||
function toProjectSummary(input: {
|
||||
draft: ProjectGroup;
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}): ProjectSummary {
|
||||
const hosts = Array.from(input.draft.hostsByServerId.values())
|
||||
.map(toHostEntry)
|
||||
.sort(compareHosts);
|
||||
const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
|
||||
const onlineHostCount = hosts.filter((host) => host.isOnline).length;
|
||||
return {
|
||||
projectKey: input.draft.projectKey,
|
||||
projectName: input.draft.projectName,
|
||||
hosts,
|
||||
totalWorkspaceCount,
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: deriveGithubUrl(input.draft.projectKey),
|
||||
hiddenUnsupportedRemoteCount: input.hiddenUnsupportedRemoteCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
const groups = new Map<string, ProjectGroup>();
|
||||
let hiddenUnsupportedRemoteCount = 0;
|
||||
|
||||
for (const host of input.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
const projectKey = workspace.projectId;
|
||||
if (!isSupportedProjectKey(projectKey)) {
|
||||
hiddenUnsupportedRemoteCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let group = groups.get(projectKey);
|
||||
if (!group) {
|
||||
group = {
|
||||
projectKey,
|
||||
projectName: workspace.projectDisplayName,
|
||||
hostsByServerId: new Map(),
|
||||
};
|
||||
groups.set(projectKey, group);
|
||||
}
|
||||
|
||||
let hostGroup = group.hostsByServerId.get(host.serverId);
|
||||
if (!hostGroup) {
|
||||
hostGroup = {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline: host.isOnline,
|
||||
workspaces: [],
|
||||
};
|
||||
group.hostsByServerId.set(host.serverId, hostGroup);
|
||||
}
|
||||
hostGroup.workspaces.push(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
const projects = Array.from(groups.values()).map((draft) =>
|
||||
toProjectSummary({ draft, hiddenUnsupportedRemoteCount }),
|
||||
);
|
||||
projects.sort((left, right) => {
|
||||
const name = left.projectName.localeCompare(right.projectName);
|
||||
if (name !== 0) {
|
||||
return name;
|
||||
}
|
||||
return left.projectKey.localeCompare(right.projectKey);
|
||||
});
|
||||
|
||||
return {
|
||||
projects,
|
||||
hiddenUnsupportedRemoteCount,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
workspaceKey: "srv:ws-root",
|
||||
serverId: "srv",
|
||||
workspaceId: "ws-root",
|
||||
projectKey: "project-1",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
|
||||
@@ -11,11 +11,13 @@ function workspace(input: {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
name: string;
|
||||
projectKey?: string;
|
||||
}): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
projectKey: input.projectKey ?? "project-default",
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
|
||||
Reference in New Issue
Block a user