* feat(server): derive owner/repo display name for any remote host Generalize deriveProjectGroupingName so any remote:<host>/<segments...> project key returns the last two path segments (owner/repo) instead of just the trailing segment. Path-fallback for non-remote keys is unchanged. Brings GitLab, Gitea, Bitbucket, and self-hosted remotes to parity with the prior github.com-only behavior — no separate special-case needed. * feat(app): show projects from any git remote in Projects settings Remove the isSupportedProjectKey filter so workspaces with non-GitHub remotes (GitLab, Gitea, Bitbucket, self-hosted, ssh-style) appear in the Projects list and route to the project settings screen. The daemon RPCs, config schema, and registry were already host-agnostic; this lifts a client-side filter that hid them. Also remove the now-vestigial hiddenUnsupportedRemoteCount field from ProjectSummary, BuildProjectsResult, and UseProjectsResult — once the filter is gone, the count is always zero and the field is dead state. This is an internal app-package type, not a wire schema, so deletion is safe. * chore(app): simplify Projects empty state to "No projects yet" Drop the "Non-GitHub remote projects aren't supported yet" empty-state branch — it can no longer fire now that any git remote is shown. The empty state is unconditional now. * test(app): cover non-GitHub remote in projects-settings e2e Add a fixture that creates a temp git repo with origin pointing at a gitlab.com URL and exercises the same paseo.json read/edit/save flow already covered for local repos. Verifies the project surfaces with the "acme/app" display name and that the round-trip persists correctly. Extracted the remote-setup branch in createTempGitRepo into a small configureRemote helper to keep the main function under the cyclomatic complexity limit. --------- Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
210 lines
5.8 KiB
TypeScript
210 lines
5.8 KiB
TypeScript
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, 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) {
|
|
return (
|
|
<View style={styles.centered} testID="projects-list">
|
|
<Text style={styles.emptyText}>No projects yet</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,
|
|
},
|
|
}));
|