| author | |
| committer | |
| log | a4c8745abee3f73f960c914800974dee530dbc3b |
| tree | f1ada9ba9f459c155b6b3ccaf9dd67f7215ebd0c |
| parent | 9b40d3746388e573370488f73b50e1e869b79a45 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 15 | getCloneDestinationPath, |
| 16 | 16 | getCloneDirectoryName, |
| 17 | 17 | getDefaultCloneUrl, |
| 18 | getNewProjectGitHubRepository, | |
| 19 | getNewProjectGitHubTarget, | |
| 20 | 18 | getNewProjectPathPreview, |
| 19 | getNewProjectPublishTarget, | |
| 20 | getNewProjectPublishTargets, | |
| 21 | getNewProjectRepository, | |
| 21 | 22 | normalizePastedCloneUrl, |
| 22 | 23 | parseAddProjectRemoteSource, |
| 23 | 24 | resolveAddProjectPath, |
| ... | ... | @@ -995,9 +996,12 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s |
| 995 | 996 | input: {}, |
| 996 | 997 | }), |
| 997 | 998 | ); |
| 998 | const githubTarget = getNewProjectGitHubTarget(discoveryState.data); | |
| 999 | const publishTargets = getNewProjectPublishTargets(discoveryState.data); | |
| 999 | 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 | 1005 | const [isSubmitting, setIsSubmitting] = useState(false); |
| 1002 | 1006 | const [error, setError] = useState<string | null>(null); |
| 1003 | 1007 | const trimmedName = name.trim(); |
| ... | ... | @@ -1066,19 +1070,19 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s |
| 1066 | 1070 | if (commitError !== undefined) { |
| 1067 | 1071 | Alert.alert("Created without a first commit", commitError); |
| 1068 | 1072 | } |
| 1069 | if (publishesToGitHub && githubTarget !== null) { | |
| 1073 | if (publishes && publishTarget !== null) { | |
| 1070 | 1074 | void publishRepository({ |
| 1071 | 1075 | environmentId: environment.environmentId, |
| 1072 | 1076 | input: { |
| 1073 | 1077 | cwd: workspaceRoot, |
| 1074 | provider: SourceControlProviderKind.make("github"), | |
| 1075 | repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot), | |
| 1078 | provider: publishTarget.definition.kind, | |
| 1079 | repository: getNewProjectRepository(publishTarget, workspaceRoot), | |
| 1076 | 1080 | visibility: "private", |
| 1077 | 1081 | }, |
| 1078 | 1082 | }).then((publishResult) => { |
| 1079 | 1083 | if (AsyncResult.isFailure(publishResult)) { |
| 1080 | 1084 | Alert.alert( |
| 1081 | "Could not create the GitHub repository", | |
| 1085 | `Could not create the ${publishTarget.definition.label} repository`, | |
| 1082 | 1086 | errorMessage(Cause.squash(publishResult.cause)), |
| 1083 | 1087 | ); |
| 1084 | 1088 | } |
| ... | ... | @@ -1133,18 +1137,18 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s |
| 1133 | 1137 | </Text> |
| 1134 | 1138 | ) : null} |
| 1135 | 1139 | {machineRows} |
| 1136 | {githubTarget !== null ? ( | |
| 1140 | {publishTarget !== null ? ( | |
| 1137 | 1141 | <ListSection> |
| 1138 | 1142 | <ListRow |
| 1139 | title="Create private repository on GitHub" | |
| 1143 | title={`Create private repository on ${publishTarget.definition.label}`} | |
| 1140 | 1144 | subtitle={ |
| 1141 | 1145 | trimmedName.length > 0 && pathPreview !== null |
| 1142 | ? getNewProjectGitHubRepository(githubTarget, pathPreview) | |
| 1143 | : githubTarget.account | |
| 1146 | ? getNewProjectRepository(publishTarget, pathPreview) | |
| 1147 | : publishTarget.owner | |
| 1144 | 1148 | } |
| 1145 | 1149 | icon={ |
| 1146 | 1150 | <SourceControlIcon |
| 1147 | kind={sourceControlClients.get("github").icon} | |
| 1151 | kind={publishTarget.definition.icon} | |
| 1148 | 1152 | size={Platform.OS === "android" ? 24 : 18} |
| 1149 | 1153 | colorClassName="accent-icon" |
| 1150 | 1154 | /> |
| ... | ... | @@ -1152,13 +1156,44 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s |
| 1152 | 1156 | isFirst |
| 1153 | 1157 | right={ |
| 1154 | 1158 | <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} | |
| 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 | 1197 | </ListSection> |
| 1163 | 1198 | ) : null} |
| 1164 | 1199 | <PrimaryActionButton |
apps/web/src/components/CommandPalette.tsx+97-19| ... | ... | @@ -10,9 +10,11 @@ import { |
| 10 | 10 | getCloneDestinationPath, |
| 11 | 11 | getCloneDirectoryName, |
| 12 | 12 | getDefaultCloneUrl, |
| 13 | getNewProjectGitHubRepository, | |
| 14 | getNewProjectGitHubTarget, | |
| 15 | 13 | getNewProjectPathPreview, |
| 14 | getNewProjectPublishTarget, | |
| 15 | getNewProjectPublishTargets, | |
| 16 | getNewProjectRepository, | |
| 17 | type NewProjectPublishTarget, | |
| 16 | 18 | normalizePastedCloneUrl, |
| 17 | 19 | addProjectRemoteSourceLabel, |
| 18 | 20 | addProjectRemoteSourcePathHint, |
| ... | ... | @@ -195,7 +197,6 @@ import { |
| 195 | 197 | CommandPaletteVirtualizedResults, |
| 196 | 198 | scrollCommandPaletteRowIntoView, |
| 197 | 199 | } from "./CommandPaletteResults"; |
| 198 | import { GitHubIcon } from "./Icons"; | |
| 199 | 200 | import { sourceControlIcon } from "~/sourceControlPresentation"; |
| 200 | 201 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 201 | 202 | import { Checkbox } from "./ui/checkbox"; |
| ... | ... | @@ -221,6 +222,7 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin |
| 221 | 222 | import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; |
| 222 | 223 | import { Button } from "./ui/button"; |
| 223 | 224 | import { Kbd, KbdGroup } from "./ui/kbd"; |
| 225 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | |
| 224 | 226 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 225 | 227 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 226 | 228 | import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; |
| ... | ... | @@ -280,6 +282,14 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri |
| 280 | 282 | return <Icon className={className} />; |
| 281 | 283 | } |
| 282 | 284 | |
| 285 | function sourceControlHostIcon( | |
| 286 | definition: NewProjectPublishTarget["definition"], | |
| 287 | className: string, | |
| 288 | ): ReactNode { | |
| 289 | const Icon = sourceControlIcon(definition); | |
| 290 | return <Icon className={className} />; | |
| 291 | } | |
| 292 | ||
| 283 | 293 | function projectFaviconIcon(project: Project): ReactNode { |
| 284 | 294 | return <ProjectFavicon project={project} className={ITEM_ICON_CLASS} />; |
| 285 | 295 | } |
| ... | ... | @@ -762,7 +772,11 @@ function OpenCommandPaletteDialog(props: { |
| 762 | 772 | /** Machine of the Add project sources view under this step; null from the palette root. */ |
| 763 | 773 | readonly sourcesEnvironmentId: EnvironmentId | null; |
| 764 | 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 | 780 | const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); |
| 767 | 781 | // State lags a render behind, so a repeated Enter could start a second create. |
| 768 | 782 | const newProjectSubmittingRef = useRef(false); |
| ... | ... | @@ -1482,7 +1496,8 @@ function OpenCommandPaletteDialog(props: { |
| 1482 | 1496 | setAddProjectEnvironmentId(environmentId); |
| 1483 | 1497 | setAddProjectCloneFlow(null); |
| 1484 | 1498 | setNewProjectFlow({ environmentId, sourcesEnvironmentId }); |
| 1485 | setNewProjectPublishesToGitHub(false); | |
| 1499 | setNewProjectPublishes(false); | |
| 1500 | setNewProjectPublishKind(null); | |
| 1486 | 1501 | pushPaletteView({ |
| 1487 | 1502 | addonIcon: <FolderGit2Icon className={ADDON_ICON_CLASS} />, |
| 1488 | 1503 | groups: [], |
| ... | ... | @@ -2417,8 +2432,12 @@ function OpenCommandPaletteDialog(props: { |
| 2417 | 2432 | ], |
| 2418 | 2433 | ); |
| 2419 | 2434 | |
| 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 | ); | |
| 2422 | 2441 | const newProjectName = query.trim(); |
| 2423 | 2442 | const canSubmitNewProject = |
| 2424 | 2443 | newProjectFlow !== null && |
| ... | ... | @@ -2436,7 +2455,7 @@ function OpenCommandPaletteDialog(props: { |
| 2436 | 2455 | const created = await createNewProject({ |
| 2437 | 2456 | environmentId: newProjectFlow.environmentId, |
| 2438 | 2457 | name: newProjectName, |
| 2439 | github: newProjectPublishesToGitHub ? newProjectGitHubTarget : null, | |
| 2458 | publishTo: newProjectPublishes ? newProjectPublishTarget : null, | |
| 2440 | 2459 | }); |
| 2441 | 2460 | if (created) setOpen(false); |
| 2442 | 2461 | } finally { |
| ... | ... | @@ -2742,7 +2761,7 @@ function OpenCommandPaletteDialog(props: { |
| 2742 | 2761 | const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; |
| 2743 | 2762 | const newProjectPathPreview = |
| 2744 | 2763 | newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); |
| 2745 | const newProjectGitHubToggleValue = "new-project:github"; | |
| 2764 | const newProjectPublishToggleValue = "new-project:publish"; | |
| 2746 | 2765 | // The name step's way out to folders and clones, for the selected machine. |
| 2747 | 2766 | // It replaces the name step (and a sources view for another machine under |
| 2748 | 2767 | // it), so Back returns to wherever New project was opened from. |
| ... | ... | @@ -2829,7 +2848,7 @@ function OpenCommandPaletteDialog(props: { |
| 2829 | 2848 | ], |
| 2830 | 2849 | }; |
| 2831 | 2850 | const newProjectOptionGroups: CommandPaletteView["groups"] = |
| 2832 | newProjectGitHubTarget === null || newProjectPathPreview === null | |
| 2851 | newProjectPublishTarget === null || newProjectPathPreview === null | |
| 2833 | 2852 | ? [] |
| 2834 | 2853 | : [ |
| 2835 | 2854 | { |
| ... | ... | @@ -2838,22 +2857,81 @@ function OpenCommandPaletteDialog(props: { |
| 2838 | 2857 | items: [ |
| 2839 | 2858 | { |
| 2840 | 2859 | kind: "action", |
| 2841 | value: newProjectGitHubToggleValue, | |
| 2860 | value: newProjectPublishToggleValue, | |
| 2842 | 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 | 2866 | description: |
| 2845 | 2867 | 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), | |
| 2849 | 2872 | 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> | |
| 2852 | 2928 | </span> |
| 2853 | 2929 | ), |
| 2930 | // The create in flight keeps the choice it started with. | |
| 2931 | ...(isCreatingNewProject ? { disabled: true } : {}), | |
| 2854 | 2932 | keepOpen: true, |
| 2855 | 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 | 3405 | newProjectFlow !== null |
| 3328 | 3406 | ? highlightedItemValue === null |
| 3329 | 3407 | ? "Create" |
| 3330 | : highlightedItemValue === newProjectGitHubToggleValue | |
| 3408 | : highlightedItemValue === newProjectPublishToggleValue | |
| 3331 | 3409 | ? "Toggle" |
| 3332 | 3410 | : "Select" |
| 3333 | 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 | 14 | data-slot="checkbox" |
| 15 | 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 | 19 | <CheckboxPrimitive.Indicator |
| 19 | 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 | 21 | data-slot="checkbox-indicator" |
| ... | ... | @@ -22,7 +23,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { |
| 22 | 23 | <span {...props}> |
| 23 | 24 | {state.indeterminate ? ( |
| 24 | 25 | <svg |
| 25 | className="size-3.5 sm:size-3" | |
| 26 | className="size-3.5 text-current sm:size-3" | |
| 26 | 27 | fill="none" |
| 27 | 28 | height="24" |
| 28 | 29 | stroke="currentColor" |
| ... | ... | @@ -37,7 +38,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { |
| 37 | 38 | </svg> |
| 38 | 39 | ) : ( |
| 39 | 40 | <svg |
| 40 | className="size-3.5 sm:size-3" | |
| 41 | className="size-3.5 text-current sm:size-3" | |
| 41 | 42 | fill="none" |
| 42 | 43 | height="24" |
| 43 | 44 | stroke="currentColor" |
apps/web/src/hooks/useNewProject.ts+18-15| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; | |
| 2 | 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 3 | import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects"; | |
| 2 | import { | |
| 3 | getNewProjectRepository, | |
| 4 | type NewProjectPublishTarget, | |
| 5 | } from "@t3tools/client-runtime/operations/projects"; | |
| 4 | 6 | import { |
| 5 | 7 | isAtomCommandInterrupted, |
| 6 | 8 | squashAtomCommandFailure, |
| ... | ... | @@ -24,8 +26,8 @@ function errorMessage(error: unknown): string { |
| 24 | 26 | /** |
| 25 | 27 | * Starts a project from just a name. The server makes a folder under its |
| 26 | 28 | * `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. | |
| 29 | 31 | * |
| 30 | 32 | * Resolves to whether the project was created. |
| 31 | 33 | */ |
| ... | ... | @@ -36,18 +38,19 @@ export function useNewProject() { |
| 36 | 38 | }); |
| 37 | 39 | const handleNewThread = useNewThreadHandler(); |
| 38 | 40 | |
| 39 | const publishToGitHub = useCallback( | |
| 41 | const publishNewRepository = useCallback( | |
| 40 | 42 | async (input: { |
| 41 | 43 | readonly environmentId: EnvironmentId; |
| 42 | 44 | readonly workspaceRoot: string; |
| 43 | readonly account: string | null; | |
| 45 | readonly target: NewProjectPublishTarget; | |
| 44 | 46 | }) => { |
| 47 | const { label } = input.target.definition; | |
| 45 | 48 | const result = await publishRepository({ |
| 46 | 49 | environmentId: input.environmentId, |
| 47 | 50 | input: { |
| 48 | 51 | 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), | |
| 51 | 54 | visibility: "private", |
| 52 | 55 | }, |
| 53 | 56 | }); |
| ... | ... | @@ -56,7 +59,7 @@ export function useNewProject() { |
| 56 | 59 | toastManager.add( |
| 57 | 60 | stackedThreadToast({ |
| 58 | 61 | type: "error", |
| 59 | title: "Could not create the GitHub repository", | |
| 62 | title: `Could not create the ${label} repository`, | |
| 60 | 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 | 69 | toastManager.add( |
| 67 | 70 | stackedThreadToast({ |
| 68 | 71 | type: "success", |
| 69 | title: "Published to GitHub", | |
| 72 | title: `Published to ${label}`, | |
| 70 | 73 | description: result.value.repository.nameWithOwner, |
| 71 | 74 | }), |
| 72 | 75 | ); |
| ... | ... | @@ -78,7 +81,7 @@ export function useNewProject() { |
| 78 | 81 | async (input: { |
| 79 | 82 | readonly environmentId: EnvironmentId; |
| 80 | 83 | readonly name: string; |
| 81 | readonly github: { readonly account: string | null } | null; | |
| 84 | readonly publishTo: NewProjectPublishTarget | null; | |
| 82 | 85 | }): Promise<boolean> => { |
| 83 | 86 | const result = await createNew({ |
| 84 | 87 | environmentId: input.environmentId, |
| ... | ... | @@ -110,11 +113,11 @@ export function useNewProject() { |
| 110 | 113 | }, |
| 111 | 114 | ), |
| 112 | 115 | ); |
| 113 | if (input.github) { | |
| 114 | void publishToGitHub({ | |
| 116 | if (input.publishTo) { | |
| 117 | void publishNewRepository({ | |
| 115 | 118 | environmentId: input.environmentId, |
| 116 | 119 | workspaceRoot, |
| 117 | account: input.github.account, | |
| 120 | target: input.publishTo, | |
| 118 | 121 | }); |
| 119 | 122 | } |
| 120 | 123 | |
| ... | ... | @@ -143,6 +146,6 @@ export function useNewProject() { |
| 143 | 146 | }); |
| 144 | 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 | 18 | getCloneDestinationPath, |
| 19 | 19 | getCloneDirectoryName, |
| 20 | 20 | getDefaultCloneUrl, |
| 21 | getNewProjectPublishTarget, | |
| 22 | getNewProjectPublishTargets, | |
| 21 | 23 | normalizePastedCloneUrl, |
| 22 | 24 | resolveAddProjectPath, |
| 23 | 25 | sortAddProjectProviderSources, |
| ... | ... | @@ -279,3 +281,36 @@ describe("add project shared logic", () => { |
| 279 | 281 | }); |
| 280 | 282 | }); |
| 281 | 283 | }); |
| 284 | ||
| 285 | describe("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 | 25 | } from "../state/projects.ts"; |
| 26 | 26 | import type { EnvironmentProject } from "../state/models.ts"; |
| 27 | 27 | import { sourceControlClients } from "../sourceControlClients.ts"; |
| 28 | import type { SourceControlClientDefinition } from "../sourceControlClients.ts"; | |
| 28 | 29 | |
| 29 | 30 | /** A host to clone from, or `url` for a pasted clone URL. */ |
| 30 | 31 | export type AddProjectRemoteSource = SourceControlProviderKind | "url"; |
| ... | ... | @@ -225,27 +226,49 @@ export function getNewProjectPathPreview(newProjectsRoot: string, name: string): |
| 225 | 226 | return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name)); |
| 226 | 227 | } |
| 227 | 228 | |
| 229 | /** A host a new project can be published to, and who the repository goes under there. */ | |
| 230 | export 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 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. | |
| 232 | 240 | */ |
| 233 | export function getNewProjectGitHubTarget( | |
| 241 | export function getNewProjectPublishTargets( | |
| 234 | 242 | 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. */ | |
| 258 | export 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 | } |
| 241 | 264 | |
| 242 | /** `owner/folder` for publishing a new project, or just the folder for `gh` to place. */ | |
| 243 | export 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. */ | |
| 266 | export function getNewProjectRepository( | |
| 267 | target: Pick<NewProjectPublishTarget, "owner">, | |
| 245 | 268 | workspaceRoot: string, |
| 246 | 269 | ): string { |
| 247 | 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 | } |
| 250 | 273 | |
| 251 | 274 | /** |
packages/source-control-azure-devops/src/client/definition.ts+2| ... | ... | @@ -37,6 +37,8 @@ export const definition = defineSourceControlClient({ |
| 37 | 37 | publicHost: null, |
| 38 | 38 | publishDescription: "dev.azure.com", |
| 39 | 39 | publishHost: () => "dev.azure.com", |
| 40 | // A new repository belongs to a project, which the account does not name. | |
| 41 | newRepositoryOwner: () => null, | |
| 40 | 42 | defaultCloneTransport: "ssh", |
| 41 | 43 | changeRequestUrl: ({ host, repository, number }) => |
| 42 | 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 | 26 | publicHost: "bitbucket.org", |
| 27 | 27 | publishDescription: "bitbucket.org", |
| 28 | 28 | publishHost: () => "bitbucket.org", |
| 29 | // A new repository belongs to a workspace, which the signed-in account does not name. | |
| 30 | newRepositoryOwner: () => null, | |
| 29 | 31 | defaultCloneTransport: "ssh", |
| 30 | 32 | changeRequestUrl: ({ host, repository, number }) => |
| 31 | 33 | `https://${host}/${repository}/pull-requests/${number}`, |
packages/source-control-core/src/client/definition.ts+7| ... | ... | @@ -74,6 +74,12 @@ export interface SourceControlClientDefinition { |
| 74 | 74 | readonly publishDescription: string; |
| 75 | 75 | /** Where a publish lands, given the host the server is signed in to, if it reported one. */ |
| 76 | 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 | 83 | /** Which of a repository's clone URLs a new clone uses. */ |
| 78 | 84 | readonly defaultCloneTransport: "https" | "ssh"; |
| 79 | 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 | 230 | publicHost: null, |
| 225 | 231 | publishDescription: "Your signed-in server", |
| 226 | 232 | publishHost: (signedInHost) => signedInHost ?? "your server", |
| 233 | newRepositoryOwner: () => null, | |
| 227 | 234 | defaultCloneTransport: "ssh", |
| 228 | 235 | changeRequestUrl: () => null, |
| 229 | 236 | changeRequestActions: new Set(), |
packages/source-control-forgejo/src/client/definition.ts+1| ... | ... | @@ -44,6 +44,7 @@ export const definition = defineSourceControlClient({ |
| 44 | 44 | publicHost: null, |
| 45 | 45 | publishDescription: "Your signed-in server", |
| 46 | 46 | publishHost: (signedInHost) => signedInHost ?? "your server", |
| 47 | newRepositoryOwner: (account) => (account ? { owner: account } : null), | |
| 47 | 48 | defaultCloneTransport: "https", |
| 48 | 49 | // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the |
| 49 | 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 | 28 | publicHost: null, |
| 29 | 29 | publishDescription: "git.cafe", |
| 30 | 30 | publishHost: () => "git.cafe", |
| 31 | newRepositoryOwner: (account) => (account ? { owner: account } : null), | |
| 31 | 32 | defaultCloneTransport: "ssh", |
| 32 | 33 | changeRequestUrl: ({ host, repository, number }) => |
| 33 | 34 | `https://${host}/${repository}/pulls/${number}`, |
packages/source-control-github/src/client/definition.ts+1| ... | ... | @@ -27,6 +27,7 @@ export const definition = defineSourceControlClient({ |
| 27 | 27 | publicHost: "github.com", |
| 28 | 28 | publishDescription: "github.com", |
| 29 | 29 | publishHost: () => "github.com", |
| 30 | newRepositoryOwner: (account) => ({ owner: account }), | |
| 30 | 31 | defaultCloneTransport: "https", |
| 31 | 32 | changeRequestUrl: ({ host, repository, number }) => |
| 32 | 33 | `https://${host}/${repository}/pull/${number}`, |
packages/source-control-gitlab/src/client/definition.ts+1| ... | ... | @@ -27,6 +27,7 @@ export const definition = defineSourceControlClient({ |
| 27 | 27 | publicHost: "gitlab.com", |
| 28 | 28 | publishDescription: "gitlab.com", |
| 29 | 29 | publishHost: () => "gitlab.com", |
| 30 | newRepositoryOwner: (account) => ({ owner: account }), | |
| 30 | 31 | defaultCloneTransport: "ssh", |
| 31 | 32 | changeRequestUrl: ({ host, repository, number }) => |
| 32 | 33 | `https://${host}/${repository}/-/merge_requests/${number}`, |