| author | |
| committer | |
| log | 148e6deea046658639aae9fef5b349781cec39d1 |
| tree | 51071be1058f309ae4bc461e80b8ed68a1945a81 |
| parent | 41a8239849d7e6523c08edb363efdeccfb150be5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>21 files changed, 1334 insertions(+), 38 deletions(-)
apps/mobile/src/Stack.tsx+5| ... | @@ -53,6 +53,7 @@ import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewR | ... | @@ -53,6 +53,7 @@ import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewR |
| 53 | import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; | 53 | import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; |
| 54 | import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute"; | 54 | import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute"; |
| 55 | import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; | 55 | import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; |
| 56 | import { AddProjectNewRoute } from "./features/projects/AddProjectNewRoute"; | ||
| 56 | import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; | 57 | import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; |
| 57 | import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; | 58 | import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; |
| 58 | import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; | 59 | import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; |
| ... | @@ -459,6 +460,10 @@ const NewTaskSheetStack = createNativeStackNavigator({ | ... | @@ -459,6 +460,10 @@ const NewTaskSheetStack = createNativeStackNavigator({ |
| 459 | screen: AddProjectLocalRoute, | 460 | screen: AddProjectLocalRoute, |
| 460 | linking: "add-project/local", | 461 | linking: "add-project/local", |
| 461 | }), | 462 | }), |
| 463 | AddProjectNew: createNativeStackScreen({ | ||
| 464 | screen: AddProjectNewRoute, | ||
| 465 | linking: "add-project/new", | ||
| 466 | }), | ||
| 462 | }, | 467 | }, |
| 463 | }); | 468 | }); |
| 464 | 469 |
apps/mobile/src/features/projects/AddProjectNewRoute.tsx created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import type { StaticScreenProps } from "@react-navigation/native"; | ||
| 2 | import { AddProjectNewScreen } from "./AddProjectScreen"; | ||
| 3 | |||
| 4 | type AddProjectNewRouteParams = { | ||
| 5 | readonly environmentId?: string | string[]; | ||
| 6 | }; | ||
| 7 | |||
| 8 | export function AddProjectNewRoute({ | ||
| 9 | route, | ||
| 10 | }: StaticScreenProps<AddProjectNewRouteParams | undefined>) { | ||
| 11 | return <AddProjectNewScreen {...(route.params ?? {})} />; | ||
| 12 | } | ||
apps/mobile/src/features/projects/AddProjectScreen.tsx+260-1| ... | @@ -15,6 +15,9 @@ import { | ... | @@ -15,6 +15,9 @@ import { |
| 15 | getCloneDestinationPath, | 15 | getCloneDestinationPath, |
| 16 | getCloneDirectoryName, | 16 | getCloneDirectoryName, |
| 17 | getDefaultCloneUrl, | 17 | getDefaultCloneUrl, |
| 18 | getNewProjectGitHubRepository, | ||
| 19 | getNewProjectGitHubTarget, | ||
| 20 | getNewProjectPathPreview, | ||
| 18 | normalizePastedCloneUrl, | 21 | normalizePastedCloneUrl, |
| 19 | resolveAddProjectPath, | 22 | resolveAddProjectPath, |
| 20 | sortAddProjectProviderSources, | 23 | sortAddProjectProviderSources, |
| ... | @@ -61,6 +64,7 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App | ... | @@ -61,6 +64,7 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App |
| 61 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | 64 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 62 | import { ErrorBanner } from "../../components/ErrorBanner"; | 65 | import { ErrorBanner } from "../../components/ErrorBanner"; |
| 63 | import { SourceControlIcon } from "../../components/SourceControlIcon"; | 66 | import { SourceControlIcon } from "../../components/SourceControlIcon"; |
| 67 | import { ThemedSwitch } from "../../components/ThemedSwitch"; | ||
| 64 | import { uuidv4 } from "../../lib/uuid"; | 68 | import { uuidv4 } from "../../lib/uuid"; |
| 65 | import { useAtomCommand } from "../../state/use-atom-command"; | 69 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 66 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; | 70 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| ... | @@ -77,6 +81,8 @@ interface EnvironmentOption { | ... | @@ -77,6 +81,8 @@ interface EnvironmentOption { |
| 77 | readonly platform: string; | 81 | readonly platform: string; |
| 78 | readonly machine: EnvironmentMachineKind; | 82 | readonly machine: EnvironmentMachineKind; |
| 79 | readonly baseDirectory: string | null; | 83 | readonly baseDirectory: string | null; |
| 84 | /** Folder for projects started from just a name; null on servers without it. */ | ||
| 85 | readonly newProjectsRoot: string | null; | ||
| 80 | readonly connectionState: EnvironmentConnectionPhase; | 86 | readonly connectionState: EnvironmentConnectionPhase; |
| 81 | readonly connectionError: string | null; | 87 | readonly connectionError: string | null; |
| 82 | readonly connectionErrorTraceId: string | null; | 88 | readonly connectionErrorTraceId: string | null; |
| ... | @@ -399,6 +405,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { | ... | @@ -399,6 +405,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { |
| 399 | platform: platformFromOs(config?.environment.platform.os ?? null), | 405 | platform: platformFromOs(config?.environment.platform.os ?? null), |
| 400 | machine: resolveEnvironmentMachineKind(config ?? null), | 406 | machine: resolveEnvironmentMachineKind(config ?? null), |
| 401 | baseDirectory: config?.settings.addProjectBaseDirectory ?? null, | 407 | baseDirectory: config?.settings.addProjectBaseDirectory ?? null, |
| 408 | newProjectsRoot: config?.newProjectsRoot ?? null, | ||
| 402 | connectionState: runtime?.connectionState ?? "available", | 409 | connectionState: runtime?.connectionState ?? "available", |
| 403 | connectionError: runtime?.connectionError ?? null, | 410 | connectionError: runtime?.connectionError ?? null, |
| 404 | connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, | 411 | connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, |
| ... | @@ -578,6 +585,28 @@ export function AddProjectSourceScreen() { | ... | @@ -578,6 +585,28 @@ export function AddProjectSourceScreen() { |
| 578 | {selectedEnvironment ? ( | 585 | {selectedEnvironment ? ( |
| 579 | <> | 586 | <> |
| 580 | <ListSection> | 587 | <ListSection> |
| 588 | {selectedEnvironment.newProjectsRoot !== null ? ( | ||
| 589 | <ListRow | ||
| 590 | title="New project" | ||
| 591 | subtitle="Start a new Git repository from a name" | ||
| 592 | icon={ | ||
| 593 | <SymbolView | ||
| 594 | name="plus" | ||
| 595 | size={Platform.OS === "android" ? 24 : 17} | ||
| 596 | tintColorClassName="accent-icon" | ||
| 597 | type="monochrome" | ||
| 598 | /> | ||
| 599 | } | ||
| 600 | isFirst | ||
| 601 | onPress={() => | ||
| 602 | navigation.dispatch( | ||
| 603 | StackActions.push("AddProjectNew", { | ||
| 604 | environmentId: selectedEnvironment.environmentId, | ||
| 605 | }), | ||
| 606 | ) | ||
| 607 | } | ||
| 608 | /> | ||
| 609 | ) : null} | ||
| 581 | <ListRow | 610 | <ListRow |
| 582 | title="Local folder" | 611 | title="Local folder" |
| 583 | subtitle="Browse a folder on disk" | 612 | subtitle="Browse a folder on disk" |
| ... | @@ -589,7 +618,7 @@ export function AddProjectSourceScreen() { | ... | @@ -589,7 +618,7 @@ export function AddProjectSourceScreen() { |
| 589 | type="monochrome" | 618 | type="monochrome" |
| 590 | /> | 619 | /> |
| 591 | } | 620 | } |
| 592 | isFirst | 621 | isFirst={selectedEnvironment.newProjectsRoot === null} |
| 593 | onPress={() => | 622 | onPress={() => |
| 594 | navigation.dispatch( | 623 | navigation.dispatch( |
| 595 | StackActions.push("AddProjectLocal", { | 624 | StackActions.push("AddProjectLocal", { |
| ... | @@ -898,6 +927,236 @@ function FolderBrowser(props: { | ... | @@ -898,6 +927,236 @@ function FolderBrowser(props: { |
| 898 | ); | 927 | ); |
| 899 | } | 928 | } |
| 900 | 929 | ||
| 930 | /** | ||
| 931 | * New project: a name, then the server makes the folder, README, icon, and | ||
| 932 | * first commit. Optionally publishes it to GitHub as a private repository. | ||
| 933 | */ | ||
| 934 | export function AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) { | ||
| 935 | const navigation = useNavigation(); | ||
| 936 | // Starts on the machine picked in Add project; the rows below switch it. | ||
| 937 | const environmentOptions = useEnvironmentOptions().filter( | ||
| 938 | (option) => option.newProjectsRoot !== null, | ||
| 939 | ); | ||
| 940 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( | ||
| 941 | () => stringParam(props.environmentId) as EnvironmentId | null, | ||
| 942 | ); | ||
| 943 | const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId); | ||
| 944 | const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); | ||
| 945 | const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { | ||
| 946 | reportFailure: false, | ||
| 947 | }); | ||
| 948 | const discoveryState = useEnvironmentQuery( | ||
| 949 | environment === null | ||
| 950 | ? null | ||
| 951 | : sourceControlEnvironment.discovery({ | ||
| 952 | environmentId: environment.environmentId, | ||
| 953 | input: {}, | ||
| 954 | }), | ||
| 955 | ); | ||
| 956 | const githubTarget = getNewProjectGitHubTarget(discoveryState.data); | ||
| 957 | const [name, setName] = useState(""); | ||
| 958 | const [publishesToGitHub, setPublishesToGitHub] = useState(false); | ||
| 959 | const [isSubmitting, setIsSubmitting] = useState(false); | ||
| 960 | const [error, setError] = useState<string | null>(null); | ||
| 961 | const trimmedName = name.trim(); | ||
| 962 | const pathPreview = | ||
| 963 | environment?.newProjectsRoot != null | ||
| 964 | ? getNewProjectPathPreview(environment.newProjectsRoot, trimmedName) | ||
| 965 | : null; | ||
| 966 | |||
| 967 | // Shown when there is a choice, or when the selected machine went away and | ||
| 968 | // another one can take over. | ||
| 969 | const showMachines = | ||
| 970 | environmentOptions.length > 1 || (environment === null && environmentOptions.length > 0); | ||
| 971 | const machineRows = showMachines ? ( | ||
| 972 | <ListSection> | ||
| 973 | {environmentOptions.map((option, index) => { | ||
| 974 | const selected = option.environmentId === environment?.environmentId; | ||
| 975 | return ( | ||
| 976 | <ListRow | ||
| 977 | key={option.environmentId} | ||
| 978 | title={option.label} | ||
| 979 | icon={ | ||
| 980 | <EnvironmentMachineSymbol | ||
| 981 | kind={option.machine} | ||
| 982 | size={Platform.OS === "android" ? 24 : 17} | ||
| 983 | tintColorClassName="accent-icon" | ||
| 984 | /> | ||
| 985 | } | ||
| 986 | selected={selected} | ||
| 987 | // The create in flight keeps the machine it started on. | ||
| 988 | disabled={isSubmitting} | ||
| 989 | isFirst={index === 0} | ||
| 990 | right={ | ||
| 991 | selected ? ( | ||
| 992 | <SymbolView | ||
| 993 | name="checkmark" | ||
| 994 | size={Platform.OS === "android" ? 20 : 14} | ||
| 995 | tintColorClassName="accent-icon" | ||
| 996 | type="monochrome" | ||
| 997 | /> | ||
| 998 | ) : null | ||
| 999 | } | ||
| 1000 | onPress={() => setSelectedEnvironmentId(option.environmentId)} | ||
| 1001 | /> | ||
| 1002 | ); | ||
| 1003 | })} | ||
| 1004 | </ListSection> | ||
| 1005 | ) : null; | ||
| 1006 | |||
| 1007 | // State lags a render behind, so a double tap could start a second create. | ||
| 1008 | const submittingRef = useRef(false); | ||
| 1009 | const submit = async () => { | ||
| 1010 | if (!environment || trimmedName.length === 0 || submittingRef.current) return; | ||
| 1011 | submittingRef.current = true; | ||
| 1012 | setError(null); | ||
| 1013 | setIsSubmitting(true); | ||
| 1014 | try { | ||
| 1015 | const result = await createNew({ | ||
| 1016 | environmentId: environment.environmentId, | ||
| 1017 | input: { name: trimmedName }, | ||
| 1018 | }); | ||
| 1019 | if (AsyncResult.isFailure(result)) { | ||
| 1020 | setError(errorMessage(Cause.squash(result.cause))); | ||
| 1021 | return; | ||
| 1022 | } | ||
| 1023 | const { projectId, workspaceRoot, commitError } = result.value; | ||
| 1024 | if (commitError !== undefined) { | ||
| 1025 | Alert.alert("Created without a first commit", commitError); | ||
| 1026 | } | ||
| 1027 | if (publishesToGitHub && githubTarget !== null) { | ||
| 1028 | void publishRepository({ | ||
| 1029 | environmentId: environment.environmentId, | ||
| 1030 | input: { | ||
| 1031 | cwd: workspaceRoot, | ||
| 1032 | provider: "github", | ||
| 1033 | repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot), | ||
| 1034 | visibility: "private", | ||
| 1035 | }, | ||
| 1036 | }).then((publishResult) => { | ||
| 1037 | if (AsyncResult.isFailure(publishResult)) { | ||
| 1038 | Alert.alert( | ||
| 1039 | "Could not create the GitHub repository", | ||
| 1040 | errorMessage(Cause.squash(publishResult.cause)), | ||
| 1041 | ); | ||
| 1042 | } | ||
| 1043 | }); | ||
| 1044 | } | ||
| 1045 | // The draft screen resolves its project from the client store, so it | ||
| 1046 | // must not open before the create event has arrived. | ||
| 1047 | const project = await waitForProject( | ||
| 1048 | { environmentId: environment.environmentId, projectId }, | ||
| 1049 | 15_000, | ||
| 1050 | ); | ||
| 1051 | if (project === null) { | ||
| 1052 | // The project exists, so clearing the name keeps Create from making a `-2` copy. | ||
| 1053 | setName(""); | ||
| 1054 | setError( | ||
| 1055 | "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", | ||
| 1056 | ); | ||
| 1057 | return; | ||
| 1058 | } | ||
| 1059 | openNewTaskDraft(navigation, { | ||
| 1060 | environmentId: environment.environmentId, | ||
| 1061 | projectId, | ||
| 1062 | title: trimmedName, | ||
| 1063 | }); | ||
| 1064 | } finally { | ||
| 1065 | submittingRef.current = false; | ||
| 1066 | setIsSubmitting(false); | ||
| 1067 | } | ||
| 1068 | }; | ||
| 1069 | |||
| 1070 | return ( | ||
| 1071 | <AddProjectShell title="New project"> | ||
| 1072 | {error ? <ErrorBanner message={error} /> : null} | ||
| 1073 | {environment ? ( | ||
| 1074 | <> | ||
| 1075 | <TextInput | ||
| 1076 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug" | ||
| 1077 | value={name} | ||
| 1078 | onChangeText={setName} | ||
| 1079 | autoCorrect={false} | ||
| 1080 | autoFocus | ||
| 1081 | placeholder="Project name" | ||
| 1082 | returnKeyType="done" | ||
| 1083 | onSubmitEditing={() => void submit()} | ||
| 1084 | /> | ||
| 1085 | {pathPreview !== null ? ( | ||
| 1086 | <Text className="px-1 text-sm leading-snug text-foreground-muted" numberOfLines={2}> | ||
| 1087 | {trimmedName.length > 0 | ||
| 1088 | ? `Creates ${pathPreview}` | ||
| 1089 | : `Goes in ${environment.newProjectsRoot}`} | ||
| 1090 | {showMachines ? ` on ${environment.label}` : null} | ||
| 1091 | </Text> | ||
| 1092 | ) : null} | ||
| 1093 | {machineRows} | ||
| 1094 | {githubTarget !== null ? ( | ||
| 1095 | <ListSection> | ||
| 1096 | <ListRow | ||
| 1097 | title="Create private repository on GitHub" | ||
| 1098 | subtitle={ | ||
| 1099 | trimmedName.length > 0 && pathPreview !== null | ||
| 1100 | ? getNewProjectGitHubRepository(githubTarget, pathPreview) | ||
| 1101 | : githubTarget.account | ||
| 1102 | } | ||
| 1103 | icon={ | ||
| 1104 | <SourceControlIcon | ||
| 1105 | kind="github" | ||
| 1106 | size={Platform.OS === "android" ? 24 : 18} | ||
| 1107 | colorClassName="accent-icon" | ||
| 1108 | /> | ||
| 1109 | } | ||
| 1110 | isFirst | ||
| 1111 | right={ | ||
| 1112 | <ThemedSwitch | ||
| 1113 | accessibilityLabel="Create private repository on GitHub" | ||
| 1114 | value={publishesToGitHub} | ||
| 1115 | onValueChange={setPublishesToGitHub} | ||
| 1116 | /> | ||
| 1117 | } | ||
| 1118 | onPress={() => setPublishesToGitHub((publishes) => !publishes)} | ||
| 1119 | /> | ||
| 1120 | </ListSection> | ||
| 1121 | ) : null} | ||
| 1122 | <PrimaryActionButton | ||
| 1123 | label="Create project" | ||
| 1124 | disabled={isSubmitting || trimmedName.length === 0} | ||
| 1125 | onPress={() => void submit()} | ||
| 1126 | loading={isSubmitting} | ||
| 1127 | /> | ||
| 1128 | <ListSection> | ||
| 1129 | <ListRow | ||
| 1130 | title="Add existing project" | ||
| 1131 | subtitle="Open a folder or clone a repository" | ||
| 1132 | icon={ | ||
| 1133 | <SymbolView | ||
| 1134 | name="folder.badge.plus" | ||
| 1135 | size={Platform.OS === "android" ? 24 : 17} | ||
| 1136 | tintColorClassName="accent-icon" | ||
| 1137 | type="monochrome" | ||
| 1138 | /> | ||
| 1139 | } | ||
| 1140 | isFirst | ||
| 1141 | // New project opens from Add project, so going back shows the | ||
| 1142 | // other sources. A deep link has nothing behind it. | ||
| 1143 | onPress={() => | ||
| 1144 | navigation.canGoBack() | ||
| 1145 | ? navigation.goBack() | ||
| 1146 | : navigation.dispatch(StackActions.replace("AddProject")) | ||
| 1147 | } | ||
| 1148 | /> | ||
| 1149 | </ListSection> | ||
| 1150 | </> | ||
| 1151 | ) : environmentOptions.length > 0 ? ( | ||
| 1152 | machineRows | ||
| 1153 | ) : ( | ||
| 1154 | <EmptyEnvironmentState /> | ||
| 1155 | )} | ||
| 1156 | </AddProjectShell> | ||
| 1157 | ); | ||
| 1158 | } | ||
| 1159 | |||
| 901 | export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { | 1160 | export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { |
| 902 | const environment = useEnvironmentFromParam(props.environmentId); | 1161 | const environment = useEnvironmentFromParam(props.environmentId); |
| 903 | const createProject = useCreateProject(environment); | 1162 | const createProject = useCreateProject(environment); |
apps/server/src/auth/RpcAuthorization.ts+1| ... | @@ -116,6 +116,7 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -116,6 +116,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 116 | [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, | 116 | [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, |
| 117 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, | 117 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, |
| 118 | [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, | 118 | [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, |
| 119 | [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, | ||
| 119 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, | 120 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, |
| 120 | [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, | 121 | [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, |
| 121 | [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, | 122 | [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, |
apps/server/src/project/NewProject.test.ts created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | |||
| 8 | import * as ServerConfig from "../config.ts"; | ||
| 9 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 10 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 11 | import { createNewProjectFolder } from "./NewProject.ts"; | ||
| 12 | |||
| 13 | const TestLayer = GitVcsDriver.layer.pipe( | ||
| 14 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-new-project-" })), | ||
| 15 | Layer.provideMerge(VcsProcess.layer), | ||
| 16 | Layer.provideMerge(NodeServices.layer), | ||
| 17 | ); | ||
| 18 | |||
| 19 | const GIT_ENV_KEYS = [ | ||
| 20 | "GIT_CONFIG_GLOBAL", | ||
| 21 | "GIT_CONFIG_NOSYSTEM", | ||
| 22 | "GIT_CONFIG_COUNT", | ||
| 23 | "GIT_CONFIG_KEY_0", | ||
| 24 | "GIT_CONFIG_VALUE_0", | ||
| 25 | "GIT_AUTHOR_NAME", | ||
| 26 | "GIT_AUTHOR_EMAIL", | ||
| 27 | "GIT_COMMITTER_NAME", | ||
| 28 | "GIT_COMMITTER_EMAIL", | ||
| 29 | "EMAIL", | ||
| 30 | ] as const; | ||
| 31 | |||
| 32 | // Git reads the developer's own config (signing, default branch, identity) | ||
| 33 | // unless the test replaces it, so each test runs against an empty one. | ||
| 34 | const withGitEnv = <A, E, R>( | ||
| 35 | env: Partial<Record<(typeof GIT_ENV_KEYS)[number], string>>, | ||
| 36 | effect: Effect.Effect<A, E, R>, | ||
| 37 | ) => | ||
| 38 | Effect.gen(function* () { | ||
| 39 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 40 | const emptyConfig = yield* fileSystem.makeTempFileScoped({ prefix: "t3-gitconfig-" }); | ||
| 41 | return yield* Effect.acquireUseRelease( | ||
| 42 | Effect.sync(() => { | ||
| 43 | const saved = GIT_ENV_KEYS.map((key) => [key, process.env[key]] as const); | ||
| 44 | for (const key of GIT_ENV_KEYS) delete process.env[key]; | ||
| 45 | Object.assign(process.env, { | ||
| 46 | GIT_CONFIG_GLOBAL: emptyConfig, | ||
| 47 | GIT_CONFIG_NOSYSTEM: "1", | ||
| 48 | ...env, | ||
| 49 | }); | ||
| 50 | return saved; | ||
| 51 | }), | ||
| 52 | () => effect, | ||
| 53 | (saved) => | ||
| 54 | Effect.sync(() => { | ||
| 55 | for (const [key, value] of saved) { | ||
| 56 | if (value === undefined) delete process.env[key]; | ||
| 57 | else process.env[key] = value; | ||
| 58 | } | ||
| 59 | }), | ||
| 60 | ); | ||
| 61 | }); | ||
| 62 | |||
| 63 | const gitOutput = (cwd: string, args: ReadonlyArray<string>) => | ||
| 64 | Effect.gen(function* () { | ||
| 65 | const git = yield* GitVcsDriver.GitVcsDriver; | ||
| 66 | const result = yield* git.execute({ operation: "NewProject.test", cwd, args }); | ||
| 67 | return result.stdout.trim(); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it.layer(TestLayer)("createNewProjectFolder", (it) => { | ||
| 71 | it.effect("makes the folder, starter files, and first commit, and suffixes a taken name", () => | ||
| 72 | Effect.scoped( | ||
| 73 | withGitEnv( | ||
| 74 | { | ||
| 75 | GIT_AUTHOR_NAME: "Test", | ||
| 76 | GIT_AUTHOR_EMAIL: "test@test.com", | ||
| 77 | GIT_COMMITTER_NAME: "Test", | ||
| 78 | GIT_COMMITTER_EMAIL: "test@test.com", | ||
| 79 | }, | ||
| 80 | Effect.gen(function* () { | ||
| 81 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 82 | const path = yield* Path.Path; | ||
| 83 | const root = path.join( | ||
| 84 | yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-projects-" }), | ||
| 85 | "projects", | ||
| 86 | ); | ||
| 87 | |||
| 88 | const first = yield* createNewProjectFolder({ root, name: "Pinball Stats" }); | ||
| 89 | const second = yield* createNewProjectFolder({ root, name: "pinball stats" }); | ||
| 90 | |||
| 91 | assert.equal(first.workspaceRoot, path.join(root, "pinball-stats")); | ||
| 92 | assert.equal(second.workspaceRoot, path.join(root, "pinball-stats-2")); | ||
| 93 | assert.isUndefined(first.commitError); | ||
| 94 | const readme = yield* fileSystem.readFileString( | ||
| 95 | path.join(first.workspaceRoot, "README.md"), | ||
| 96 | ); | ||
| 97 | assert.include(readme, "# Pinball Stats"); | ||
| 98 | assert.include(readme, `src="assets/icon.svg"`); | ||
| 99 | const icon = yield* fileSystem.readFileString( | ||
| 100 | path.join(first.workspaceRoot, "assets", "icon.svg"), | ||
| 101 | ); | ||
| 102 | assert.include(icon, ">PS</text>"); | ||
| 103 | assert.equal( | ||
| 104 | yield* gitOutput(first.workspaceRoot, ["log", "--format=%s"]), | ||
| 105 | "Initial commit", | ||
| 106 | ); | ||
| 107 | assert.equal( | ||
| 108 | yield* gitOutput(first.workspaceRoot, ["rev-parse", "--abbrev-ref", "HEAD"]), | ||
| 109 | "main", | ||
| 110 | ); | ||
| 111 | assert.equal(yield* gitOutput(first.workspaceRoot, ["status", "--porcelain"]), ""); | ||
| 112 | }), | ||
| 113 | ), | ||
| 114 | ), | ||
| 115 | ); | ||
| 116 | |||
| 117 | it.effect("keeps the folder and reports why when Git cannot commit", () => | ||
| 118 | Effect.scoped( | ||
| 119 | // No identity anywhere, and Git may not guess one from the host name. | ||
| 120 | withGitEnv( | ||
| 121 | { | ||
| 122 | GIT_CONFIG_COUNT: "1", | ||
| 123 | GIT_CONFIG_KEY_0: "user.useConfigOnly", | ||
| 124 | GIT_CONFIG_VALUE_0: "true", | ||
| 125 | }, | ||
| 126 | Effect.gen(function* () { | ||
| 127 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 128 | const path = yield* Path.Path; | ||
| 129 | const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-projects-" }); | ||
| 130 | |||
| 131 | const result = yield* createNewProjectFolder({ root, name: "No Identity" }); | ||
| 132 | |||
| 133 | assert.include(result.commitError ?? "", "no name or email"); | ||
| 134 | assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, "README.md"))); | ||
| 135 | assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, ".git"))); | ||
| 136 | }), | ||
| 137 | ), | ||
| 138 | ), | ||
| 139 | ); | ||
| 140 | }); | ||
apps/server/src/project/NewProject.ts created+179| ... | @@ -0,0 +1,179 @@ | ||
| 1 | /** | ||
| 2 | * NewProject - makes the folder for a project started from just a name: | ||
| 3 | * `<projects root>/<slug>` with a README, an icon, and a first commit. | ||
| 4 | * | ||
| 5 | * The icon lives at `assets/icon.svg`, a path ProjectFaviconResolver already | ||
| 6 | * checks after the usual web app icons. It travels with the repository, so | ||
| 7 | * every machine that clones the project shows the same icon. | ||
| 8 | * | ||
| 9 | * @module NewProject | ||
| 10 | */ | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as FileSystem from "effect/FileSystem"; | ||
| 13 | import * as Path from "effect/Path"; | ||
| 14 | import * as PlatformError from "effect/PlatformError"; | ||
| 15 | |||
| 16 | import { newProjectFolderName } from "@t3tools/shared/path"; | ||
| 17 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 18 | |||
| 19 | // Tailwind 600 shades: dark enough for white initials on every hue. | ||
| 20 | const ICON_BACKGROUNDS = [ | ||
| 21 | "#dc2626", | ||
| 22 | "#ea580c", | ||
| 23 | "#d97706", | ||
| 24 | "#16a34a", | ||
| 25 | "#059669", | ||
| 26 | "#0d9488", | ||
| 27 | "#0891b2", | ||
| 28 | "#0284c7", | ||
| 29 | "#2563eb", | ||
| 30 | "#4f46e5", | ||
| 31 | "#7c3aed", | ||
| 32 | "#9333ea", | ||
| 33 | "#c026d3", | ||
| 34 | "#db2777", | ||
| 35 | "#e11d48", | ||
| 36 | ] as const; | ||
| 37 | |||
| 38 | const MAX_FOLDER_ATTEMPTS = 100; | ||
| 39 | |||
| 40 | function escapeXml(value: string): string { | ||
| 41 | return value | ||
| 42 | .replaceAll("&", "&amp;") | ||
| 43 | .replaceAll("<", "&lt;") | ||
| 44 | .replaceAll(">", "&gt;") | ||
| 45 | .replaceAll('"', "&quot;"); | ||
| 46 | } | ||
| 47 | |||
| 48 | /** A rounded square with the name's initials, colored by a hash of the name. */ | ||
| 49 | function newProjectIconSvg(name: string): string { | ||
| 50 | const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean); | ||
| 51 | const initials = | ||
| 52 | words | ||
| 53 | .slice(0, 2) | ||
| 54 | .map((word) => Array.from(word)[0] ?? "") | ||
| 55 | .join("") | ||
| 56 | .toUpperCase() || | ||
| 57 | Array.from(name.trim())[0] || | ||
| 58 | "?"; | ||
| 59 | let hash = 0; | ||
| 60 | for (const char of name) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0; | ||
| 61 | const background = ICON_BACKGROUNDS[hash % ICON_BACKGROUNDS.length]; | ||
| 62 | return [ | ||
| 63 | `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">`, | ||
| 64 | ` <rect width="64" height="64" rx="14" fill="${background}"/>`, | ||
| 65 | ` <text x="32" y="32" dy="0.35em" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, sans-serif" font-size="${initials.length > 1 ? 26 : 32}" font-weight="600" fill="#ffffff">${escapeXml(initials)}</text>`, | ||
| 66 | `</svg>`, | ||
| 67 | "", | ||
| 68 | ].join("\n"); | ||
| 69 | } | ||
| 70 | |||
| 71 | function newProjectReadme(name: string): string { | ||
| 72 | return [ | ||
| 73 | `<img src="assets/icon.svg" width="64" height="64" alt="">`, | ||
| 74 | "", | ||
| 75 | `# ${name}`, | ||
| 76 | "", | ||
| 77 | "Created in [T3 Code](https://t3.codes).", | ||
| 78 | "", | ||
| 79 | ].join("\n"); | ||
| 80 | } | ||
| 81 | |||
| 82 | // Git's own identity message runs several lines; say what to do instead. | ||
| 83 | // Otherwise its last line ("error: gpg failed to sign the data") says enough. | ||
| 84 | function describeCommitFailure(stderr: string): string { | ||
| 85 | if (/identity unknown|tell me who you are|no (name|email) was given/i.test(stderr)) { | ||
| 86 | return "Git has no name or email on this machine. Set user.name and user.email, then commit."; | ||
| 87 | } | ||
| 88 | const lines = stderr | ||
| 89 | .split("\n") | ||
| 90 | .map((line) => line.trim()) | ||
| 91 | .filter(Boolean); | ||
| 92 | return lines.at(-1) ?? "Git could not make the first commit."; | ||
| 93 | } | ||
| 94 | |||
| 95 | /** | ||
| 96 | * Claims a fresh folder for `name` under `root` (adding `-2`, `-3`, ... when | ||
| 97 | * the name is taken), writes the starter files, and makes the first commit. | ||
| 98 | * A failed commit (no Git identity, a signing prompt) keeps the folder and | ||
| 99 | * returns why, so the project still opens. Any other failure removes the | ||
| 100 | * folder it claimed. | ||
| 101 | */ | ||
| 102 | export const createNewProjectFolder = Effect.fn("NewProject.createNewProjectFolder")( | ||
| 103 | function* (input: { readonly root: string; readonly name: string }) { | ||
| 104 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 105 | const path = yield* Path.Path; | ||
| 106 | const git = yield* GitVcsDriver.GitVcsDriver; | ||
| 107 | |||
| 108 | yield* fileSystem.makeDirectory(input.root, { recursive: true }); | ||
| 109 | // Created without `recursive`, so the create itself claims the folder and | ||
| 110 | // two requests for the same name never share one. | ||
| 111 | const folderName = newProjectFolderName(input.name); | ||
| 112 | let workspaceRoot: string | null = null; | ||
| 113 | for (let attempt = 1; workspaceRoot === null && attempt <= MAX_FOLDER_ATTEMPTS; attempt++) { | ||
| 114 | const candidate = path.join( | ||
| 115 | input.root, | ||
| 116 | attempt === 1 ? folderName : `${folderName}-${attempt}`, | ||
| 117 | ); | ||
| 118 | const claimed = yield* fileSystem.makeDirectory(candidate).pipe( | ||
| 119 | Effect.as(true), | ||
| 120 | Effect.catchIf( | ||
| 121 | (error) => error.reason._tag === "AlreadyExists", | ||
| 122 | () => Effect.succeed(false), | ||
| 123 | ), | ||
| 124 | ); | ||
| 125 | if (claimed) workspaceRoot = candidate; | ||
| 126 | } | ||
| 127 | if (workspaceRoot === null) { | ||
| 128 | return yield* PlatformError.systemError({ | ||
| 129 | _tag: "AlreadyExists", | ||
| 130 | module: "FileSystem", | ||
| 131 | method: "makeDirectory", | ||
| 132 | description: `Every folder name for "${folderName}" is taken.`, | ||
| 133 | pathOrDescriptor: input.root, | ||
| 134 | }); | ||
| 135 | } | ||
| 136 | const cwd = workspaceRoot; | ||
| 137 | |||
| 138 | return yield* Effect.gen(function* () { | ||
| 139 | const branch = | ||
| 140 | (yield* git | ||
| 141 | .readConfigValue(cwd, "init.defaultBranch") | ||
| 142 | .pipe(Effect.orElseSucceed(() => null))) ?? "main"; | ||
| 143 | yield* git.execute({ | ||
| 144 | operation: "NewProject.init", | ||
| 145 | cwd, | ||
| 146 | args: ["init", `--initial-branch=${branch}`], | ||
| 147 | timeoutMs: 10_000, | ||
| 148 | }); | ||
| 149 | yield* fileSystem.writeFileString(path.join(cwd, "README.md"), newProjectReadme(input.name)); | ||
| 150 | yield* fileSystem.makeDirectory(path.join(cwd, "assets")); | ||
| 151 | yield* fileSystem.writeFileString( | ||
| 152 | path.join(cwd, "assets", "icon.svg"), | ||
| 153 | newProjectIconSvg(input.name), | ||
| 154 | ); | ||
| 155 | // Named and forced so a global ignore rule (say `*.svg`) cannot drop one. | ||
| 156 | yield* git.execute({ | ||
| 157 | operation: "NewProject.add", | ||
| 158 | cwd, | ||
| 159 | args: ["add", "--force", "--", "README.md", "assets/icon.svg"], | ||
| 160 | timeoutMs: 10_000, | ||
| 161 | }); | ||
| 162 | const commitError = yield* git | ||
| 163 | .execute({ | ||
| 164 | operation: "NewProject.commit", | ||
| 165 | cwd, | ||
| 166 | args: ["commit", "--message", "Initial commit"], | ||
| 167 | allowNonZeroExit: true, | ||
| 168 | timeoutMs: 30_000, | ||
| 169 | }) | ||
| 170 | .pipe( | ||
| 171 | Effect.map((result) => | ||
| 172 | result.exitCode === 0 ? undefined : describeCommitFailure(result.stderr), | ||
| 173 | ), | ||
| 174 | Effect.catch((error) => Effect.succeed(error.message)), | ||
| 175 | ); | ||
| 176 | return { workspaceRoot: cwd, commitError }; | ||
| 177 | }).pipe(Effect.onError(() => fileSystem.remove(cwd, { recursive: true }).pipe(Effect.ignore))); | ||
| 178 | }, | ||
| 179 | ); | ||
apps/server/src/server.test.ts+59| ... | @@ -5556,6 +5556,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -5556,6 +5556,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5556 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 5556 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 5557 | ); | 5557 | ); |
| 5558 | 5558 | ||
| 5559 | it.effect("creates a project from just a name in the projects folder", () => | ||
| 5560 | Effect.gen(function* () { | ||
| 5561 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 5562 | const path = yield* Path.Path; | ||
| 5563 | const created: Array<{ readonly title: string; readonly workspaceRoot: string }> = []; | ||
| 5564 | const gitCalls: Array<string> = []; | ||
| 5565 | yield* buildAppUnderTest({ | ||
| 5566 | layers: { | ||
| 5567 | orchestrationEngine: { | ||
| 5568 | dispatch: (command) => | ||
| 5569 | Effect.sync(() => { | ||
| 5570 | if (command.type === "project.create") { | ||
| 5571 | created.push({ title: command.title, workspaceRoot: command.workspaceRoot }); | ||
| 5572 | } | ||
| 5573 | return { sequence: created.length }; | ||
| 5574 | }), | ||
| 5575 | }, | ||
| 5576 | gitVcsDriver: { | ||
| 5577 | readConfigValue: () => Effect.succeed(null), | ||
| 5578 | execute: (input) => | ||
| 5579 | Effect.sync(() => { | ||
| 5580 | gitCalls.push(input.args.join(" ")); | ||
| 5581 | return { | ||
| 5582 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 5583 | stdout: "", | ||
| 5584 | stderr: "", | ||
| 5585 | stdoutTruncated: false, | ||
| 5586 | stderrTruncated: false, | ||
| 5587 | }; | ||
| 5588 | }), | ||
| 5589 | }, | ||
| 5590 | }, | ||
| 5591 | }); | ||
| 5592 | |||
| 5593 | yield* Effect.scoped( | ||
| 5594 | withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => | ||
| 5595 | Effect.gen(function* () { | ||
| 5596 | const root = (yield* client[WS_METHODS.serverGetConfig]({})).newProjectsRoot ?? ""; | ||
| 5597 | const result = yield* client[WS_METHODS.projectsCreateNew]({ name: "Pinball Stats" }); | ||
| 5598 | |||
| 5599 | assert.equal(result.workspaceRoot, path.join(root, "pinball-stats")); | ||
| 5600 | assert.isUndefined(result.commitError); | ||
| 5601 | assert.deepEqual(created, [ | ||
| 5602 | { title: "Pinball Stats", workspaceRoot: result.workspaceRoot }, | ||
| 5603 | ]); | ||
| 5604 | assert.deepEqual(gitCalls, [ | ||
| 5605 | "init --initial-branch=main", | ||
| 5606 | "add --force -- README.md assets/icon.svg", | ||
| 5607 | "commit --message Initial commit", | ||
| 5608 | ]); | ||
| 5609 | assert.isTrue( | ||
| 5610 | yield* fileSystem.exists(path.join(result.workspaceRoot, "assets", "icon.svg")), | ||
| 5611 | ); | ||
| 5612 | }), | ||
| 5613 | ), | ||
| 5614 | ); | ||
| 5615 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 5616 | ); | ||
| 5617 | |||
| 5559 | it.effect("advertises the usable file manager and its reveal label", () => | 5618 | it.effect("advertises the usable file manager and its reveal label", () => |
| 5560 | Effect.gen(function* () { | 5619 | Effect.gen(function* () { |
| 5561 | yield* buildAppUnderTest({ | 5620 | yield* buildAppUnderTest({ |
apps/server/src/ws.ts+50| ... | @@ -49,6 +49,7 @@ import { | ... | @@ -49,6 +49,7 @@ import { |
| 49 | OrchestrationGetTurnDiffError, | 49 | OrchestrationGetTurnDiffError, |
| 50 | ORCHESTRATION_WS_METHODS, | 50 | ORCHESTRATION_WS_METHODS, |
| 51 | ProjectId, | 51 | ProjectId, |
| 52 | type ProjectCreateNewInput, | ||
| 52 | type ProjectEntriesFailure, | 53 | type ProjectEntriesFailure, |
| 53 | type ProjectFileFailure, | 54 | type ProjectFileFailure, |
| 54 | type ProjectFileOperation, | 55 | type ProjectFileOperation, |
| ... | @@ -143,6 +144,7 @@ import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; | ... | @@ -143,6 +144,7 @@ import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; |
| 143 | import * as ReviewService from "./review/ReviewService.ts"; | 144 | import * as ReviewService from "./review/ReviewService.ts"; |
| 144 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; | 145 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 145 | import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; | 146 | import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; |
| 147 | import * as NewProject from "./project/NewProject.ts"; | ||
| 146 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; | 148 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 147 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; | 149 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; |
| 148 | import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; | 150 | import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; |
| ... | @@ -2018,6 +2020,49 @@ const makeWsRpcLayer = ( | ... | @@ -2018,6 +2020,49 @@ const makeWsRpcLayer = ( |
| 2018 | ); | 2020 | ); |
| 2019 | }); | 2021 | }); |
| 2020 | 2022 | ||
| 2023 | // Projects started from just a name live beside Scratch and worktrees, | ||
| 2024 | // away from folders the user organizes by hand. A nested repository is | ||
| 2025 | // fine here (unlike Scratch) because each project gets its own `git init`. | ||
| 2026 | const newProjectsRoot = path.resolve(config.baseDir, "projects"); | ||
| 2027 | const createNewProject = (input: ProjectCreateNewInput) => | ||
| 2028 | Effect.gen(function* () { | ||
| 2029 | const folder = yield* NewProject.createNewProjectFolder({ | ||
| 2030 | root: newProjectsRoot, | ||
| 2031 | name: input.name, | ||
| 2032 | }).pipe( | ||
| 2033 | Effect.mapError( | ||
| 2034 | (cause) => | ||
| 2035 | new OrchestrationDispatchCommandError({ | ||
| 2036 | message: "Failed to create the project folder.", | ||
| 2037 | cause, | ||
| 2038 | }), | ||
| 2039 | ), | ||
| 2040 | ); | ||
| 2041 | const projectId = ProjectId.make(yield* randomUUID); | ||
| 2042 | yield* Effect.gen(function* () { | ||
| 2043 | const command = yield* normalizeDispatchCommand({ | ||
| 2044 | type: "project.create", | ||
| 2045 | commandId: yield* serverCommandId("project-create-new"), | ||
| 2046 | projectId, | ||
| 2047 | title: input.name, | ||
| 2048 | workspaceRoot: folder.workspaceRoot, | ||
| 2049 | createdAt: yield* nowIso, | ||
| 2050 | }); | ||
| 2051 | yield* dispatchNormalizedCommand(command); | ||
| 2052 | }).pipe( | ||
| 2053 | // Only a rejected command means no project uses the folder. An | ||
| 2054 | // interrupt can land after the command is queued, so keep it then. | ||
| 2055 | Effect.tapError(() => | ||
| 2056 | fileSystem.remove(folder.workspaceRoot, { recursive: true }).pipe(Effect.ignore), | ||
| 2057 | ), | ||
| 2058 | ); | ||
| 2059 | return { | ||
| 2060 | projectId, | ||
| 2061 | workspaceRoot: folder.workspaceRoot, | ||
| 2062 | ...(folder.commitError === undefined ? {} : { commitError: folder.commitError }), | ||
| 2063 | }; | ||
| 2064 | }); | ||
| 2065 | |||
| 2021 | // Only clients that answer /usage-limits themselves see it in the catalogs; | 2066 | // Only clients that answer /usage-limits themselves see it in the catalogs; |
| 2022 | // an older client would send the injected command to the provider. | 2067 | // an older client would send the injected command to the provider. |
| 2023 | const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => | 2068 | const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => |
| ... | @@ -2082,6 +2127,7 @@ const makeWsRpcLayer = ( | ... | @@ -2082,6 +2127,7 @@ const makeWsRpcLayer = ( |
| 2082 | threadSnapshotPagination: true, | 2127 | threadSnapshotPagination: true, |
| 2083 | reasoningMessages: true, | 2128 | reasoningMessages: true, |
| 2084 | ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }), | 2129 | ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }), |
| 2130 | newProjectsRoot, | ||
| 2085 | }; | 2131 | }; |
| 2086 | }); | 2132 | }); |
| 2087 | 2133 | ||
| ... | @@ -3252,6 +3298,10 @@ const makeWsRpcLayer = ( | ... | @@ -3252,6 +3298,10 @@ const makeWsRpcLayer = ( |
| 3252 | observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, { | 3298 | observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, { |
| 3253 | "rpc.aggregate": "orchestration", | 3299 | "rpc.aggregate": "orchestration", |
| 3254 | }), | 3300 | }), |
| 3301 | [WS_METHODS.projectsCreateNew]: (input) => | ||
| 3302 | observeRpcEffect(WS_METHODS.projectsCreateNew, createNewProject(input), { | ||
| 3303 | "rpc.aggregate": "orchestration", | ||
| 3304 | }), | ||
| 3255 | [WS_METHODS.projectCloneCancel]: (input) => | 3305 | [WS_METHODS.projectCloneCancel]: (input) => |
| 3256 | observeRpcEffect( | 3306 | observeRpcEffect( |
| 3257 | WS_METHODS.projectCloneCancel, | 3307 | WS_METHODS.projectCloneCancel, |
apps/web/src/components/CommandPalette.tsx+366-33| ... | @@ -10,6 +10,9 @@ import { | ... | @@ -10,6 +10,9 @@ import { |
| 10 | getCloneDestinationPath, | 10 | getCloneDestinationPath, |
| 11 | getCloneDirectoryName, | 11 | getCloneDirectoryName, |
| 12 | getDefaultCloneUrl, | 12 | getDefaultCloneUrl, |
| 13 | getNewProjectGitHubRepository, | ||
| 14 | getNewProjectGitHubTarget, | ||
| 15 | getNewProjectPathPreview, | ||
| 13 | normalizePastedCloneUrl, | 16 | normalizePastedCloneUrl, |
| 14 | } from "@t3tools/client-runtime/operations/projects"; | 17 | } from "@t3tools/client-runtime/operations/projects"; |
| 15 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; | 18 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; |
| ... | @@ -43,8 +46,11 @@ import * as Option from "effect/Option"; | ... | @@ -43,8 +46,11 @@ import * as Option from "effect/Option"; |
| 43 | import { | 46 | import { |
| 44 | ArrowLeftIcon, | 47 | ArrowLeftIcon, |
| 45 | ChartNoAxesColumnIcon, | 48 | ChartNoAxesColumnIcon, |
| 49 | CheckIcon, | ||
| 50 | ChevronRightIcon, | ||
| 46 | CornerLeftUpIcon, | 51 | CornerLeftUpIcon, |
| 47 | FileSearchIcon, | 52 | FileSearchIcon, |
| 53 | FolderGit2Icon, | ||
| 48 | FolderIcon, | 54 | FolderIcon, |
| 49 | FolderPlusIcon, | 55 | FolderPlusIcon, |
| 50 | MessageSquareDashedIcon, | 56 | MessageSquareDashedIcon, |
| ... | @@ -100,6 +106,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; | ... | @@ -100,6 +106,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; |
| 100 | import { useAtomCommand } from "../state/use-atom-command"; | 106 | import { useAtomCommand } from "../state/use-atom-command"; |
| 101 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | 107 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 102 | import { useScratchProject } from "../hooks/useScratchProject"; | 108 | import { useScratchProject } from "../hooks/useScratchProject"; |
| 109 | import { useNewProject } from "../hooks/useNewProject"; | ||
| 103 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | 110 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 104 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; | 111 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 105 | import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; | 112 | import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; |
| ... | @@ -170,6 +177,7 @@ import { CommandPaletteContent } from "./CommandPaletteContent"; | ... | @@ -170,6 +177,7 @@ import { CommandPaletteContent } from "./CommandPaletteContent"; |
| 170 | import { CommandPaletteResults } from "./CommandPaletteResults"; | 177 | import { CommandPaletteResults } from "./CommandPaletteResults"; |
| 171 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; | 178 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; |
| 172 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 179 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 180 | import { Checkbox } from "./ui/checkbox"; | ||
| 173 | import { ProjectFavicon } from "./ProjectFavicon"; | 181 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 174 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; | 182 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; |
| 175 | import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; | 183 | import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; |
| ... | @@ -862,6 +870,17 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -862,6 +870,17 @@ function OpenCommandPaletteDialog(props: { |
| 862 | ); | 870 | ); |
| 863 | const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); | 871 | const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); |
| 864 | const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null); | 872 | const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null); |
| 873 | // The name step of New project: while set, the palette input is the name. | ||
| 874 | const [newProjectFlow, setNewProjectFlow] = useState<{ | ||
| 875 | readonly environmentId: EnvironmentId; | ||
| 876 | /** Machine of the Add project sources view under this step; null from the palette root. */ | ||
| 877 | readonly sourcesEnvironmentId: EnvironmentId | null; | ||
| 878 | } | null>(null); | ||
| 879 | const [newProjectPublishesToGitHub, setNewProjectPublishesToGitHub] = useState(false); | ||
| 880 | const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); | ||
| 881 | // State lags a render behind, so a repeated Enter could start a second create. | ||
| 882 | const newProjectSubmittingRef = useRef(false); | ||
| 883 | const createNewProject = useNewProject(); | ||
| 865 | const cloneLookupGeneration = useRef(0); | 884 | const cloneLookupGeneration = useRef(0); |
| 866 | const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); | 885 | const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); |
| 867 | const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); | 886 | const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); |
| ... | @@ -1073,9 +1092,15 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1073,9 +1092,15 @@ function OpenCommandPaletteDialog(props: { |
| 1073 | getFilesystemBrowsePath( | 1092 | getFilesystemBrowsePath( |
| 1074 | query, | 1093 | query, |
| 1075 | browseEnvironmentPlatform, | 1094 | browseEnvironmentPlatform, |
| 1076 | browseEnvironmentId !== null && !isRemoteProjectRepositoryStep, | 1095 | browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null, |
| 1077 | ), | 1096 | ), |
| 1078 | [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], | 1097 | [ |
| 1098 | browseEnvironmentId, | ||
| 1099 | browseEnvironmentPlatform, | ||
| 1100 | isRemoteProjectRepositoryStep, | ||
| 1101 | newProjectFlow, | ||
| 1102 | query, | ||
| 1103 | ], | ||
| 1079 | ); | 1104 | ); |
| 1080 | const isBrowsing = browsePath.isBrowsing; | 1105 | const isBrowsing = browsePath.isBrowsing; |
| 1081 | const browseDirectoryPath = browsePath.directoryPath; | 1106 | const browseDirectoryPath = browsePath.directoryPath; |
| ... | @@ -1469,8 +1494,12 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1469,8 +1494,12 @@ function OpenCommandPaletteDialog(props: { |
| 1469 | function popView(): void { | 1494 | function popView(): void { |
| 1470 | browseNavigation.invalidate(); | 1495 | browseNavigation.invalidate(); |
| 1471 | setAddProjectCloneFlow(null); | 1496 | setAddProjectCloneFlow(null); |
| 1497 | setNewProjectFlow(null); | ||
| 1472 | if (viewStack.length <= 1) { | 1498 | if (viewStack.length <= 1) { |
| 1473 | setAddProjectEnvironmentId(null); | 1499 | setAddProjectEnvironmentId(null); |
| 1500 | } else if (newProjectFlow?.sourcesEnvironmentId) { | ||
| 1501 | // The machine switcher may have moved off the sources view's machine. | ||
| 1502 | setAddProjectEnvironmentId(newProjectFlow.sourcesEnvironmentId); | ||
| 1474 | } | 1503 | } |
| 1475 | setViewStack((previousViews) => previousViews.slice(0, -1)); | 1504 | setViewStack((previousViews) => previousViews.slice(0, -1)); |
| 1476 | setHighlightedItemValue(null); | 1505 | setHighlightedItemValue(null); |
| ... | @@ -1531,6 +1560,28 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1531,6 +1560,28 @@ function OpenCommandPaletteDialog(props: { |
| 1531 | [pushPaletteView], | 1560 | [pushPaletteView], |
| 1532 | ); | 1561 | ); |
| 1533 | 1562 | ||
| 1563 | /** Folder that holds an environment's name-only projects, or null when it has none. */ | ||
| 1564 | const newProjectsRootFor = useCallback( | ||
| 1565 | (environmentId: EnvironmentId | null): string | null => | ||
| 1566 | environments.find((environment) => environment.environmentId === environmentId)?.serverConfig | ||
| 1567 | ?.newProjectsRoot ?? null, | ||
| 1568 | [environments], | ||
| 1569 | ); | ||
| 1570 | |||
| 1571 | const startNewProject = useCallback( | ||
| 1572 | (environmentId: EnvironmentId, sourcesEnvironmentId: EnvironmentId | null): void => { | ||
| 1573 | setAddProjectEnvironmentId(environmentId); | ||
| 1574 | setAddProjectCloneFlow(null); | ||
| 1575 | setNewProjectFlow({ environmentId, sourcesEnvironmentId }); | ||
| 1576 | setNewProjectPublishesToGitHub(false); | ||
| 1577 | pushPaletteView({ | ||
| 1578 | addonIcon: <FolderGit2Icon className={ADDON_ICON_CLASS} />, | ||
| 1579 | groups: [], | ||
| 1580 | }); | ||
| 1581 | }, | ||
| 1582 | [pushPaletteView], | ||
| 1583 | ); | ||
| 1584 | |||
| 1534 | const openSourceControlSettings = useCallback(() => { | 1585 | const openSourceControlSettings = useCallback(() => { |
| 1535 | setOpen(false); | 1586 | setOpen(false); |
| 1536 | void navigate({ to: "/settings/source-control" }); | 1587 | void navigate({ to: "/settings/source-control" }); |
| ... | @@ -1556,6 +1607,21 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1556,6 +1607,21 @@ function OpenCommandPaletteDialog(props: { |
| 1556 | }, | 1607 | }, |
| 1557 | ]; | 1608 | ]; |
| 1558 | 1609 | ||
| 1610 | if (newProjectsRootFor(environmentId) !== null) { | ||
| 1611 | sourceItems.unshift({ | ||
| 1612 | kind: "action", | ||
| 1613 | value: `action:add-project:${environmentId}:new`, | ||
| 1614 | searchTerms: ["new project", "create", "empty", "repository", "git init"], | ||
| 1615 | title: "New project", | ||
| 1616 | description: "Start a new Git repository from a name", | ||
| 1617 | icon: <FolderGit2Icon className={ITEM_ICON_CLASS} />, | ||
| 1618 | keepOpen: true, | ||
| 1619 | run: async () => { | ||
| 1620 | startNewProject(environmentId, environmentId); | ||
| 1621 | }, | ||
| 1622 | }); | ||
| 1623 | } | ||
| 1624 | |||
| 1559 | const orderedSources: ReadonlyArray<AddProjectRemoteSource> = [ | 1625 | const orderedSources: ReadonlyArray<AddProjectRemoteSource> = [ |
| 1560 | "url", | 1626 | "url", |
| 1561 | ...sortAddProjectProviderSources(readinessBySource), | 1627 | ...sortAddProjectProviderSources(readinessBySource), |
| ... | @@ -1626,7 +1692,13 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1626,7 +1692,13 @@ function OpenCommandPaletteDialog(props: { |
| 1626 | 1692 | ||
| 1627 | return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; | 1693 | return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; |
| 1628 | }, | 1694 | }, |
| 1629 | [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], | 1695 | [ |
| 1696 | newProjectsRootFor, | ||
| 1697 | openSourceControlSettings, | ||
| 1698 | startAddProjectBrowse, | ||
| 1699 | startAddProjectClone, | ||
| 1700 | startNewProject, | ||
| 1701 | ], | ||
| 1630 | ); | 1702 | ); |
| 1631 | 1703 | ||
| 1632 | const startAddProjectSourceSelection = useCallback( | 1704 | const startAddProjectSourceSelection = useCallback( |
| ... | @@ -1665,24 +1737,36 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1665,24 +1737,36 @@ function OpenCommandPaletteDialog(props: { |
| 1665 | ], | 1737 | ], |
| 1666 | ); | 1738 | ); |
| 1667 | 1739 | ||
| 1668 | const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( | 1740 | const buildEnvironmentItem = ( |
| 1669 | (option) => ({ | 1741 | option: AddProjectEnvironmentOption, |
| 1670 | kind: "action", | 1742 | value: string, |
| 1671 | value: `action:add-project:environment:${option.environmentId}`, | 1743 | run: (environmentId: EnvironmentId) => void, |
| 1672 | searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], | 1744 | ): CommandPaletteActionItem => ({ |
| 1673 | title: option.label, | 1745 | kind: "action", |
| 1674 | description: option.isConnected | 1746 | value, |
| 1675 | ? option.isPrimary | 1747 | searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], |
| 1676 | ? "This device" | 1748 | title: option.label, |
| 1677 | : option.environmentId | 1749 | description: option.isConnected |
| 1678 | : option.status, | 1750 | ? option.isPrimary |
| 1679 | disabled: !option.isConnected, | 1751 | ? "This device" |
| 1680 | icon: <EnvironmentMachineIcon kind={option.machine} className={ITEM_ICON_CLASS} />, | 1752 | : option.environmentId |
| 1681 | keepOpen: true, | 1753 | : option.status, |
| 1682 | run: async () => { | 1754 | disabled: !option.isConnected, |
| 1683 | startAddProjectSourceSelection(option.environmentId); | 1755 | icon: <EnvironmentMachineIcon kind={option.machine} className={ITEM_ICON_CLASS} />, |
| 1684 | }, | 1756 | keepOpen: true, |
| 1685 | }), | 1757 | run: async () => { |
| 1758 | run(option.environmentId); | ||
| 1759 | }, | ||
| 1760 | }); | ||
| 1761 | const addProjectEnvironmentItems = addProjectEnvironmentOptions.map((option) => | ||
| 1762 | buildEnvironmentItem( | ||
| 1763 | option, | ||
| 1764 | `action:add-project:environment:${option.environmentId}`, | ||
| 1765 | startAddProjectSourceSelection, | ||
| 1766 | ), | ||
| 1767 | ); | ||
| 1768 | const newProjectEnvironmentOptions = addProjectEnvironmentOptions.filter( | ||
| 1769 | (option) => option.isConnected && newProjectsRootFor(option.environmentId) !== null, | ||
| 1686 | ); | 1770 | ); |
| 1687 | 1771 | ||
| 1688 | const addProjectEnvironmentGroups = useMemo<CommandPaletteView["groups"]>( | 1772 | const addProjectEnvironmentGroups = useMemo<CommandPaletteView["groups"]>( |
| ... | @@ -1724,12 +1808,20 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1724,12 +1808,20 @@ function OpenCommandPaletteDialog(props: { |
| 1724 | startAddProjectSourceSelection, | 1808 | startAddProjectSourceSelection, |
| 1725 | ]); | 1809 | ]); |
| 1726 | 1810 | ||
| 1811 | // New project starts on this device (options list it first); the name step | ||
| 1812 | // lists the other machines when there is a choice. | ||
| 1813 | const openNewProjectFlow = () => { | ||
| 1814 | const firstOption = newProjectEnvironmentOptions[0]; | ||
| 1815 | if (firstOption) startNewProject(firstOption.environmentId, null); | ||
| 1816 | }; | ||
| 1817 | |||
| 1727 | useLayoutEffect(() => { | 1818 | useLayoutEffect(() => { |
| 1728 | if (openIntent?.kind !== "search") return; | 1819 | if (openIntent?.kind !== "search") return; |
| 1729 | browseNavigation.invalidate(); | 1820 | browseNavigation.invalidate(); |
| 1730 | cloneLookupGeneration.current += 1; | 1821 | cloneLookupGeneration.current += 1; |
| 1731 | setIsRemoteProjectLookingUp(false); | 1822 | setIsRemoteProjectLookingUp(false); |
| 1732 | setAddProjectCloneFlow(null); | 1823 | setAddProjectCloneFlow(null); |
| 1824 | setNewProjectFlow(null); | ||
| 1733 | setViewStack([]); | 1825 | setViewStack([]); |
| 1734 | setLinkedThreadSearch(openIntent); | 1826 | setLinkedThreadSearch(openIntent); |
| 1735 | setQuery(openIntent.query); | 1827 | setQuery(openIntent.query); |
| ... | @@ -1751,6 +1843,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1751,6 +1843,7 @@ function OpenCommandPaletteDialog(props: { |
| 1751 | clearOpenIntent(); | 1843 | clearOpenIntent(); |
| 1752 | browseNavigation.invalidate(); | 1844 | browseNavigation.invalidate(); |
| 1753 | setAddProjectCloneFlow(null); | 1845 | setAddProjectCloneFlow(null); |
| 1846 | setNewProjectFlow(null); | ||
| 1754 | setViewStack([]); | 1847 | setViewStack([]); |
| 1755 | setQuery(""); | 1848 | setQuery(""); |
| 1756 | const currentPrefix = | 1849 | const currentPrefix = |
| ... | @@ -1949,6 +2042,20 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1949,6 +2042,20 @@ function OpenCommandPaletteDialog(props: { |
| 1949 | }, | 2042 | }, |
| 1950 | }); | 2043 | }); |
| 1951 | 2044 | ||
| 2045 | if (newProjectEnvironmentOptions.length > 0) { | ||
| 2046 | actionItems.push({ | ||
| 2047 | kind: "action", | ||
| 2048 | value: "action:new-project", | ||
| 2049 | searchTerms: ["new project", "create project", "empty", "repository", "repo", "git init"], | ||
| 2050 | title: "New project", | ||
| 2051 | icon: <FolderGit2Icon className={ITEM_ICON_CLASS} />, | ||
| 2052 | keepOpen: true, | ||
| 2053 | run: async () => { | ||
| 2054 | openNewProjectFlow(); | ||
| 2055 | }, | ||
| 2056 | }); | ||
| 2057 | } | ||
| 2058 | |||
| 1952 | actionItems.push({ | 2059 | actionItems.push({ |
| 1953 | kind: "action", | 2060 | kind: "action", |
| 1954 | value: "action:add-project", | 2061 | value: "action:add-project", |
| ... | @@ -2081,6 +2188,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2081,6 +2188,7 @@ function OpenCommandPaletteDialog(props: { |
| 2081 | cloneLookupGeneration.current += 1; | 2188 | cloneLookupGeneration.current += 1; |
| 2082 | setIsRemoteProjectLookingUp(false); | 2189 | setIsRemoteProjectLookingUp(false); |
| 2083 | setAddProjectCloneFlow(null); | 2190 | setAddProjectCloneFlow(null); |
| 2191 | setNewProjectFlow(null); | ||
| 2084 | setViewStack([]); | 2192 | setViewStack([]); |
| 2085 | pushPaletteView({ | 2193 | pushPaletteView({ |
| 2086 | addonIcon: <PaletteIcon className={ADDON_ICON_CLASS} />, | 2194 | addonIcon: <PaletteIcon className={ADDON_ICON_CLASS} />, |
| ... | @@ -2398,6 +2506,34 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2398,6 +2506,34 @@ function OpenCommandPaletteDialog(props: { |
| 2398 | ], | 2506 | ], |
| 2399 | ); | 2507 | ); |
| 2400 | 2508 | ||
| 2509 | const newProjectGitHubTarget = | ||
| 2510 | newProjectFlow === null ? null : getNewProjectGitHubTarget(sourceControlDiscovery.data ?? null); | ||
| 2511 | const newProjectName = query.trim(); | ||
| 2512 | const canSubmitNewProject = | ||
| 2513 | newProjectFlow !== null && | ||
| 2514 | newProjectName.length > 0 && | ||
| 2515 | !isCreatingNewProject && | ||
| 2516 | canCreateProjectInEnvironment(browseEnvironment?.connection.phase); | ||
| 2517 | |||
| 2518 | async function submitNewProject(): Promise<void> { | ||
| 2519 | if (newProjectFlow === null || !canSubmitNewProject || newProjectSubmittingRef.current) { | ||
| 2520 | return; | ||
| 2521 | } | ||
| 2522 | newProjectSubmittingRef.current = true; | ||
| 2523 | setIsCreatingNewProject(true); | ||
| 2524 | try { | ||
| 2525 | const created = await createNewProject({ | ||
| 2526 | environmentId: newProjectFlow.environmentId, | ||
| 2527 | name: newProjectName, | ||
| 2528 | github: newProjectPublishesToGitHub ? newProjectGitHubTarget : null, | ||
| 2529 | }); | ||
| 2530 | if (created) setOpen(false); | ||
| 2531 | } finally { | ||
| 2532 | newProjectSubmittingRef.current = false; | ||
| 2533 | setIsCreatingNewProject(false); | ||
| 2534 | } | ||
| 2535 | } | ||
| 2536 | |||
| 2401 | function getDefaultCloneParentPath(environmentId: EnvironmentId): string { | 2537 | function getDefaultCloneParentPath(environmentId: EnvironmentId): string { |
| 2402 | return getAddProjectInitialQueryForEnvironment(environmentId); | 2538 | return getAddProjectInitialQueryForEnvironment(environmentId); |
| 2403 | } | 2539 | } |
| ... | @@ -2681,8 +2817,135 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2681,8 +2817,135 @@ function OpenCommandPaletteDialog(props: { |
| 2681 | }; | 2817 | }; |
| 2682 | }, [addProjectCloneFlow]); | 2818 | }, [addProjectCloneFlow]); |
| 2683 | 2819 | ||
| 2820 | const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; | ||
| 2821 | const newProjectPathPreview = | ||
| 2822 | newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); | ||
| 2823 | const newProjectGitHubToggleValue = "new-project:github"; | ||
| 2824 | // The name step's way out to folders and clones, for the selected machine. | ||
| 2825 | // It replaces the name step (and a sources view for another machine under | ||
| 2826 | // it), so Back returns to wherever New project was opened from. | ||
| 2827 | const showExistingProjectSources = (environmentId: EnvironmentId) => { | ||
| 2828 | const sourcesEnvironmentId = newProjectFlow?.sourcesEnvironmentId ?? null; | ||
| 2829 | if (sourcesEnvironmentId === environmentId) { | ||
| 2830 | popView(); | ||
| 2831 | return; | ||
| 2832 | } | ||
| 2833 | if (sourcesEnvironmentId !== null) { | ||
| 2834 | setViewStack((previousViews) => previousViews.slice(0, -1)); | ||
| 2835 | } | ||
| 2836 | popView(); | ||
| 2837 | startAddProjectSourceSelection(environmentId); | ||
| 2838 | }; | ||
| 2839 | // Switching machines keeps the typed name; the GitHub option follows the | ||
| 2840 | // machine because source control discovery reads addProjectEnvironmentId. | ||
| 2841 | const switchNewProjectEnvironment = (environmentId: EnvironmentId) => { | ||
| 2842 | setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId })); | ||
| 2843 | setAddProjectEnvironmentId(environmentId); | ||
| 2844 | }; | ||
| 2845 | const selectedNewProjectEnvironment = | ||
| 2846 | newProjectFlow === null | ||
| 2847 | ? undefined | ||
| 2848 | : newProjectEnvironmentOptions.find( | ||
| 2849 | (option) => option.environmentId === newProjectFlow.environmentId, | ||
| 2850 | ); | ||
| 2851 | // Shown when there is a choice, or when the selected machine went away and | ||
| 2852 | // another one can take over. | ||
| 2853 | const showNewProjectMachines = | ||
| 2854 | newProjectFlow !== null && | ||
| 2855 | (newProjectEnvironmentOptions.length > 1 || | ||
| 2856 | (selectedNewProjectEnvironment === undefined && newProjectEnvironmentOptions.length > 0)); | ||
| 2857 | const newProjectEnvironmentLabel = showNewProjectMachines | ||
| 2858 | ? (selectedNewProjectEnvironment?.label ?? | ||
| 2859 | environmentLabelById.get(newProjectFlow.environmentId) ?? | ||
| 2860 | null) | ||
| 2861 | : null; | ||
| 2862 | const newProjectMachineGroup: CommandPaletteView["groups"][number] | null = | ||
| 2863 | !showNewProjectMachines || newProjectFlow === null | ||
| 2864 | ? null | ||
| 2865 | : { | ||
| 2866 | value: "new-project-machines", | ||
| 2867 | label: "Environments", | ||
| 2868 | items: newProjectEnvironmentOptions.map((option) => ({ | ||
| 2869 | ...buildEnvironmentItem( | ||
| 2870 | option, | ||
| 2871 | `new-project:environment:${option.environmentId}`, | ||
| 2872 | switchNewProjectEnvironment, | ||
| 2873 | ), | ||
| 2874 | // The create in flight keeps the machine it started on. | ||
| 2875 | ...(isCreatingNewProject ? { disabled: true } : {}), | ||
| 2876 | ...(option.environmentId === newProjectFlow.environmentId | ||
| 2877 | ? { | ||
| 2878 | titleTrailingContent: ( | ||
| 2879 | <CheckIcon className="ms-auto size-4 shrink-0 text-muted-foreground/70" /> | ||
| 2880 | ), | ||
| 2881 | } | ||
| 2882 | : {}), | ||
| 2883 | })), | ||
| 2884 | }; | ||
| 2885 | const newProjectExistingGroup: CommandPaletteView["groups"][number] | null = | ||
| 2886 | newProjectFlow === null | ||
| 2887 | ? null | ||
| 2888 | : { | ||
| 2889 | value: "new-project-existing", | ||
| 2890 | label: "", | ||
| 2891 | items: [ | ||
| 2892 | { | ||
| 2893 | kind: "action", | ||
| 2894 | value: "new-project:existing", | ||
| 2895 | searchTerms: [], | ||
| 2896 | title: "Add existing project", | ||
| 2897 | description: "Open a folder or clone a repository", | ||
| 2898 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | ||
| 2899 | titleTrailingContent: ( | ||
| 2900 | <ChevronRightIcon className="-me-0.5 ms-auto size-4 shrink-0 text-muted-foreground/70" /> | ||
| 2901 | ), | ||
| 2902 | keepOpen: true, | ||
| 2903 | run: async () => { | ||
| 2904 | showExistingProjectSources(newProjectFlow.environmentId); | ||
| 2905 | }, | ||
| 2906 | }, | ||
| 2907 | ], | ||
| 2908 | }; | ||
| 2909 | const newProjectOptionGroups: CommandPaletteView["groups"] = | ||
| 2910 | newProjectGitHubTarget === null || newProjectPathPreview === null | ||
| 2911 | ? [] | ||
| 2912 | : [ | ||
| 2913 | { | ||
| 2914 | value: "new-project-options", | ||
| 2915 | label: "Options", | ||
| 2916 | items: [ | ||
| 2917 | { | ||
| 2918 | kind: "action", | ||
| 2919 | value: newProjectGitHubToggleValue, | ||
| 2920 | searchTerms: [], | ||
| 2921 | title: "Create private repository on GitHub", | ||
| 2922 | description: | ||
| 2923 | newProjectName.length > 0 | ||
| 2924 | ? getNewProjectGitHubRepository(newProjectGitHubTarget, newProjectPathPreview) | ||
| 2925 | : (newProjectGitHubTarget.account ?? "Your GitHub account"), | ||
| 2926 | icon: <GitHubIcon className={ITEM_ICON_CLASS} />, | ||
| 2927 | titleTrailingContent: ( | ||
| 2928 | <span className="pointer-events-none ms-auto flex"> | ||
| 2929 | <Checkbox checked={newProjectPublishesToGitHub} tabIndex={-1} aria-hidden /> | ||
| 2930 | </span> | ||
| 2931 | ), | ||
| 2932 | keepOpen: true, | ||
| 2933 | run: async () => { | ||
| 2934 | setNewProjectPublishesToGitHub((publishes) => !publishes); | ||
| 2935 | }, | ||
| 2936 | }, | ||
| 2937 | ], | ||
| 2938 | }, | ||
| 2939 | ]; | ||
| 2940 | |||
| 2684 | let displayedGroups: CommandPaletteView["groups"] = filteredGroups; | 2941 | let displayedGroups: CommandPaletteView["groups"] = filteredGroups; |
| 2685 | if (addProjectCloneFlow?.step === "repository") { | 2942 | if (newProjectFlow !== null) { |
| 2943 | displayedGroups = [ | ||
| 2944 | ...(newProjectMachineGroup ? [newProjectMachineGroup] : []), | ||
| 2945 | ...newProjectOptionGroups, | ||
| 2946 | ...(newProjectExistingGroup ? [newProjectExistingGroup] : []), | ||
| 2947 | ]; | ||
| 2948 | } else if (addProjectCloneFlow?.step === "repository") { | ||
| 2686 | displayedGroups = []; | 2949 | displayedGroups = []; |
| 2687 | } else if (addProjectCloneFlow?.step === "confirm") { | 2950 | } else if (addProjectCloneFlow?.step === "confirm") { |
| 2688 | displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; | 2951 | displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; |
| ... | @@ -2691,8 +2954,10 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2691,8 +2954,10 @@ function OpenCommandPaletteDialog(props: { |
| 2691 | } | 2954 | } |
| 2692 | 2955 | ||
| 2693 | const inputPlaceholder = | 2956 | const inputPlaceholder = |
| 2694 | remoteProjectInputPlaceholder(addProjectCloneFlow) ?? | 2957 | newProjectFlow !== null |
| 2695 | getCommandPaletteInputPlaceholder(paletteMode); | 2958 | ? "Project name" |
| 2959 | : (remoteProjectInputPlaceholder(addProjectCloneFlow) ?? | ||
| 2960 | getCommandPaletteInputPlaceholder(paletteMode)); | ||
| 2696 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; | 2961 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; |
| 2697 | const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; | 2962 | const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; |
| 2698 | const canSubmitBrowsePath = | 2963 | const canSubmitBrowsePath = |
| ... | @@ -2799,6 +3064,21 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2799,6 +3064,21 @@ function OpenCommandPaletteDialog(props: { |
| 2799 | return; | 3064 | return; |
| 2800 | } | 3065 | } |
| 2801 | 3066 | ||
| 3067 | // Enter creates the project unless an item below the name is | ||
| 3068 | // highlighted, in which case it runs that item. | ||
| 3069 | if ( | ||
| 3070 | newProjectFlow !== null && | ||
| 3071 | event.key === "Enter" && | ||
| 3072 | highlightedItemValue === null && | ||
| 3073 | // Enter that confirms an IME composition is part of typing the name. | ||
| 3074 | !event.nativeEvent.isComposing && | ||
| 3075 | event.keyCode !== 229 | ||
| 3076 | ) { | ||
| 3077 | event.preventDefault(); | ||
| 3078 | void submitNewProject(); | ||
| 3079 | return; | ||
| 3080 | } | ||
| 3081 | |||
| 2802 | const shouldSubmitBrowsePath = | 3082 | const shouldSubmitBrowsePath = |
| 2803 | canSubmitBrowsePath && | 3083 | canSubmitBrowsePath && |
| 2804 | event.key === "Enter" && | 3084 | event.key === "Enter" && |
| ... | @@ -2957,7 +3237,34 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2957,7 +3237,34 @@ function OpenCommandPaletteDialog(props: { |
| 2957 | ]); | 3237 | ]); |
| 2958 | 3238 | ||
| 2959 | const inputAccessory = | 3239 | const inputAccessory = |
| 2960 | addProjectCloneFlow?.step === "repository" ? ( | 3240 | newProjectFlow !== null ? ( |
| 3241 | <Tooltip> | ||
| 3242 | <TooltipTrigger | ||
| 3243 | render={ | ||
| 3244 | <Button | ||
| 3245 | variant="outline" | ||
| 3246 | size="xs" | ||
| 3247 | tabIndex={-1} | ||
| 3248 | className="absolute inset-e-2.5 top-1/2 -translate-y-1/2" | ||
| 3249 | aria-label="Create (Enter)" | ||
| 3250 | disabled={!canSubmitNewProject} | ||
| 3251 | onMouseDown={(event) => { | ||
| 3252 | event.preventDefault(); | ||
| 3253 | }} | ||
| 3254 | onClick={() => { | ||
| 3255 | void submitNewProject(); | ||
| 3256 | }} | ||
| 3257 | /> | ||
| 3258 | } | ||
| 3259 | > | ||
| 3260 | <span>{isCreatingNewProject ? "Creating" : "Create"}</span> | ||
| 3261 | <KbdGroup className="pointer-events-none -me-0.5"> | ||
| 3262 | <Kbd>Enter</Kbd> | ||
| 3263 | </KbdGroup> | ||
| 3264 | </TooltipTrigger> | ||
| 3265 | <TooltipPopup side="top">Create (Enter)</TooltipPopup> | ||
| 3266 | </Tooltip> | ||
| 3267 | ) : addProjectCloneFlow?.step === "repository" ? ( | ||
| 2961 | <Tooltip> | 3268 | <Tooltip> |
| 2962 | <TooltipTrigger | 3269 | <TooltipTrigger |
| 2963 | render={ | 3270 | render={ |
| ... | @@ -3029,11 +3336,17 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3029,11 +3336,17 @@ function OpenCommandPaletteDialog(props: { |
| 3029 | ) : null; | 3336 | ) : null; |
| 3030 | 3337 | ||
| 3031 | const footerActionLabel = | 3338 | const footerActionLabel = |
| 3032 | addProjectCloneFlow?.step === "repository" | 3339 | newProjectFlow !== null |
| 3033 | ? (remoteProjectButtonLabel ?? "Continue") | 3340 | ? highlightedItemValue === null |
| 3034 | : !canSubmitBrowsePath || hasHighlightedBrowseItem | 3341 | ? "Create" |
| 3035 | ? "Select" | 3342 | : highlightedItemValue === newProjectGitHubToggleValue |
| 3036 | : undefined; | 3343 | ? "Toggle" |
| 3344 | : "Select" | ||
| 3345 | : addProjectCloneFlow?.step === "repository" | ||
| 3346 | ? (remoteProjectButtonLabel ?? "Continue") | ||
| 3347 | : !canSubmitBrowsePath || hasHighlightedBrowseItem | ||
| 3348 | ? "Select" | ||
| 3349 | : undefined; | ||
| 3037 | 3350 | ||
| 3038 | const footerTrailing = canOpenProjectFromFileManager ? ( | 3351 | const footerTrailing = canOpenProjectFromFileManager ? ( |
| 3039 | <CommandFooterAction | 3352 | <CommandFooterAction |
| ... | @@ -3048,9 +3361,11 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3048,9 +3361,11 @@ function OpenCommandPaletteDialog(props: { |
| 3048 | 3361 | ||
| 3049 | return ( | 3362 | return ( |
| 3050 | <CommandPaletteContent | 3363 | <CommandPaletteContent |
| 3051 | key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`} | 3364 | key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`} |
| 3052 | aria-label="Command palette" | 3365 | aria-label="Command palette" |
| 3053 | autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"} | 3366 | autoHighlight={ |
| 3367 | isBrowsing || isRemoteProjectCloneFlow || newProjectFlow !== null ? false : "always" | ||
| 3368 | } | ||
| 3054 | footerActionLabel={footerActionLabel} | 3369 | footerActionLabel={footerActionLabel} |
| 3055 | footerTrailing={footerTrailing} | 3370 | footerTrailing={footerTrailing} |
| 3056 | inputAccessory={inputAccessory} | 3371 | inputAccessory={inputAccessory} |
| ... | @@ -3058,7 +3373,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3058,7 +3373,7 @@ function OpenCommandPaletteDialog(props: { |
| 3058 | // The submit button is absolutely positioned over the field, so the | 3373 | // The submit button is absolutely positioned over the field, so the |
| 3059 | // inner input must reserve enough room for the full action label. | 3374 | // inner input must reserve enough room for the full action label. |
| 3060 | className: | 3375 | className: |
| 3061 | addProjectCloneFlow?.step === "repository" | 3376 | addProjectCloneFlow?.step === "repository" || newProjectFlow !== null |
| 3062 | ? "*:data-[slot=autocomplete-input]:pe-32!" | 3377 | ? "*:data-[slot=autocomplete-input]:pe-32!" |
| 3063 | : isBrowsing | 3378 | : isBrowsing |
| 3064 | ? browseInputEndPaddingClass({ | 3379 | ? browseInputEndPaddingClass({ |
| ... | @@ -3093,6 +3408,24 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3093,6 +3408,24 @@ function OpenCommandPaletteDialog(props: { |
| 3093 | showBackHint={isSubmenu} | 3408 | showBackHint={isSubmenu} |
| 3094 | value={query} | 3409 | value={query} |
| 3095 | > | 3410 | > |
| 3411 | {newProjectPathPreview !== null ? ( | ||
| 3412 | <div className="p-2 pb-0"> | ||
| 3413 | <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5"> | ||
| 3414 | <FolderGit2Icon className={ITEM_ICON_CLASS} /> | ||
| 3415 | <span className="flex min-w-0 flex-1 flex-col"> | ||
| 3416 | <span className="truncate text-foreground text-sm"> | ||
| 3417 | {newProjectName.length > 0 ? newProjectName : "New project"} | ||
| 3418 | </span> | ||
| 3419 | <span className="truncate text-muted-foreground/85 text-xs"> | ||
| 3420 | {newProjectName.length > 0 | ||
| 3421 | ? `Creates ${newProjectPathPreview}` | ||
| 3422 | : `Goes in ${newProjectsRoot}`} | ||
| 3423 | {newProjectEnvironmentLabel === null ? null : ` on ${newProjectEnvironmentLabel}`} | ||
| 3424 | </span> | ||
| 3425 | </span> | ||
| 3426 | </div> | ||
| 3427 | </div> | ||
| 3428 | ) : null} | ||
| 3096 | {remoteProjectContext ? ( | 3429 | {remoteProjectContext ? ( |
| 3097 | <div className="p-2 pb-0"> | 3430 | <div className="p-2 pb-0"> |
| 3098 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Repository</div> | 3431 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Repository</div> |
apps/web/src/components/CommandPaletteResults.tsx+1-1| ... | @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { | ... | @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { |
| 41 | <CommandList> | 41 | <CommandList> |
| 42 | {props.groups.map((group) => ( | 42 | {props.groups.map((group) => ( |
| 43 | <CommandGroup items={group.items} key={group.value}> | 43 | <CommandGroup items={group.items} key={group.value}> |
| 44 | <CommandGroupLabel>{group.label}</CommandGroupLabel> | 44 | {group.label ? <CommandGroupLabel>{group.label}</CommandGroupLabel> : null} |
| 45 | <CommandCollection> | 45 | <CommandCollection> |
| 46 | {(item) => | 46 | {(item) => |
| 47 | item.disabled ? ( | 47 | item.disabled ? ( |
apps/web/src/components/chat/DraftHeroHeadline.tsx+1-1| ... | @@ -304,7 +304,7 @@ export function DraftHeroHeadline({ | ... | @@ -304,7 +304,7 @@ export function DraftHeroHeadline({ |
| 304 | {projectPickerEntries.length > 0 ? <MenuSeparator /> : null} | 304 | {projectPickerEntries.length > 0 ? <MenuSeparator /> : null} |
| 305 | <MenuItem onClick={openAddProject}> | 305 | <MenuItem onClick={openAddProject}> |
| 306 | <FolderPlusIcon /> | 306 | <FolderPlusIcon /> |
| 307 | New project | 307 | Add project |
| 308 | </MenuItem> | 308 | </MenuItem> |
| 309 | </MenuPopup> | 309 | </MenuPopup> |
| 310 | </Menu> | 310 | </Menu> |
apps/web/src/components/sidebar/SidebarThreadHeader.tsx+1-1| ... | @@ -127,7 +127,7 @@ export function SidebarThreadHeader({ | ... | @@ -127,7 +127,7 @@ export function SidebarThreadHeader({ |
| 127 | {hasProjects ? ( | 127 | {hasProjects ? ( |
| 128 | <> | 128 | <> |
| 129 | {projectScope} | 129 | {projectScope} |
| 130 | <SidebarHeaderIconButton label="New project" onClick={onNewProject}> | 130 | <SidebarHeaderIconButton label="Add project" onClick={onNewProject}> |
| 131 | <FolderPlusIcon /> | 131 | <FolderPlusIcon /> |
| 132 | </SidebarHeaderIconButton> | 132 | </SidebarHeaderIconButton> |
| 133 | </> | 133 | </> |
apps/web/src/hooks/useNewProject.ts created+147| ... | @@ -0,0 +1,147 @@ | ||
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | ||
| 2 | import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects"; | ||
| 3 | import { | ||
| 4 | isAtomCommandInterrupted, | ||
| 5 | squashAtomCommandFailure, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 7 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 8 | import { useCallback } from "react"; | ||
| 9 | |||
| 10 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | ||
| 11 | import { waitForProject } from "~/state/entities"; | ||
| 12 | import { projectEnvironment } from "~/state/projects"; | ||
| 13 | import { sourceControlEnvironment } from "~/state/sourceControl"; | ||
| 14 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 15 | import { useNewThreadHandler } from "./useHandleNewThread"; | ||
| 16 | |||
| 17 | function errorMessage(error: unknown): string { | ||
| 18 | return error instanceof Error && error.message.trim().length > 0 | ||
| 19 | ? error.message | ||
| 20 | : "An error occurred."; | ||
| 21 | } | ||
| 22 | |||
| 23 | /** | ||
| 24 | * Starts a project from just a name. The server makes a folder under its | ||
| 25 | * `newProjectsRoot` with a README, an icon, and a first commit; this then | ||
| 26 | * opens a new thread draft in it. With `github`, it also publishes the | ||
| 27 | * repository as private, without holding up the draft. | ||
| 28 | * | ||
| 29 | * Resolves to whether the project was created. | ||
| 30 | */ | ||
| 31 | export function useNewProject() { | ||
| 32 | const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); | ||
| 33 | const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { | ||
| 34 | reportFailure: false, | ||
| 35 | }); | ||
| 36 | const handleNewThread = useNewThreadHandler(); | ||
| 37 | |||
| 38 | const publishToGitHub = useCallback( | ||
| 39 | async (input: { | ||
| 40 | readonly environmentId: EnvironmentId; | ||
| 41 | readonly workspaceRoot: string; | ||
| 42 | readonly account: string | null; | ||
| 43 | }) => { | ||
| 44 | const result = await publishRepository({ | ||
| 45 | environmentId: input.environmentId, | ||
| 46 | input: { | ||
| 47 | cwd: input.workspaceRoot, | ||
| 48 | provider: "github", | ||
| 49 | repository: getNewProjectGitHubRepository(input, input.workspaceRoot), | ||
| 50 | visibility: "private", | ||
| 51 | }, | ||
| 52 | }); | ||
| 53 | if (result._tag === "Failure") { | ||
| 54 | if (!isAtomCommandInterrupted(result)) { | ||
| 55 | toastManager.add( | ||
| 56 | stackedThreadToast({ | ||
| 57 | type: "error", | ||
| 58 | title: "Could not create the GitHub repository", | ||
| 59 | description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`, | ||
| 60 | }), | ||
| 61 | ); | ||
| 62 | } | ||
| 63 | return; | ||
| 64 | } | ||
| 65 | toastManager.add( | ||
| 66 | stackedThreadToast({ | ||
| 67 | type: "success", | ||
| 68 | title: "Published to GitHub", | ||
| 69 | description: result.value.repository.nameWithOwner, | ||
| 70 | }), | ||
| 71 | ); | ||
| 72 | }, | ||
| 73 | [publishRepository], | ||
| 74 | ); | ||
| 75 | |||
| 76 | return useCallback( | ||
| 77 | async (input: { | ||
| 78 | readonly environmentId: EnvironmentId; | ||
| 79 | readonly name: string; | ||
| 80 | readonly github: { readonly account: string | null } | null; | ||
| 81 | }): Promise<boolean> => { | ||
| 82 | const result = await createNew({ | ||
| 83 | environmentId: input.environmentId, | ||
| 84 | input: { name: input.name }, | ||
| 85 | }); | ||
| 86 | if (result._tag === "Failure") { | ||
| 87 | if (!isAtomCommandInterrupted(result)) { | ||
| 88 | toastManager.add( | ||
| 89 | stackedThreadToast({ | ||
| 90 | type: "error", | ||
| 91 | title: "Could not create the project", | ||
| 92 | description: errorMessage(squashAtomCommandFailure(result)), | ||
| 93 | }), | ||
| 94 | ); | ||
| 95 | } | ||
| 96 | return false; | ||
| 97 | } | ||
| 98 | |||
| 99 | const { projectId, workspaceRoot, commitError } = result.value; | ||
| 100 | // The folder sits in T3 Code's data directory, so always say where. | ||
| 101 | toastManager.add( | ||
| 102 | stackedThreadToast( | ||
| 103 | commitError === undefined | ||
| 104 | ? { type: "success", title: `Created ${input.name}`, description: workspaceRoot } | ||
| 105 | : { | ||
| 106 | type: "warning", | ||
| 107 | title: `Created ${input.name} without a first commit`, | ||
| 108 | description: `${commitError} The project is in ${workspaceRoot}.`, | ||
| 109 | }, | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | if (input.github) { | ||
| 113 | void publishToGitHub({ | ||
| 114 | environmentId: input.environmentId, | ||
| 115 | workspaceRoot, | ||
| 116 | account: input.github.account, | ||
| 117 | }); | ||
| 118 | } | ||
| 119 | |||
| 120 | const projectRef = scopeProjectRef(input.environmentId, projectId); | ||
| 121 | // Drafts key off the project's stored path, so wait for the create event | ||
| 122 | // to reach the store before opening one. | ||
| 123 | const project = await waitForProject(projectRef).catch((error: unknown) => { | ||
| 124 | toastManager.add( | ||
| 125 | stackedThreadToast({ | ||
| 126 | type: "error", | ||
| 127 | title: "Failed to open project", | ||
| 128 | description: `${errorMessage(error)} It will appear in the sidebar once this client catches up.`, | ||
| 129 | }), | ||
| 130 | ); | ||
| 131 | return null; | ||
| 132 | }); | ||
| 133 | if (project === null) return true; | ||
| 134 | await handleNewThread(projectRef).catch((error: unknown) => { | ||
| 135 | toastManager.add( | ||
| 136 | stackedThreadToast({ | ||
| 137 | type: "error", | ||
| 138 | title: "Failed to open project", | ||
| 139 | description: errorMessage(error), | ||
| 140 | }), | ||
| 141 | ); | ||
| 142 | }); | ||
| 143 | return true; | ||
| 144 | }, | ||
| 145 | [createNew, handleNewThread, publishToGitHub], | ||
| 146 | ); | ||
| 147 | } | ||
docs/user/source-control.md+9-1| ... | @@ -79,7 +79,15 @@ az extension add --name azure-devops | ... | @@ -79,7 +79,15 @@ az extension add --name azure-devops |
| 79 | az login | 79 | az login |
| 80 | ``` | 80 | ``` |
| 81 | 81 | ||
| 82 | ## Clone or publish a project | 82 | ## Start, clone, or publish a project |
| 83 | |||
| 84 | To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or | ||
| 85 | **New project** under **Add Project** on any client, and type a name. T3 Code makes a Git | ||
| 86 | repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README, | ||
| 87 | an icon, and a first commit, then opens a new thread in it. The folder is named after the project, | ||
| 88 | like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also | ||
| 89 | publish it. If Git has no name or email on that machine, the project is created without the | ||
| 90 | first commit. | ||
| 83 | 91 | ||
| 84 | Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting | 92 | Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting |
| 85 | provider or paste a Git URL, then choose where to save it. The project opens right away while the | 93 | provider or paste a Git URL, then choose where to save it. The project opens right away while the |
packages/client-runtime/src/operations/projects.ts+31| ... | @@ -8,6 +8,7 @@ import type { | ... | @@ -8,6 +8,7 @@ import type { |
| 8 | SourceControlProviderKind, | 8 | SourceControlProviderKind, |
| 9 | SourceControlRepositoryInfo, | 9 | SourceControlRepositoryInfo, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import { newProjectFolderName } from "@t3tools/shared/path"; | ||
| 11 | import * as Arr from "effect/Array"; | 12 | import * as Arr from "effect/Array"; |
| 12 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 13 | import * as Order from "effect/Order"; | 14 | import * as Order from "effect/Order"; |
| ... | @@ -250,6 +251,36 @@ export function getCloneDestinationPath( | ... | @@ -250,6 +251,36 @@ export function getCloneDestinationPath( |
| 250 | return `${ensureBrowseDirectoryPath(directoryPath)}${name}`; | 251 | return `${ensureBrowseDirectoryPath(directoryPath)}${name}`; |
| 251 | } | 252 | } |
| 252 | 253 | ||
| 254 | /** | ||
| 255 | * Where `projects.createNew` will put a project named `name`. The server adds | ||
| 256 | * `-2`, `-3`, ... when that folder is taken, so this is a preview. | ||
| 257 | */ | ||
| 258 | export function getNewProjectPathPreview(newProjectsRoot: string, name: string): string { | ||
| 259 | return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name)); | ||
| 260 | } | ||
| 261 | |||
| 262 | /** | ||
| 263 | * The GitHub account a new project would be published under, or null when | ||
| 264 | * GitHub is not ready on that environment. A ready GitHub with an unknown | ||
| 265 | * account still publishes; `gh` picks the signed-in user. | ||
| 266 | */ | ||
| 267 | export function getNewProjectGitHubTarget( | ||
| 268 | discovery: SourceControlDiscoveryResult | null, | ||
| 269 | ): { readonly account: string | null } | null { | ||
| 270 | if (!buildAddProjectRemoteSourceReadiness(discovery).github.ready) return null; | ||
| 271 | const github = discovery?.sourceControlProviders.find((provider) => provider.kind === "github"); | ||
| 272 | return { account: github ? Option.getOrNull(github.auth.account) : null }; | ||
| 273 | } | ||
| 274 | |||
| 275 | /** `owner/folder` for publishing a new project, or just the folder for `gh` to place. */ | ||
| 276 | export function getNewProjectGitHubRepository( | ||
| 277 | target: { readonly account: string | null }, | ||
| 278 | workspaceRoot: string, | ||
| 279 | ): string { | ||
| 280 | const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? ""; | ||
| 281 | return target.account ? `${target.account}/${folderName}` : folderName; | ||
| 282 | } | ||
| 283 | |||
| 253 | /** | 284 | /** |
| 254 | * Destination query after choosing a directory while the clone folder is | 285 | * Destination query after choosing a directory while the clone folder is |
| 255 | * pinned in the path input. Selecting an existing directory with the pinned | 286 | * pinned in the path input. Selecting an existing directory with the pinned |
packages/client-runtime/src/state/projectCommands.ts+7| ... | @@ -99,6 +99,13 @@ export function createProjectEnvironmentAtoms<R, E>( | ... | @@ -99,6 +99,13 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 99 | scheduler: projectScheduler, | 99 | scheduler: projectScheduler, |
| 100 | concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, | 100 | concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, |
| 101 | }), | 101 | }), |
| 102 | // Makes a new folder and repository from just a name, then the project. | ||
| 103 | createNew: createEnvironmentRpcCommand(runtime, { | ||
| 104 | label: "environment-data:projects:create-new", | ||
| 105 | tag: WS_METHODS.projectsCreateNew, | ||
| 106 | scheduler: projectScheduler, | ||
| 107 | concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, | ||
| 108 | }), | ||
| 102 | writeFile: createEnvironmentRpcCommand(runtime, { | 109 | writeFile: createEnvironmentRpcCommand(runtime, { |
| 103 | label: "environment-data:projects:write-file", | 110 | label: "environment-data:projects:write-file", |
| 104 | tag: WS_METHODS.projectsWriteFile, | 111 | tag: WS_METHODS.projectsWriteFile, |
packages/contracts/src/project.ts+14| ... | @@ -288,6 +288,20 @@ export const ProjectEnsureScratchResult = Schema.Struct({ | ... | @@ -288,6 +288,20 @@ export const ProjectEnsureScratchResult = Schema.Struct({ |
| 288 | }); | 288 | }); |
| 289 | export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type; | 289 | export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type; |
| 290 | 290 | ||
| 291 | /** A project started from just a name, in a new folder the server makes. */ | ||
| 292 | export const ProjectCreateNewInput = Schema.Struct({ | ||
| 293 | name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)), | ||
| 294 | }); | ||
| 295 | export type ProjectCreateNewInput = typeof ProjectCreateNewInput.Type; | ||
| 296 | |||
| 297 | export const ProjectCreateNewResult = Schema.Struct({ | ||
| 298 | projectId: ProjectId, | ||
| 299 | workspaceRoot: TrimmedNonEmptyString, | ||
| 300 | /** Why the first commit failed. The project and its files exist either way. */ | ||
| 301 | commitError: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 302 | }); | ||
| 303 | export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type; | ||
| 304 | |||
| 291 | export class ProjectWriteFileError extends Schema.TaggedError<ProjectWriteFileError>()( | 305 | export class ProjectWriteFileError extends Schema.TaggedError<ProjectWriteFileError>()( |
| 292 | "ProjectWriteFileError", | 306 | "ProjectWriteFileError", |
| 293 | { | 307 | { |
packages/contracts/src/rpc.ts+11| ... | @@ -159,6 +159,8 @@ import { | ... | @@ -159,6 +159,8 @@ import { |
| 159 | RelayClientStatusSchema, | 159 | RelayClientStatusSchema, |
| 160 | } from "./relayClient.ts"; | 160 | } from "./relayClient.ts"; |
| 161 | import { | 161 | import { |
| 162 | ProjectCreateNewInput, | ||
| 163 | ProjectCreateNewResult, | ||
| 162 | ProjectEnsureScratchResult, | 164 | ProjectEnsureScratchResult, |
| 163 | ProjectListEntriesError, | 165 | ProjectListEntriesError, |
| 164 | ProjectListEntriesInput, | 166 | ProjectListEntriesInput, |
| ... | @@ -295,6 +297,7 @@ export const WS_METHODS = { | ... | @@ -295,6 +297,7 @@ export const WS_METHODS = { |
| 295 | projectsSearchEntries: "projects.searchEntries", | 297 | projectsSearchEntries: "projects.searchEntries", |
| 296 | projectsWriteFile: "projects.writeFile", | 298 | projectsWriteFile: "projects.writeFile", |
| 297 | projectsEnsureScratch: "projects.ensureScratch", | 299 | projectsEnsureScratch: "projects.ensureScratch", |
| 300 | projectsCreateNew: "projects.createNew", | ||
| 298 | 301 | ||
| 299 | // Shell methods | 302 | // Shell methods |
| 300 | shellOpenInEditor: "shell.openInEditor", | 303 | shellOpenInEditor: "shell.openInEditor", |
| ... | @@ -1003,6 +1006,13 @@ const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { | ... | @@ -1003,6 +1006,13 @@ const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { |
| 1003 | error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), | 1006 | error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), |
| 1004 | }); | 1007 | }); |
| 1005 | 1008 | ||
| 1009 | // Makes a folder under ServerConfig.newProjectsRoot with a first commit, then the project. | ||
| 1010 | const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, { | ||
| 1011 | payload: ProjectCreateNewInput, | ||
| 1012 | success: ProjectCreateNewResult, | ||
| 1013 | error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), | ||
| 1014 | }); | ||
| 1015 | |||
| 1006 | const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { | 1016 | const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { |
| 1007 | payload: LaunchEditorInput, | 1017 | payload: LaunchEditorInput, |
| 1008 | error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), | 1018 | error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), |
| ... | @@ -1521,6 +1531,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -1521,6 +1531,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1521 | WsProjectsSearchContentsRpc, | 1531 | WsProjectsSearchContentsRpc, |
| 1522 | WsProjectsSearchEntriesRpc, | 1532 | WsProjectsSearchEntriesRpc, |
| 1523 | WsProjectsEnsureScratchRpc, | 1533 | WsProjectsEnsureScratchRpc, |
| 1534 | WsProjectsCreateNewRpc, | ||
| 1524 | WsProjectsWriteFileRpc, | 1535 | WsProjectsWriteFileRpc, |
| 1525 | WsShellOpenInEditorRpc, | 1536 | WsShellOpenInEditorRpc, |
| 1526 | WsFilesystemBrowseRpc, | 1537 | WsFilesystemBrowseRpc, |
packages/contracts/src/server.ts+5| ... | @@ -617,6 +617,11 @@ export const ServerConfig = Schema.Struct({ | ... | @@ -617,6 +617,11 @@ export const ServerConfig = Schema.Struct({ |
| 617 | * and whose data dir is outside a Git checkout. | 617 | * and whose data dir is outside a Git checkout. |
| 618 | */ | 618 | */ |
| 619 | scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), | 619 | scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), |
| 620 | /** | ||
| 621 | * Folder that holds projects started from just a name. Present only on | ||
| 622 | * servers that answer projects.createNew. | ||
| 623 | */ | ||
| 624 | newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 620 | /** | 625 | /** |
| 621 | * Palettes published by this environment's machine. Never sent in a config | 626 | * Palettes published by this environment's machine. Never sent in a config |
| 622 | * snapshot: the theme stream emits the current set before any change, so a | 627 | * snapshot: the theme stream emits the current set before any change, so a |
packages/shared/src/path.test.ts+14| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | isUncPath, | 4 | isUncPath, |
| 5 | isWindowsAbsolutePath, | 5 | isWindowsAbsolutePath, |
| 6 | isWindowsDrivePath, | 6 | isWindowsDrivePath, |
| 7 | newProjectFolderName, | ||
| 7 | normalizeProjectPathForComparison, | 8 | normalizeProjectPathForComparison, |
| 8 | normalizeProjectPathForDispatch, | 9 | normalizeProjectPathForDispatch, |
| 9 | } from "./path.ts"; | 10 | } from "./path.ts"; |
| ... | @@ -43,4 +44,17 @@ describe("path helpers", () => { | ... | @@ -43,4 +44,17 @@ describe("path helpers", () => { |
| 43 | // Non-root drive paths keep their trailing separator trimmed as before. | 44 | // Non-root drive paths keep their trailing separator trimmed as before. |
| 44 | expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); | 45 | expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); |
| 45 | }); | 46 | }); |
| 47 | |||
| 48 | it("names a new project's folder from any typed name", () => { | ||
| 49 | expect(newProjectFolderName("Pinball Stats")).toBe("pinball-stats"); | ||
| 50 | expect(newProjectFolderName(" Café & Crème! ")).toBe("cafe-creme"); | ||
| 51 | expect(newProjectFolderName("../../etc")).toBe("etc"); | ||
| 52 | // Nothing usable left, so the server falls back to a fixed name. | ||
| 53 | expect(newProjectFolderName("🎱🎱")).toBe("project"); | ||
| 54 | expect(newProjectFolderName(`${"a".repeat(63)} b`)).toBe("a".repeat(63)); | ||
| 55 | // Windows cannot make folders with device names. | ||
| 56 | expect(newProjectFolderName("Con")).toBe("con-project"); | ||
| 57 | expect(newProjectFolderName("LPT1")).toBe("lpt1-project"); | ||
| 58 | expect(newProjectFolderName("console")).toBe("console"); | ||
| 59 | }); | ||
| 46 | }); | 60 | }); |
packages/shared/src/path.ts+21| ... | @@ -53,3 +53,24 @@ export function normalizeProjectPathForComparison(value: string): string { | ... | @@ -53,3 +53,24 @@ export function normalizeProjectPathForComparison(value: string): string { |
| 53 | } | 53 | } |
| 54 | return normalized; | 54 | return normalized; |
| 55 | } | 55 | } |
| 56 | |||
| 57 | // Windows refuses these as file names, with or without an extension. | ||
| 58 | const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/; | ||
| 59 | |||
| 60 | /** | ||
| 61 | * Folder name for a project started from just a name ("Pinball Stats" becomes | ||
| 62 | * "pinball-stats"). The server uses it for `projects.createNew`, and clients | ||
| 63 | * use it to show the path before the server makes it. | ||
| 64 | */ | ||
| 65 | export function newProjectFolderName(name: string): string { | ||
| 66 | const slug = name | ||
| 67 | .normalize("NFKD") | ||
| 68 | .replace(/[̀-ͯ]/g, "") | ||
| 69 | .toLowerCase() | ||
| 70 | .replace(/[^a-z0-9]+/g, "-") | ||
| 71 | .replace(/^-+/, "") | ||
| 72 | .slice(0, 64) | ||
| 73 | .replace(/-+$/, ""); | ||
| 74 | if (slug.length === 0) return "project"; | ||
| 75 | return WINDOWS_RESERVED_NAME.test(slug) ? `${slug}-project` : slug; | ||
| 76 | } |