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 {
1515 getCloneDestinationPath,
1616 getCloneDirectoryName,
1717 getDefaultCloneUrl,
18 getNewProjectGitHubRepository,
19 getNewProjectGitHubTarget,
2018 getNewProjectPathPreview,
19 getNewProjectPublishTarget,
20 getNewProjectPublishTargets,
21 getNewProjectRepository,
2122 normalizePastedCloneUrl,
2223 parseAddProjectRemoteSource,
2324 resolveAddProjectPath,
......@@ -995,9 +996,12 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
995996 input: {},
996997 }),
997998 );
998 const githubTarget = getNewProjectGitHubTarget(discoveryState.data);
999 const publishTargets = getNewProjectPublishTargets(discoveryState.data);
9991000 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);
10011005 const [isSubmitting, setIsSubmitting] = useState(false);
10021006 const [error, setError] = useState<string | null>(null);
10031007 const trimmedName = name.trim();
......@@ -1066,19 +1070,19 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
10661070 if (commitError !== undefined) {
10671071 Alert.alert("Created without a first commit", commitError);
10681072 }
1069 if (publishesToGitHub && githubTarget !== null) {
1073 if (publishes && publishTarget !== null) {
10701074 void publishRepository({
10711075 environmentId: environment.environmentId,
10721076 input: {
10731077 cwd: workspaceRoot,
1074 provider: SourceControlProviderKind.make("github"),
1075 repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot),
1078 provider: publishTarget.definition.kind,
1079 repository: getNewProjectRepository(publishTarget, workspaceRoot),
10761080 visibility: "private",
10771081 },
10781082 }).then((publishResult) => {
10791083 if (AsyncResult.isFailure(publishResult)) {
10801084 Alert.alert(
1081 "Could not create the GitHub repository",
1085 `Could not create the ${publishTarget.definition.label} repository`,
10821086 errorMessage(Cause.squash(publishResult.cause)),
10831087 );
10841088 }
......@@ -1133,18 +1137,18 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
11331137 </Text>
11341138 ) : null}
11351139 {machineRows}
1136 {githubTarget !== null ? (
1140 {publishTarget !== null ? (
11371141 <ListSection>
11381142 <ListRow
1139 title="Create private repository on GitHub"
1143 title={`Create private repository on ${publishTarget.definition.label}`}
11401144 subtitle={
11411145 trimmedName.length > 0 && pathPreview !== null
1142 ? getNewProjectGitHubRepository(githubTarget, pathPreview)
1143 : githubTarget.account
1146 ? getNewProjectRepository(publishTarget, pathPreview)
1147 : publishTarget.owner
11441148 }
11451149 icon={
11461150 <SourceControlIcon
1147 kind={sourceControlClients.get("github").icon}
1151 kind={publishTarget.definition.icon}
11481152 size={Platform.OS === "android" ? 24 : 18}
11491153 colorClassName="accent-icon"
11501154 />
......@@ -1152,13 +1156,44 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
11521156 isFirst
11531157 right={
11541158 <ThemedSwitch
1155 accessibilityLabel="Create private repository on GitHub"
1156 value={publishesToGitHub}
1157 onValueChange={setPublishesToGitHub}
1159 accessibilityLabel={`Create private repository on ${publishTarget.definition.label}`}
1160 value={publishes}
1161 onValueChange={setPublishes}
11581162 />
11591163 }
1160 onPress={() => setPublishesToGitHub((publishes) => !publishes)}
1164 onPress={() => setPublishes((current) => !current)}
11611165 />
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}
11621197 </ListSection>
11631198 ) : null}
11641199 <PrimaryActionButton
apps/web/src/components/CommandPalette.tsx+97-19
......@@ -10,9 +10,11 @@ import {
1010 getCloneDestinationPath,
1111 getCloneDirectoryName,
1212 getDefaultCloneUrl,
13 getNewProjectGitHubRepository,
14 getNewProjectGitHubTarget,
1513 getNewProjectPathPreview,
14 getNewProjectPublishTarget,
15 getNewProjectPublishTargets,
16 getNewProjectRepository,
17 type NewProjectPublishTarget,
1618 normalizePastedCloneUrl,
1719 addProjectRemoteSourceLabel,
1820 addProjectRemoteSourcePathHint,
......@@ -195,7 +197,6 @@ import {
195197 CommandPaletteVirtualizedResults,
196198 scrollCommandPaletteRowIntoView,
197199} from "./CommandPaletteResults";
198import { GitHubIcon } from "./Icons";
199200import { sourceControlIcon } from "~/sourceControlPresentation";
200201import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
201202import { Checkbox } from "./ui/checkbox";
......@@ -221,6 +222,7 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin
221222import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
222223import { Button } from "./ui/button";
223224import { Kbd, KbdGroup } from "./ui/kbd";
225import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
224226import { stackedThreadToast, toastManager } from "./ui/toast";
225227import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
226228import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
......@@ -280,6 +282,14 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
280282 return <Icon className={className} />;
281283}
282284
285function sourceControlHostIcon(
286 definition: NewProjectPublishTarget["definition"],
287 className: string,
288): ReactNode {
289 const Icon = sourceControlIcon(definition);
290 return <Icon className={className} />;
291}
292
283293function projectFaviconIcon(project: Project): ReactNode {
284294 return <ProjectFavicon project={project} className={ITEM_ICON_CLASS} />;
285295}
......@@ -762,7 +772,11 @@ function OpenCommandPaletteDialog(props: {
762772 /** Machine of the Add project sources view under this step; null from the palette root. */
763773 readonly sourcesEnvironmentId: EnvironmentId | null;
764774 } | 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);
766780 const [isCreatingNewProject, setIsCreatingNewProject] = useState(false);
767781 // State lags a render behind, so a repeated Enter could start a second create.
768782 const newProjectSubmittingRef = useRef(false);
......@@ -1482,7 +1496,8 @@ function OpenCommandPaletteDialog(props: {
14821496 setAddProjectEnvironmentId(environmentId);
14831497 setAddProjectCloneFlow(null);
14841498 setNewProjectFlow({ environmentId, sourcesEnvironmentId });
1485 setNewProjectPublishesToGitHub(false);
1499 setNewProjectPublishes(false);
1500 setNewProjectPublishKind(null);
14861501 pushPaletteView({
14871502 addonIcon: <FolderGit2Icon className={ADDON_ICON_CLASS} />,
14881503 groups: [],
......@@ -2417,8 +2432,12 @@ function OpenCommandPaletteDialog(props: {
24172432 ],
24182433 );
24192434
2420 const newProjectGitHubTarget =
2421 newProjectFlow === null ? null : getNewProjectGitHubTarget(sourceControlDiscovery.data ?? null);
2435 const newProjectPublishTargets =
2436 newProjectFlow === null ? [] : getNewProjectPublishTargets(sourceControlDiscovery.data ?? null);
2437 const newProjectPublishTarget = getNewProjectPublishTarget(
2438 newProjectPublishTargets,
2439 newProjectPublishKind,
2440 );
24222441 const newProjectName = query.trim();
24232442 const canSubmitNewProject =
24242443 newProjectFlow !== null &&
......@@ -2436,7 +2455,7 @@ function OpenCommandPaletteDialog(props: {
24362455 const created = await createNewProject({
24372456 environmentId: newProjectFlow.environmentId,
24382457 name: newProjectName,
2439 github: newProjectPublishesToGitHub ? newProjectGitHubTarget : null,
2458 publishTo: newProjectPublishes ? newProjectPublishTarget : null,
24402459 });
24412460 if (created) setOpen(false);
24422461 } finally {
......@@ -2742,7 +2761,7 @@ function OpenCommandPaletteDialog(props: {
27422761 const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null;
27432762 const newProjectPathPreview =
27442763 newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName);
2745 const newProjectGitHubToggleValue = "new-project:github";
2764 const newProjectPublishToggleValue = "new-project:publish";
27462765 // The name step's way out to folders and clones, for the selected machine.
27472766 // It replaces the name step (and a sources view for another machine under
27482767 // it), so Back returns to wherever New project was opened from.
......@@ -2829,7 +2848,7 @@ function OpenCommandPaletteDialog(props: {
28292848 ],
28302849 };
28312850 const newProjectOptionGroups: CommandPaletteView["groups"] =
2832 newProjectGitHubTarget === null || newProjectPathPreview === null
2851 newProjectPublishTarget === null || newProjectPathPreview === null
28332852 ? []
28342853 : [
28352854 {
......@@ -2838,22 +2857,81 @@ function OpenCommandPaletteDialog(props: {
28382857 items: [
28392858 {
28402859 kind: "action",
2841 value: newProjectGitHubToggleValue,
2860 value: newProjectPublishToggleValue,
28422861 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}`,
28442866 description:
28452867 newProjectName.length > 0
2846 ? getNewProjectGitHubRepository(newProjectGitHubTarget, newProjectPathPreview)
2847 : (newProjectGitHubTarget.account ?? "Your GitHub account"),
2848 icon: <GitHubIcon className={ITEM_ICON_CLASS} />,
2868 ? getNewProjectRepository(newProjectPublishTarget, newProjectPathPreview)
2869 : (newProjectPublishTarget.owner ??
2870 `Your ${newProjectPublishTarget.definition.label} account`),
2871 icon: sourceControlHostIcon(newProjectPublishTarget.definition, ITEM_ICON_CLASS),
28492872 titleTrailingContent: (
2850 <span className="pointer-events-none ms-auto flex">
2851 <Checkbox checked={newProjectPublishesToGitHub} tabIndex={-1} aria-hidden />
2873 <span className="ms-auto flex items-center gap-2">
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>
28522928 </span>
28532929 ),
2930 // The create in flight keeps the choice it started with.
2931 ...(isCreatingNewProject ? { disabled: true } : {}),
28542932 keepOpen: true,
28552933 run: async () => {
2856 setNewProjectPublishesToGitHub((publishes) => !publishes);
2934 setNewProjectPublishes((publishes) => !publishes);
28572935 },
28582936 },
28592937 ],
......@@ -3327,7 +3405,7 @@ function OpenCommandPaletteDialog(props: {
33273405 newProjectFlow !== null
33283406 ? highlightedItemValue === null
33293407 ? "Create"
3330 : highlightedItemValue === newProjectGitHubToggleValue
3408 : highlightedItemValue === newProjectPublishToggleValue
33313409 ? "Toggle"
33323410 : "Select"
33333411 : addProjectCloneFlow?.step === "repository"
apps/web/src/components/ui/checkbox.tsx+4-3
......@@ -14,7 +14,8 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
1414 data-slot="checkbox"
1515 {...props}
1616 >
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. */}
1819 <CheckboxPrimitive.Indicator
1920 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"
2021 data-slot="checkbox-indicator"
......@@ -22,7 +23,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
2223 <span {...props}>
2324 {state.indeterminate ? (
2425 <svg
25 className="size-3.5 sm:size-3"
26 className="size-3.5 text-current sm:size-3"
2627 fill="none"
2728 height="24"
2829 stroke="currentColor"
......@@ -37,7 +38,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
3738 </svg>
3839 ) : (
3940 <svg
40 className="size-3.5 sm:size-3"
41 className="size-3.5 text-current sm:size-3"
4142 fill="none"
4243 height="24"
4344 stroke="currentColor"
apps/web/src/hooks/useNewProject.ts+18-15
......@@ -1,6 +1,8 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
21import { 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";
46import {
57 isAtomCommandInterrupted,
68 squashAtomCommandFailure,
......@@ -24,8 +26,8 @@ function errorMessage(error: unknown): string {
2426/**
2527 * Starts a project from just a name. The server makes a folder under its
2628 * `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 the
28 * repository as private, without holding up the draft.
29 * opens a new thread draft in it. With `publishTo`, it also publishes the
30 * repository as private on that host, without holding up the draft.
2931 *
3032 * Resolves to whether the project was created.
3133 */
......@@ -36,18 +38,19 @@ export function useNewProject() {
3638 });
3739 const handleNewThread = useNewThreadHandler();
3840
39 const publishToGitHub = useCallback(
41 const publishNewRepository = useCallback(
4042 async (input: {
4143 readonly environmentId: EnvironmentId;
4244 readonly workspaceRoot: string;
43 readonly account: string | null;
45 readonly target: NewProjectPublishTarget;
4446 }) => {
47 const { label } = input.target.definition;
4548 const result = await publishRepository({
4649 environmentId: input.environmentId,
4750 input: {
4851 cwd: input.workspaceRoot,
49 provider: SourceControlProviderKind.make("github"),
50 repository: getNewProjectGitHubRepository(input, input.workspaceRoot),
52 provider: input.target.definition.kind,
53 repository: getNewProjectRepository(input.target, input.workspaceRoot),
5154 visibility: "private",
5255 },
5356 });
......@@ -56,7 +59,7 @@ export function useNewProject() {
5659 toastManager.add(
5760 stackedThreadToast({
5861 type: "error",
59 title: "Could not create the GitHub repository",
62 title: `Could not create the ${label} repository`,
6063 description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`,
6164 }),
6265 );
......@@ -66,7 +69,7 @@ export function useNewProject() {
6669 toastManager.add(
6770 stackedThreadToast({
6871 type: "success",
69 title: "Published to GitHub",
72 title: `Published to ${label}`,
7073 description: result.value.repository.nameWithOwner,
7174 }),
7275 );
......@@ -78,7 +81,7 @@ export function useNewProject() {
7881 async (input: {
7982 readonly environmentId: EnvironmentId;
8083 readonly name: string;
81 readonly github: { readonly account: string | null } | null;
84 readonly publishTo: NewProjectPublishTarget | null;
8285 }): Promise<boolean> => {
8386 const result = await createNew({
8487 environmentId: input.environmentId,
......@@ -110,11 +113,11 @@ export function useNewProject() {
110113 },
111114 ),
112115 );
113 if (input.github) {
114 void publishToGitHub({
116 if (input.publishTo) {
117 void publishNewRepository({
115118 environmentId: input.environmentId,
116119 workspaceRoot,
117 account: input.github.account,
120 target: input.publishTo,
118121 });
119122 }
120123
......@@ -143,6 +146,6 @@ export function useNewProject() {
143146 });
144147 return true;
145148 },
146 [createNew, handleNewThread, publishToGitHub],
149 [createNew, handleNewThread, publishNewRepository],
147150 );
148151}
packages/client-runtime/src/operations/projects.test.ts+35
......@@ -18,6 +18,8 @@ import {
1818 getCloneDestinationPath,
1919 getCloneDirectoryName,
2020 getDefaultCloneUrl,
21 getNewProjectPublishTarget,
22 getNewProjectPublishTargets,
2123 normalizePastedCloneUrl,
2224 resolveAddProjectPath,
2325 sortAddProjectProviderSources,
......@@ -279,3 +281,36 @@ describe("add project shared logic", () => {
279281 });
280282 });
281283});
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 {
2525} from "../state/projects.ts";
2626import type { EnvironmentProject } from "../state/models.ts";
2727import { sourceControlClients } from "../sourceControlClients.ts";
28import type { SourceControlClientDefinition } from "../sourceControlClients.ts";
2829
2930/** A host to clone from, or `url` for a pasted clone URL. */
3031export type AddProjectRemoteSource = SourceControlProviderKind | "url";
......@@ -225,27 +226,49 @@ export function getNewProjectPathPreview(newProjectsRoot: string, name: string):
225226 return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name));
226227}
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
228236/**
229 * The GitHub account a new project would be published under, or null when
230 * GitHub is not ready on that environment. A ready GitHub with an unknown
231 * account still publishes; `gh` picks the signed-in user.
237 * The hosts a new project can be published to on that environment, in definition order so
238 * GitHub leads. A host is offered when it is ready and its account names where the repository
239 * goes.
232240 */
233export function getNewProjectGitHubTarget(
241export function getNewProjectPublishTargets(
234242 discovery: SourceControlDiscoveryResult | null,
235): { readonly account: string | null } | null {
236 const kind = SourceControlProviderKind.make("github");
237 if (!buildAddProjectRemoteSourceReadiness(discovery)(kind).ready) return null;
238 const github = discovery?.sourceControlProviders.find((provider) => provider.kind === kind);
239 return { account: github ? Option.getOrNull(github.auth.account) : null };
243): ReadonlyArray<NewProjectPublishTarget> {
244 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
245 return sourceControlClients.definitions.flatMap((definition) => {
246 if (!readiness(definition.kind).ready) return [];
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;
240263}
241264
242/** `owner/folder` for publishing a new project, or just the folder for `gh` to place. */
243export function getNewProjectGitHubRepository(
244 target: { readonly account: string | null },
265/** `owner/folder` for publishing a new project, or just the folder for the host to place. */
266export function getNewProjectRepository(
267 target: Pick<NewProjectPublishTarget, "owner">,
245268 workspaceRoot: string,
246269): string {
247270 const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? "";
248 return target.account ? `${target.account}/${folderName}` : folderName;
271 return target.owner ? `${target.owner}/${folderName}` : folderName;
249272}
250273
251274/**
packages/source-control-azure-devops/src/client/definition.ts+2
......@@ -37,6 +37,8 @@ export const definition = defineSourceControlClient({
3737 publicHost: null,
3838 publishDescription: "dev.azure.com",
3939 publishHost: () => "dev.azure.com",
40 // A new repository belongs to a project, which the account does not name.
41 newRepositoryOwner: () => null,
4042 defaultCloneTransport: "ssh",
4143 changeRequestUrl: ({ host, repository, number }) =>
4244 `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`,
packages/source-control-bitbucket/src/client/definition.ts+2
......@@ -26,6 +26,8 @@ export const definition = defineSourceControlClient({
2626 publicHost: "bitbucket.org",
2727 publishDescription: "bitbucket.org",
2828 publishHost: () => "bitbucket.org",
29 // A new repository belongs to a workspace, which the signed-in account does not name.
30 newRepositoryOwner: () => null,
2931 defaultCloneTransport: "ssh",
3032 changeRequestUrl: ({ host, repository, number }) =>
3133 `https://${host}/${repository}/pull-requests/${number}`,
packages/source-control-core/src/client/definition.ts+7
......@@ -74,6 +74,12 @@ export interface SourceControlClientDefinition {
7474 readonly publishDescription: string;
7575 /** Where a publish lands, given the host the server is signed in to, if it reported one. */
7676 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;
7783 /** Which of a repository's clone URLs a new clone uses. */
7884 readonly defaultCloneTransport: "https" | "ssh";
7985 /** 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 = {
224230 publicHost: null,
225231 publishDescription: "Your signed-in server",
226232 publishHost: (signedInHost) => signedInHost ?? "your server",
233 newRepositoryOwner: () => null,
227234 defaultCloneTransport: "ssh",
228235 changeRequestUrl: () => null,
229236 changeRequestActions: new Set(),
packages/source-control-forgejo/src/client/definition.ts+1
......@@ -44,6 +44,7 @@ export const definition = defineSourceControlClient({
4444 publicHost: null,
4545 publishDescription: "Your signed-in server",
4646 publishHost: (signedInHost) => signedInHost ?? "your server",
47 newRepositoryOwner: (account) => (account ? { owner: account } : null),
4748 defaultCloneTransport: "https",
4849 // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the
4950 // 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({
2828 publicHost: null,
2929 publishDescription: "git.cafe",
3030 publishHost: () => "git.cafe",
31 newRepositoryOwner: (account) => (account ? { owner: account } : null),
3132 defaultCloneTransport: "ssh",
3233 changeRequestUrl: ({ host, repository, number }) =>
3334 `https://${host}/${repository}/pulls/${number}`,
packages/source-control-github/src/client/definition.ts+1
......@@ -27,6 +27,7 @@ export const definition = defineSourceControlClient({
2727 publicHost: "github.com",
2828 publishDescription: "github.com",
2929 publishHost: () => "github.com",
30 newRepositoryOwner: (account) => ({ owner: account }),
3031 defaultCloneTransport: "https",
3132 changeRequestUrl: ({ host, repository, number }) =>
3233 `https://${host}/${repository}/pull/${number}`,
packages/source-control-gitlab/src/client/definition.ts+1
......@@ -27,6 +27,7 @@ export const definition = defineSourceControlClient({
2727 publicHost: "gitlab.com",
2828 publishDescription: "gitlab.com",
2929 publishHost: () => "gitlab.com",
30 newRepositoryOwner: (account) => ({ owner: account }),
3031 defaultCloneTransport: "ssh",
3132 changeRequestUrl: ({ host, repository, number }) =>
3233 `https://${host}/${repository}/-/merge_requests/${number}`,