authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 12:07:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 12:07:02-07:00
loga4c8745abee3f73f960c914800974dee530dbc3b
treef1ada9ba9f459c155b6b3ccaf9dd67f7215ebd0c
parent9b40d3746388e573370488f73b50e1e869b79a45
signature Signed by PGP key B5690EEEBB952194

feat(projects): new projects can be published to any ready host (#17860)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

13 files changed, 257 insertions(+), 67 deletions(-)

apps/mobile/src/features/projects/AddProjectScreen.tsx+52-17
...@@ -15,9 +15,10 @@ import {...@@ -15,9 +15,10 @@ import {
15 getCloneDestinationPath,15 getCloneDestinationPath,
16 getCloneDirectoryName,16 getCloneDirectoryName,
17 getDefaultCloneUrl,17 getDefaultCloneUrl,
18 getNewProjectGitHubRepository,
19 getNewProjectGitHubTarget,
20 getNewProjectPathPreview,18 getNewProjectPathPreview,
19 getNewProjectPublishTarget,
20 getNewProjectPublishTargets,
21 getNewProjectRepository,
21 normalizePastedCloneUrl,22 normalizePastedCloneUrl,
22 parseAddProjectRemoteSource,23 parseAddProjectRemoteSource,
23 resolveAddProjectPath,24 resolveAddProjectPath,
...@@ -995,9 +996,12 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s...@@ -995,9 +996,12 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
995 input: {},996 input: {},
996 }),997 }),
997 );998 );
998 const githubTarget = getNewProjectGitHubTarget(discoveryState.data);999 const publishTargets = getNewProjectPublishTargets(discoveryState.data);
999 const [name, setName] = useState("");1000 const [name, setName] = useState("");
1000 const [publishesToGitHub, setPublishesToGitHub] = useState(false);1001 const [publishes, setPublishes] = useState(false);
1002 // Null takes the first ready host, GitHub when it is ready.
1003 const [publishKind, setPublishKind] = useState<SourceControlProviderKind | null>(null);
1004 const publishTarget = getNewProjectPublishTarget(publishTargets, publishKind);
1001 const [isSubmitting, setIsSubmitting] = useState(false);1005 const [isSubmitting, setIsSubmitting] = useState(false);
1002 const [error, setError] = useState<string | null>(null);1006 const [error, setError] = useState<string | null>(null);
1003 const trimmedName = name.trim();1007 const trimmedName = name.trim();
...@@ -1066,19 +1070,19 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s...@@ -1066,19 +1070,19 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
1066 if (commitError !== undefined) {1070 if (commitError !== undefined) {
1067 Alert.alert("Created without a first commit", commitError);1071 Alert.alert("Created without a first commit", commitError);
1068 }1072 }
1069 if (publishesToGitHub && githubTarget !== null) {1073 if (publishes && publishTarget !== null) {
1070 void publishRepository({1074 void publishRepository({
1071 environmentId: environment.environmentId,1075 environmentId: environment.environmentId,
1072 input: {1076 input: {
1073 cwd: workspaceRoot,1077 cwd: workspaceRoot,
1074 provider: SourceControlProviderKind.make("github"),1078 provider: publishTarget.definition.kind,
1075 repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot),1079 repository: getNewProjectRepository(publishTarget, workspaceRoot),
1076 visibility: "private",1080 visibility: "private",
1077 },1081 },
1078 }).then((publishResult) => {1082 }).then((publishResult) => {
1079 if (AsyncResult.isFailure(publishResult)) {1083 if (AsyncResult.isFailure(publishResult)) {
1080 Alert.alert(1084 Alert.alert(
1081 "Could not create the GitHub repository",1085 `Could not create the ${publishTarget.definition.label} repository`,
1082 errorMessage(Cause.squash(publishResult.cause)),1086 errorMessage(Cause.squash(publishResult.cause)),
1083 );1087 );
1084 }1088 }
...@@ -1133,18 +1137,18 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s...@@ -1133,18 +1137,18 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
1133 </Text>1137 </Text>
1134 ) : null}1138 ) : null}
1135 {machineRows}1139 {machineRows}
1136 {githubTarget !== null ? (1140 {publishTarget !== null ? (
1137 <ListSection>1141 <ListSection>
1138 <ListRow1142 <ListRow
1139 title="Create private repository on GitHub"1143 title={`Create private repository on ${publishTarget.definition.label}`}
1140 subtitle={1144 subtitle={
1141 trimmedName.length > 0 && pathPreview !== null1145 trimmedName.length > 0 && pathPreview !== null
1142 ? getNewProjectGitHubRepository(githubTarget, pathPreview)1146 ? getNewProjectRepository(publishTarget, pathPreview)
1143 : githubTarget.account1147 : publishTarget.owner
1144 }1148 }
1145 icon={1149 icon={
1146 <SourceControlIcon1150 <SourceControlIcon
1147 kind={sourceControlClients.get("github").icon}1151 kind={publishTarget.definition.icon}
1148 size={Platform.OS === "android" ? 24 : 18}1152 size={Platform.OS === "android" ? 24 : 18}
1149 colorClassName="accent-icon"1153 colorClassName="accent-icon"
1150 />1154 />
...@@ -1152,13 +1156,44 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s...@@ -1152,13 +1156,44 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
1152 isFirst1156 isFirst
1153 right={1157 right={
1154 <ThemedSwitch1158 <ThemedSwitch
1155 accessibilityLabel="Create private repository on GitHub"1159 accessibilityLabel={`Create private repository on ${publishTarget.definition.label}`}
1156 value={publishesToGitHub}1160 value={publishes}
1157 onValueChange={setPublishesToGitHub}1161 onValueChange={setPublishes}
1158 />1162 />
1159 }1163 }
1160 onPress={() => setPublishesToGitHub((publishes) => !publishes)}1164 onPress={() => setPublishes((current) => !current)}
1161 />1165 />
1166 {publishes && publishTargets.length > 1
1167 ? publishTargets.map((target) => {
1168 const selected = target.definition.kind === publishTarget.definition.kind;
1169 return (
1170 <ListRow
1171 key={target.definition.kind}
1172 title={target.definition.label}
1173 icon={
1174 <SourceControlIcon
1175 kind={target.definition.icon}
1176 size={Platform.OS === "android" ? 24 : 17}
1177 colorClassName="accent-icon"
1178 />
1179 }
1180 selected={selected}
1181 disabled={isSubmitting}
1182 right={
1183 selected ? (
1184 <SymbolView
1185 name="checkmark"
1186 size={Platform.OS === "android" ? 20 : 14}
1187 tintColorClassName="accent-icon"
1188 type="monochrome"
1189 />
1190 ) : null
1191 }
1192 onPress={() => setPublishKind(target.definition.kind)}
1193 />
1194 );
1195 })
1196 : null}
1162 </ListSection>1197 </ListSection>
1163 ) : null}1198 ) : null}
1164 <PrimaryActionButton1199 <PrimaryActionButton
apps/web/src/components/CommandPalette.tsx+97-19
...@@ -10,9 +10,11 @@ import {...@@ -10,9 +10,11 @@ import {
10 getCloneDestinationPath,10 getCloneDestinationPath,
11 getCloneDirectoryName,11 getCloneDirectoryName,
12 getDefaultCloneUrl,12 getDefaultCloneUrl,
13 getNewProjectGitHubRepository,
14 getNewProjectGitHubTarget,
15 getNewProjectPathPreview,13 getNewProjectPathPreview,
14 getNewProjectPublishTarget,
15 getNewProjectPublishTargets,
16 getNewProjectRepository,
17 type NewProjectPublishTarget,
16 normalizePastedCloneUrl,18 normalizePastedCloneUrl,
17 addProjectRemoteSourceLabel,19 addProjectRemoteSourceLabel,
18 addProjectRemoteSourcePathHint,20 addProjectRemoteSourcePathHint,
...@@ -195,7 +197,6 @@ import {...@@ -195,7 +197,6 @@ import {
195 CommandPaletteVirtualizedResults,197 CommandPaletteVirtualizedResults,
196 scrollCommandPaletteRowIntoView,198 scrollCommandPaletteRowIntoView,
197} from "./CommandPaletteResults";199} from "./CommandPaletteResults";
198import { GitHubIcon } from "./Icons";
199import { sourceControlIcon } from "~/sourceControlPresentation";200import { sourceControlIcon } from "~/sourceControlPresentation";
200import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";201import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
201import { Checkbox } from "./ui/checkbox";202import { Checkbox } from "./ui/checkbox";
...@@ -221,6 +222,7 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin...@@ -221,6 +222,7 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin
221import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";222import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
222import { Button } from "./ui/button";223import { Button } from "./ui/button";
223import { Kbd, KbdGroup } from "./ui/kbd";224import { Kbd, KbdGroup } from "./ui/kbd";
225import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
224import { stackedThreadToast, toastManager } from "./ui/toast";226import { stackedThreadToast, toastManager } from "./ui/toast";
225import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";227import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
226import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";228import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
...@@ -280,6 +282,14 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri...@@ -280,6 +282,14 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
280 return <Icon className={className} />;282 return <Icon className={className} />;
281}283}
282284
285function sourceControlHostIcon(
286 definition: NewProjectPublishTarget["definition"],
287 className: string,
288): ReactNode {
289 const Icon = sourceControlIcon(definition);
290 return <Icon className={className} />;
291}
292
283function projectFaviconIcon(project: Project): ReactNode {293function projectFaviconIcon(project: Project): ReactNode {
284 return <ProjectFavicon project={project} className={ITEM_ICON_CLASS} />;294 return <ProjectFavicon project={project} className={ITEM_ICON_CLASS} />;
285}295}
...@@ -762,7 +772,11 @@ function OpenCommandPaletteDialog(props: {...@@ -762,7 +772,11 @@ function OpenCommandPaletteDialog(props: {
762 /** Machine of the Add project sources view under this step; null from the palette root. */772 /** Machine of the Add project sources view under this step; null from the palette root. */
763 readonly sourcesEnvironmentId: EnvironmentId | null;773 readonly sourcesEnvironmentId: EnvironmentId | null;
764 } | null>(null);774 } | null>(null);
765 const [newProjectPublishesToGitHub, setNewProjectPublishesToGitHub] = useState(false);775 const [newProjectPublishes, setNewProjectPublishes] = useState(false);
776 // The host picked beside the publish toggle; null takes the first ready host, GitHub when ready.
777 const [newProjectPublishKind, setNewProjectPublishKind] =
778 useState<SourceControlProviderKind | null>(null);
779 const [newProjectHostPickerOpen, setNewProjectHostPickerOpen] = useState(false);
766 const [isCreatingNewProject, setIsCreatingNewProject] = useState(false);780 const [isCreatingNewProject, setIsCreatingNewProject] = useState(false);
767 // State lags a render behind, so a repeated Enter could start a second create.781 // State lags a render behind, so a repeated Enter could start a second create.
768 const newProjectSubmittingRef = useRef(false);782 const newProjectSubmittingRef = useRef(false);
...@@ -1482,7 +1496,8 @@ function OpenCommandPaletteDialog(props: {...@@ -1482,7 +1496,8 @@ function OpenCommandPaletteDialog(props: {
1482 setAddProjectEnvironmentId(environmentId);1496 setAddProjectEnvironmentId(environmentId);
1483 setAddProjectCloneFlow(null);1497 setAddProjectCloneFlow(null);
1484 setNewProjectFlow({ environmentId, sourcesEnvironmentId });1498 setNewProjectFlow({ environmentId, sourcesEnvironmentId });
1485 setNewProjectPublishesToGitHub(false);1499 setNewProjectPublishes(false);
1500 setNewProjectPublishKind(null);
1486 pushPaletteView({1501 pushPaletteView({
1487 addonIcon: <FolderGit2Icon className={ADDON_ICON_CLASS} />,1502 addonIcon: <FolderGit2Icon className={ADDON_ICON_CLASS} />,
1488 groups: [],1503 groups: [],
...@@ -2417,8 +2432,12 @@ function OpenCommandPaletteDialog(props: {...@@ -2417,8 +2432,12 @@ function OpenCommandPaletteDialog(props: {
2417 ],2432 ],
2418 );2433 );
24192434
2420 const newProjectGitHubTarget =2435 const newProjectPublishTargets =
2421 newProjectFlow === null ? null : getNewProjectGitHubTarget(sourceControlDiscovery.data ?? null);2436 newProjectFlow === null ? [] : getNewProjectPublishTargets(sourceControlDiscovery.data ?? null);
2437 const newProjectPublishTarget = getNewProjectPublishTarget(
2438 newProjectPublishTargets,
2439 newProjectPublishKind,
2440 );
2422 const newProjectName = query.trim();2441 const newProjectName = query.trim();
2423 const canSubmitNewProject =2442 const canSubmitNewProject =
2424 newProjectFlow !== null &&2443 newProjectFlow !== null &&
...@@ -2436,7 +2455,7 @@ function OpenCommandPaletteDialog(props: {...@@ -2436,7 +2455,7 @@ function OpenCommandPaletteDialog(props: {
2436 const created = await createNewProject({2455 const created = await createNewProject({
2437 environmentId: newProjectFlow.environmentId,2456 environmentId: newProjectFlow.environmentId,
2438 name: newProjectName,2457 name: newProjectName,
2439 github: newProjectPublishesToGitHub ? newProjectGitHubTarget : null,2458 publishTo: newProjectPublishes ? newProjectPublishTarget : null,
2440 });2459 });
2441 if (created) setOpen(false);2460 if (created) setOpen(false);
2442 } finally {2461 } finally {
...@@ -2742,7 +2761,7 @@ function OpenCommandPaletteDialog(props: {...@@ -2742,7 +2761,7 @@ function OpenCommandPaletteDialog(props: {
2742 const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null;2761 const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null;
2743 const newProjectPathPreview =2762 const newProjectPathPreview =
2744 newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName);2763 newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName);
2745 const newProjectGitHubToggleValue = "new-project:github";2764 const newProjectPublishToggleValue = "new-project:publish";
2746 // The name step's way out to folders and clones, for the selected machine.2765 // The name step's way out to folders and clones, for the selected machine.
2747 // It replaces the name step (and a sources view for another machine under2766 // It replaces the name step (and a sources view for another machine under
2748 // it), so Back returns to wherever New project was opened from.2767 // it), so Back returns to wherever New project was opened from.
...@@ -2829,7 +2848,7 @@ function OpenCommandPaletteDialog(props: {...@@ -2829,7 +2848,7 @@ function OpenCommandPaletteDialog(props: {
2829 ],2848 ],
2830 };2849 };
2831 const newProjectOptionGroups: CommandPaletteView["groups"] =2850 const newProjectOptionGroups: CommandPaletteView["groups"] =
2832 newProjectGitHubTarget === null || newProjectPathPreview === null2851 newProjectPublishTarget === null || newProjectPathPreview === null
2833 ? []2852 ? []
2834 : [2853 : [
2835 {2854 {
...@@ -2838,22 +2857,81 @@ function OpenCommandPaletteDialog(props: {...@@ -2838,22 +2857,81 @@ function OpenCommandPaletteDialog(props: {
2838 items: [2857 items: [
2839 {2858 {
2840 kind: "action",2859 kind: "action",
2841 value: newProjectGitHubToggleValue,2860 value: newProjectPublishToggleValue,
2842 searchTerms: [],2861 searchTerms: [],
2843 title: "Create private repository on GitHub",2862 title:
2863 newProjectPublishTargets.length > 1
2864 ? "Create private repository"
2865 : `Create private repository on ${newProjectPublishTarget.definition.label}`,
2844 description:2866 description:
2845 newProjectName.length > 02867 newProjectName.length > 0
2846 ? getNewProjectGitHubRepository(newProjectGitHubTarget, newProjectPathPreview)2868 ? getNewProjectRepository(newProjectPublishTarget, newProjectPathPreview)
2847 : (newProjectGitHubTarget.account ?? "Your GitHub account"),2869 : (newProjectPublishTarget.owner ??
2848 icon: <GitHubIcon className={ITEM_ICON_CLASS} />,2870 `Your ${newProjectPublishTarget.definition.label} account`),
2871 icon: sourceControlHostIcon(newProjectPublishTarget.definition, ITEM_ICON_CLASS),
2849 titleTrailingContent: (2872 titleTrailingContent: (
2850 <span className="pointer-events-none ms-auto flex">2873 <span className="ms-auto flex items-center gap-2">
2851 <Checkbox checked={newProjectPublishesToGitHub} tabIndex={-1} aria-hidden />2874 {newProjectPublishTargets.length > 1 ? (
2875 // The row toggles on click, so the picker keeps its clicks to itself. The
2876 // row also cancels pointerdown to keep focus in the input, which drops the
2877 // mousedown Select opens on, so the picker opens on click instead.
2878 <span
2879 className="flex"
2880 onClick={(event) => {
2881 event.stopPropagation();
2882 // React bubbles the popup's clicks here through its portal; only the
2883 // trigger opens the picker.
2884 if (!event.currentTarget.contains(event.target as Node)) return;
2885 if (!isCreatingNewProject) setNewProjectHostPickerOpen(true);
2886 }}
2887 >
2888 <Select
2889 disabled={isCreatingNewProject}
2890 open={newProjectHostPickerOpen}
2891 onOpenChange={setNewProjectHostPickerOpen}
2892 value={newProjectPublishTarget.definition.kind}
2893 onValueChange={(kind) => {
2894 if (kind !== null) setNewProjectPublishKind(kind);
2895 }}
2896 >
2897 <SelectTrigger
2898 size="xs"
2899 variant="ghost"
2900 aria-label="Host for the new repository"
2901 >
2902 <SelectValue>
2903 {(kind: SourceControlProviderKind) =>
2904 sourceControlClients.get(kind).label
2905 }
2906 </SelectValue>
2907 </SelectTrigger>
2908 <SelectPopup align="end" alignItemWithTrigger={false}>
2909 {newProjectPublishTargets.map((target) => (
2910 <SelectItem
2911 key={target.definition.kind}
2912 hideIndicator
2913 value={target.definition.kind}
2914 >
2915 <span className="inline-flex items-center gap-1.5">
2916 {sourceControlHostIcon(target.definition, "size-3.5")}
2917 {target.definition.label}
2918 </span>
2919 </SelectItem>
2920 ))}
2921 </SelectPopup>
2922 </Select>
2923 </span>
2924 ) : null}
2925 <span className="pointer-events-none flex">
2926 <Checkbox checked={newProjectPublishes} tabIndex={-1} aria-hidden />
2927 </span>
2852 </span>2928 </span>
2853 ),2929 ),
2930 // The create in flight keeps the choice it started with.
2931 ...(isCreatingNewProject ? { disabled: true } : {}),
2854 keepOpen: true,2932 keepOpen: true,
2855 run: async () => {2933 run: async () => {
2856 setNewProjectPublishesToGitHub((publishes) => !publishes);2934 setNewProjectPublishes((publishes) => !publishes);
2857 },2935 },
2858 },2936 },
2859 ],2937 ],
...@@ -3327,7 +3405,7 @@ function OpenCommandPaletteDialog(props: {...@@ -3327,7 +3405,7 @@ function OpenCommandPaletteDialog(props: {
3327 newProjectFlow !== null3405 newProjectFlow !== null
3328 ? highlightedItemValue === null3406 ? highlightedItemValue === null
3329 ? "Create"3407 ? "Create"
3330 : highlightedItemValue === newProjectGitHubToggleValue3408 : highlightedItemValue === newProjectPublishToggleValue
3331 ? "Toggle"3409 ? "Toggle"
3332 : "Select"3410 : "Select"
3333 : addProjectCloneFlow?.step === "repository"3411 : addProjectCloneFlow?.step === "repository"
apps/web/src/components/ui/checkbox.tsx+4-3
...@@ -14,7 +14,8 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {...@@ -14,7 +14,8 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
14 data-slot="checkbox"14 data-slot="checkbox"
15 {...props}15 {...props}
16 >16 >
17 {/* Leave a contrasting inner gap inside the 2px focus outline. */}17 {/* Leave a contrasting inner gap inside the 2px focus outline. The glyphs name their color so
18 rows that mute stray icons, such as list items, leave the indicator's color alone. */}
18 <CheckboxPrimitive.Indicator19 <CheckboxPrimitive.Indicator
19 className="-inset-px absolute flex items-center justify-center rounded-[.25rem] text-primary-foreground data-unchecked:hidden data-checked:bg-primary data-indeterminate:text-foreground in-[[data-slot=checkbox]:focus-visible]:inset-shadow-[0_0_0_3px_var(--background)] in-[[data-slot=checkbox]:focus-visible]:outline-2 in-[[data-slot=checkbox]:focus-visible]:outline-solid in-[[data-slot=checkbox]:focus-visible]:-outline-offset-2 in-[[data-slot=checkbox]:focus-visible]:outline-inherit"20 className="-inset-px absolute flex items-center justify-center rounded-[.25rem] text-primary-foreground data-unchecked:hidden data-checked:bg-primary data-indeterminate:text-foreground in-[[data-slot=checkbox]:focus-visible]:inset-shadow-[0_0_0_3px_var(--background)] in-[[data-slot=checkbox]:focus-visible]:outline-2 in-[[data-slot=checkbox]:focus-visible]:outline-solid in-[[data-slot=checkbox]:focus-visible]:-outline-offset-2 in-[[data-slot=checkbox]:focus-visible]:outline-inherit"
20 data-slot="checkbox-indicator"21 data-slot="checkbox-indicator"
...@@ -22,7 +23,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {...@@ -22,7 +23,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
22 <span {...props}>23 <span {...props}>
23 {state.indeterminate ? (24 {state.indeterminate ? (
24 <svg25 <svg
25 className="size-3.5 sm:size-3"26 className="size-3.5 text-current sm:size-3"
26 fill="none"27 fill="none"
27 height="24"28 height="24"
28 stroke="currentColor"29 stroke="currentColor"
...@@ -37,7 +38,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {...@@ -37,7 +38,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
37 </svg>38 </svg>
38 ) : (39 ) : (
39 <svg40 <svg
40 className="size-3.5 sm:size-3"41 className="size-3.5 text-current sm:size-3"
41 fill="none"42 fill="none"
42 height="24"43 height="24"
43 stroke="currentColor"44 stroke="currentColor"
apps/web/src/hooks/useNewProject.ts+18-15
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { scopeProjectRef } from "@t3tools/client-runtime/environment";1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
3import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects";2import {
3 getNewProjectRepository,
4 type NewProjectPublishTarget,
5} from "@t3tools/client-runtime/operations/projects";
4import {6import {
5 isAtomCommandInterrupted,7 isAtomCommandInterrupted,
6 squashAtomCommandFailure,8 squashAtomCommandFailure,
...@@ -24,8 +26,8 @@ function errorMessage(error: unknown): string {...@@ -24,8 +26,8 @@ function errorMessage(error: unknown): string {
24/**26/**
25 * Starts a project from just a name. The server makes a folder under its27 * Starts a project from just a name. The server makes a folder under its
26 * `newProjectsRoot` with a README, an icon, and a first commit; this then28 * `newProjectsRoot` with a README, an icon, and a first commit; this then
27 * opens a new thread draft in it. With `github`, it also publishes the29 * opens a new thread draft in it. With `publishTo`, it also publishes the
28 * repository as private, without holding up the draft.30 * repository as private on that host, without holding up the draft.
29 *31 *
30 * Resolves to whether the project was created.32 * Resolves to whether the project was created.
31 */33 */
...@@ -36,18 +38,19 @@ export function useNewProject() {...@@ -36,18 +38,19 @@ export function useNewProject() {
36 });38 });
37 const handleNewThread = useNewThreadHandler();39 const handleNewThread = useNewThreadHandler();
3840
39 const publishToGitHub = useCallback(41 const publishNewRepository = useCallback(
40 async (input: {42 async (input: {
41 readonly environmentId: EnvironmentId;43 readonly environmentId: EnvironmentId;
42 readonly workspaceRoot: string;44 readonly workspaceRoot: string;
43 readonly account: string | null;45 readonly target: NewProjectPublishTarget;
44 }) => {46 }) => {
47 const { label } = input.target.definition;
45 const result = await publishRepository({48 const result = await publishRepository({
46 environmentId: input.environmentId,49 environmentId: input.environmentId,
47 input: {50 input: {
48 cwd: input.workspaceRoot,51 cwd: input.workspaceRoot,
49 provider: SourceControlProviderKind.make("github"),52 provider: input.target.definition.kind,
50 repository: getNewProjectGitHubRepository(input, input.workspaceRoot),53 repository: getNewProjectRepository(input.target, input.workspaceRoot),
51 visibility: "private",54 visibility: "private",
52 },55 },
53 });56 });
...@@ -56,7 +59,7 @@ export function useNewProject() {...@@ -56,7 +59,7 @@ export function useNewProject() {
56 toastManager.add(59 toastManager.add(
57 stackedThreadToast({60 stackedThreadToast({
58 type: "error",61 type: "error",
59 title: "Could not create the GitHub repository",62 title: `Could not create the ${label} repository`,
60 description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`,63 description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`,
61 }),64 }),
62 );65 );
...@@ -66,7 +69,7 @@ export function useNewProject() {...@@ -66,7 +69,7 @@ export function useNewProject() {
66 toastManager.add(69 toastManager.add(
67 stackedThreadToast({70 stackedThreadToast({
68 type: "success",71 type: "success",
69 title: "Published to GitHub",72 title: `Published to ${label}`,
70 description: result.value.repository.nameWithOwner,73 description: result.value.repository.nameWithOwner,
71 }),74 }),
72 );75 );
...@@ -78,7 +81,7 @@ export function useNewProject() {...@@ -78,7 +81,7 @@ export function useNewProject() {
78 async (input: {81 async (input: {
79 readonly environmentId: EnvironmentId;82 readonly environmentId: EnvironmentId;
80 readonly name: string;83 readonly name: string;
81 readonly github: { readonly account: string | null } | null;84 readonly publishTo: NewProjectPublishTarget | null;
82 }): Promise<boolean> => {85 }): Promise<boolean> => {
83 const result = await createNew({86 const result = await createNew({
84 environmentId: input.environmentId,87 environmentId: input.environmentId,
...@@ -110,11 +113,11 @@ export function useNewProject() {...@@ -110,11 +113,11 @@ export function useNewProject() {
110 },113 },
111 ),114 ),
112 );115 );
113 if (input.github) {116 if (input.publishTo) {
114 void publishToGitHub({117 void publishNewRepository({
115 environmentId: input.environmentId,118 environmentId: input.environmentId,
116 workspaceRoot,119 workspaceRoot,
117 account: input.github.account,120 target: input.publishTo,
118 });121 });
119 }122 }
120123
...@@ -143,6 +146,6 @@ export function useNewProject() {...@@ -143,6 +146,6 @@ export function useNewProject() {
143 });146 });
144 return true;147 return true;
145 },148 },
146 [createNew, handleNewThread, publishToGitHub],149 [createNew, handleNewThread, publishNewRepository],
147 );150 );
148}151}
packages/client-runtime/src/operations/projects.test.ts+35
...@@ -18,6 +18,8 @@ import {...@@ -18,6 +18,8 @@ import {
18 getCloneDestinationPath,18 getCloneDestinationPath,
19 getCloneDirectoryName,19 getCloneDirectoryName,
20 getDefaultCloneUrl,20 getDefaultCloneUrl,
21 getNewProjectPublishTarget,
22 getNewProjectPublishTargets,
21 normalizePastedCloneUrl,23 normalizePastedCloneUrl,
22 resolveAddProjectPath,24 resolveAddProjectPath,
23 sortAddProjectProviderSources,25 sortAddProjectProviderSources,
...@@ -279,3 +281,36 @@ describe("add project shared logic", () => {...@@ -279,3 +281,36 @@ describe("add project shared logic", () => {
279 });281 });
280 });282 });
281});283});
284
285describe("new project publish targets", () => {
286 const readyHost = (kind: string, account: string | null) => ({
287 kind: SourceControlProviderKind.make(kind),
288 label: kind,
289 status: "available" as const,
290 installHint: "",
291 version: Option.none(),
292 detail: Option.none(),
293 auth: {
294 status: "authenticated" as const,
295 account: Option.fromNullOr(account),
296 host: Option.none(),
297 detail: Option.none(),
298 },
299 });
300
301 it("offers ready hosts that can place the repository, GitHub first, and keeps the pick", () => {
302 const targets = getNewProjectPublishTargets({
303 versionControlSystems: [],
304 sourceControlProviders: [
305 readyHost("gitlab", "group"),
306 readyHost("github", null),
307 readyHost("azure-devops", "me"),
308 ],
309 });
310 expect(targets.map((target) => target.definition.kind)).toEqual(["github", "gitlab"]);
311 expect(getNewProjectPublishTarget(targets, null)?.definition.kind).toBe("github");
312 expect(
313 getNewProjectPublishTarget(targets, SourceControlProviderKind.make("gitlab"))?.owner,
314 ).toBe("group");
315 });
316});
packages/client-runtime/src/operations/projects.ts+36-13
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25} from "../state/projects.ts";25} from "../state/projects.ts";
26import type { EnvironmentProject } from "../state/models.ts";26import type { EnvironmentProject } from "../state/models.ts";
27import { sourceControlClients } from "../sourceControlClients.ts";27import { sourceControlClients } from "../sourceControlClients.ts";
28import type { SourceControlClientDefinition } from "../sourceControlClients.ts";
2829
29/** A host to clone from, or `url` for a pasted clone URL. */30/** A host to clone from, or `url` for a pasted clone URL. */
30export type AddProjectRemoteSource = SourceControlProviderKind | "url";31export type AddProjectRemoteSource = SourceControlProviderKind | "url";
...@@ -225,27 +226,49 @@ export function getNewProjectPathPreview(newProjectsRoot: string, name: string):...@@ -225,27 +226,49 @@ export function getNewProjectPathPreview(newProjectsRoot: string, name: string):
225 return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name));226 return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name));
226}227}
227228
229/** A host a new project can be published to, and who the repository goes under there. */
230export interface NewProjectPublishTarget {
231 readonly definition: SourceControlClientDefinition;
232 /** Null lets the host's CLI place it under the signed-in user. */
233 readonly owner: string | null;
234}
235
228/**236/**
229 * The GitHub account a new project would be published under, or null when237 * The hosts a new project can be published to on that environment, in definition order so
230 * GitHub is not ready on that environment. A ready GitHub with an unknown238 * GitHub leads. A host is offered when it is ready and its account names where the repository
231 * account still publishes; `gh` picks the signed-in user.239 * goes.
232 */240 */
233export function getNewProjectGitHubTarget(241export function getNewProjectPublishTargets(
234 discovery: SourceControlDiscoveryResult | null,242 discovery: SourceControlDiscoveryResult | null,
235): { readonly account: string | null } | null {243): ReadonlyArray<NewProjectPublishTarget> {
236 const kind = SourceControlProviderKind.make("github");244 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
237 if (!buildAddProjectRemoteSourceReadiness(discovery)(kind).ready) return null;245 return sourceControlClients.definitions.flatMap((definition) => {
238 const github = discovery?.sourceControlProviders.find((provider) => provider.kind === kind);246 if (!readiness(definition.kind).ready) return [];
239 return { account: github ? Option.getOrNull(github.auth.account) : null };247 const provider = discovery?.sourceControlProviders.find(
248 (candidate) => candidate.kind === definition.kind,
249 );
250 const placement = definition.newRepositoryOwner(
251 provider ? Option.getOrNull(provider.auth.account) : null,
252 );
253 return placement === null ? [] : [{ definition, owner: placement.owner }];
254 });
255}
256
257/** The chosen host's target, or the first one when the choice is not on offer. */
258export function getNewProjectPublishTarget(
259 targets: ReadonlyArray<NewProjectPublishTarget>,
260 kind: SourceControlProviderKind | null,
261): NewProjectPublishTarget | null {
262 return targets.find((target) => target.definition.kind === kind) ?? targets[0] ?? null;
240}263}
241264
242/** `owner/folder` for publishing a new project, or just the folder for `gh` to place. */265/** `owner/folder` for publishing a new project, or just the folder for the host to place. */
243export function getNewProjectGitHubRepository(266export function getNewProjectRepository(
244 target: { readonly account: string | null },267 target: Pick<NewProjectPublishTarget, "owner">,
245 workspaceRoot: string,268 workspaceRoot: string,
246): string {269): string {
247 const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? "";270 const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? "";
248 return target.account ? `${target.account}/${folderName}` : folderName;271 return target.owner ? `${target.owner}/${folderName}` : folderName;
249}272}
250273
251/**274/**
packages/source-control-azure-devops/src/client/definition.ts+2
...@@ -37,6 +37,8 @@ export const definition = defineSourceControlClient({...@@ -37,6 +37,8 @@ export const definition = defineSourceControlClient({
37 publicHost: null,37 publicHost: null,
38 publishDescription: "dev.azure.com",38 publishDescription: "dev.azure.com",
39 publishHost: () => "dev.azure.com",39 publishHost: () => "dev.azure.com",
40 // A new repository belongs to a project, which the account does not name.
41 newRepositoryOwner: () => null,
40 defaultCloneTransport: "ssh",42 defaultCloneTransport: "ssh",
41 changeRequestUrl: ({ host, repository, number }) =>43 changeRequestUrl: ({ host, repository, number }) =>
42 `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`,44 `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`,
packages/source-control-bitbucket/src/client/definition.ts+2
...@@ -26,6 +26,8 @@ export const definition = defineSourceControlClient({...@@ -26,6 +26,8 @@ export const definition = defineSourceControlClient({
26 publicHost: "bitbucket.org",26 publicHost: "bitbucket.org",
27 publishDescription: "bitbucket.org",27 publishDescription: "bitbucket.org",
28 publishHost: () => "bitbucket.org",28 publishHost: () => "bitbucket.org",
29 // A new repository belongs to a workspace, which the signed-in account does not name.
30 newRepositoryOwner: () => null,
29 defaultCloneTransport: "ssh",31 defaultCloneTransport: "ssh",
30 changeRequestUrl: ({ host, repository, number }) =>32 changeRequestUrl: ({ host, repository, number }) =>
31 `https://${host}/${repository}/pull-requests/${number}`,33 `https://${host}/${repository}/pull-requests/${number}`,
packages/source-control-core/src/client/definition.ts+7
...@@ -74,6 +74,12 @@ export interface SourceControlClientDefinition {...@@ -74,6 +74,12 @@ export interface SourceControlClientDefinition {
74 readonly publishDescription: string;74 readonly publishDescription: string;
75 /** Where a publish lands, given the host the server is signed in to, if it reported one. */75 /** Where a publish lands, given the host the server is signed in to, if it reported one. */
76 readonly publishHost: (signedInHost: string | null) => string;76 readonly publishHost: (signedInHost: string | null) => string;
77 /**
78 * Who a new project's repository is published under, given the signed-in account: an owner to
79 * name, `null` owner to let the host's CLI pick the signed-in user, or null when the host needs
80 * a choice an account cannot make, such as Azure DevOps' project.
81 */
82 readonly newRepositoryOwner: (account: string | null) => { readonly owner: string | null } | null;
77 /** Which of a repository's clone URLs a new clone uses. */83 /** Which of a repository's clone URLs a new clone uses. */
78 readonly defaultCloneTransport: "https" | "ssh";84 readonly defaultCloneTransport: "https" | "ssh";
79 /** The web URL of a change request, or null when this client cannot build one for the host. */85 /** The web URL of a change request, or null when this client cannot build one for the host. */
...@@ -224,6 +230,7 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {...@@ -224,6 +230,7 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {
224 publicHost: null,230 publicHost: null,
225 publishDescription: "Your signed-in server",231 publishDescription: "Your signed-in server",
226 publishHost: (signedInHost) => signedInHost ?? "your server",232 publishHost: (signedInHost) => signedInHost ?? "your server",
233 newRepositoryOwner: () => null,
227 defaultCloneTransport: "ssh",234 defaultCloneTransport: "ssh",
228 changeRequestUrl: () => null,235 changeRequestUrl: () => null,
229 changeRequestActions: new Set(),236 changeRequestActions: new Set(),
packages/source-control-forgejo/src/client/definition.ts+1
...@@ -44,6 +44,7 @@ export const definition = defineSourceControlClient({...@@ -44,6 +44,7 @@ export const definition = defineSourceControlClient({
44 publicHost: null,44 publicHost: null,
45 publishDescription: "Your signed-in server",45 publishDescription: "Your signed-in server",
46 publishHost: (signedInHost) => signedInHost ?? "your server",46 publishHost: (signedInHost) => signedInHost ?? "your server",
47 newRepositoryOwner: (account) => (account ? { owner: account } : null),
47 defaultCloneTransport: "https",48 defaultCloneTransport: "https",
48 // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the49 // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the
49 // origin, which may carry a port. SSH remotes say nothing about the web origin.50 // origin, which may carry a port. SSH remotes say nothing about the web origin.
packages/source-control-gitcafe/src/client/definition.ts+1
...@@ -28,6 +28,7 @@ export const definition = defineSourceControlClient({...@@ -28,6 +28,7 @@ export const definition = defineSourceControlClient({
28 publicHost: null,28 publicHost: null,
29 publishDescription: "git.cafe",29 publishDescription: "git.cafe",
30 publishHost: () => "git.cafe",30 publishHost: () => "git.cafe",
31 newRepositoryOwner: (account) => (account ? { owner: account } : null),
31 defaultCloneTransport: "ssh",32 defaultCloneTransport: "ssh",
32 changeRequestUrl: ({ host, repository, number }) =>33 changeRequestUrl: ({ host, repository, number }) =>
33 `https://${host}/${repository}/pulls/${number}`,34 `https://${host}/${repository}/pulls/${number}`,
packages/source-control-github/src/client/definition.ts+1
...@@ -27,6 +27,7 @@ export const definition = defineSourceControlClient({...@@ -27,6 +27,7 @@ export const definition = defineSourceControlClient({
27 publicHost: "github.com",27 publicHost: "github.com",
28 publishDescription: "github.com",28 publishDescription: "github.com",
29 publishHost: () => "github.com",29 publishHost: () => "github.com",
30 newRepositoryOwner: (account) => ({ owner: account }),
30 defaultCloneTransport: "https",31 defaultCloneTransport: "https",
31 changeRequestUrl: ({ host, repository, number }) =>32 changeRequestUrl: ({ host, repository, number }) =>
32 `https://${host}/${repository}/pull/${number}`,33 `https://${host}/${repository}/pull/${number}`,
packages/source-control-gitlab/src/client/definition.ts+1
...@@ -27,6 +27,7 @@ export const definition = defineSourceControlClient({...@@ -27,6 +27,7 @@ export const definition = defineSourceControlClient({
27 publicHost: "gitlab.com",27 publicHost: "gitlab.com",
28 publishDescription: "gitlab.com",28 publishDescription: "gitlab.com",
29 publishHost: () => "gitlab.com",29 publishHost: () => "gitlab.com",
30 newRepositoryOwner: (account) => ({ owner: account }),
30 defaultCloneTransport: "ssh",31 defaultCloneTransport: "ssh",
31 changeRequestUrl: ({ host, repository, number }) =>32 changeRequestUrl: ({ host, repository, number }) =>
32 `https://${host}/${repository}/-/merge_requests/${number}`,33 `https://${host}/${repository}/-/merge_requests/${number}`,