authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-01 00:15:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 00:15:28-07:00
log148e6deea046658639aae9fef5b349781cec39d1
tree51071be1058f309ae4bc461e80b8ed68a1945a81
parent41a8239849d7e6523c08edb363efdeccfb150be5
signature Signed by PGP key B5690EEEBB952194

feat: start a new project from just a name (#14527)

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
5353import { HomeRouteScreen } from "./features/home/HomeRouteScreen";
5454import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute";
5555import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";
56import { AddProjectNewRoute } from "./features/projects/AddProjectNewRoute";
5657import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";
5758import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute";
5859import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen";
......@@ -459,6 +460,10 @@ const NewTaskSheetStack = createNativeStackNavigator({
459460 screen: AddProjectLocalRoute,
460461 linking: "add-project/local",
461462 }),
463 AddProjectNew: createNativeStackScreen({
464 screen: AddProjectNewRoute,
465 linking: "add-project/new",
466 }),
462467 },
463468});
464469
apps/mobile/src/features/projects/AddProjectNewRoute.tsx created+12
......@@ -0,0 +1,12 @@
1import type { StaticScreenProps } from "@react-navigation/native";
2import { AddProjectNewScreen } from "./AddProjectScreen";
3
4type AddProjectNewRouteParams = {
5 readonly environmentId?: string | string[];
6};
7
8export 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 {
1515 getCloneDestinationPath,
1616 getCloneDirectoryName,
1717 getDefaultCloneUrl,
18 getNewProjectGitHubRepository,
19 getNewProjectGitHubTarget,
20 getNewProjectPathPreview,
1821 normalizePastedCloneUrl,
1922 resolveAddProjectPath,
2023 sortAddProjectProviderSources,
......@@ -61,6 +64,7 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App
6164import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
6265import { ErrorBanner } from "../../components/ErrorBanner";
6366import { SourceControlIcon } from "../../components/SourceControlIcon";
67import { ThemedSwitch } from "../../components/ThemedSwitch";
6468import { uuidv4 } from "../../lib/uuid";
6569import { useAtomCommand } from "../../state/use-atom-command";
6670import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
......@@ -77,6 +81,8 @@ interface EnvironmentOption {
7781 readonly platform: string;
7882 readonly machine: EnvironmentMachineKind;
7983 readonly baseDirectory: string | null;
84 /** Folder for projects started from just a name; null on servers without it. */
85 readonly newProjectsRoot: string | null;
8086 readonly connectionState: EnvironmentConnectionPhase;
8187 readonly connectionError: string | null;
8288 readonly connectionErrorTraceId: string | null;
......@@ -399,6 +405,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
399405 platform: platformFromOs(config?.environment.platform.os ?? null),
400406 machine: resolveEnvironmentMachineKind(config ?? null),
401407 baseDirectory: config?.settings.addProjectBaseDirectory ?? null,
408 newProjectsRoot: config?.newProjectsRoot ?? null,
402409 connectionState: runtime?.connectionState ?? "available",
403410 connectionError: runtime?.connectionError ?? null,
404411 connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null,
......@@ -578,6 +585,28 @@ export function AddProjectSourceScreen() {
578585 {selectedEnvironment ? (
579586 <>
580587 <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}
581610 <ListRow
582611 title="Local folder"
583612 subtitle="Browse a folder on disk"
......@@ -589,7 +618,7 @@ export function AddProjectSourceScreen() {
589618 type="monochrome"
590619 />
591620 }
592 isFirst
621 isFirst={selectedEnvironment.newProjectsRoot === null}
593622 onPress={() =>
594623 navigation.dispatch(
595624 StackActions.push("AddProjectLocal", {
......@@ -898,6 +927,236 @@ function FolderBrowser(props: {
898927 );
899928}
900929
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 */
934export 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
9011160export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {
9021161 const environment = useEnvironmentFromParam(props.environmentId);
9031162 const createProject = useCreateProject(environment);
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -116,6 +116,7 @@ export const RPC_REQUIRED_SCOPES = {
116116 [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
117117 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
118118 [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope,
119 [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope,
119120 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
120121 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
121122 [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,
apps/server/src/project/NewProject.test.ts created+140
......@@ -0,0 +1,140 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7
8import * as ServerConfig from "../config.ts";
9import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
10import * as VcsProcess from "../vcs/VcsProcess.ts";
11import { createNewProjectFolder } from "./NewProject.ts";
12
13const 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
19const 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.
34const 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
63const 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
70it.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 */
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";
14import * as PlatformError from "effect/PlatformError";
15
16import { newProjectFolderName } from "@t3tools/shared/path";
17import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
18
19// Tailwind 600 shades: dark enough for white initials on every hue.
20const 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
38const MAX_FOLDER_ATTEMPTS = 100;
39
40function 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. */
49function 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
71function 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.
84function 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 */
102export 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) => {
55565556 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
55575557 );
55585558
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
55595618 it.effect("advertises the usable file manager and its reveal label", () =>
55605619 Effect.gen(function* () {
55615620 yield* buildAppUnderTest({
apps/server/src/ws.ts+50
......@@ -49,6 +49,7 @@ import {
4949 OrchestrationGetTurnDiffError,
5050 ORCHESTRATION_WS_METHODS,
5151 ProjectId,
52 type ProjectCreateNewInput,
5253 type ProjectEntriesFailure,
5354 type ProjectFileFailure,
5455 type ProjectFileOperation,
......@@ -143,6 +144,7 @@ import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";
143144import * as ReviewService from "./review/ReviewService.ts";
144145import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
145146import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
147import * as NewProject from "./project/NewProject.ts";
146148import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
147149import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
148150import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";
......@@ -2018,6 +2020,49 @@ const makeWsRpcLayer = (
20182020 );
20192021 });
20202022
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
20212066 // Only clients that answer /usage-limits themselves see it in the catalogs;
20222067 // an older client would send the injected command to the provider.
20232068 const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) =>
......@@ -2082,6 +2127,7 @@ const makeWsRpcLayer = (
20822127 threadSnapshotPagination: true,
20832128 reasoningMessages: true,
20842129 ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }),
2130 newProjectsRoot,
20852131 };
20862132 });
20872133
......@@ -3252,6 +3298,10 @@ const makeWsRpcLayer = (
32523298 observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, {
32533299 "rpc.aggregate": "orchestration",
32543300 }),
3301 [WS_METHODS.projectsCreateNew]: (input) =>
3302 observeRpcEffect(WS_METHODS.projectsCreateNew, createNewProject(input), {
3303 "rpc.aggregate": "orchestration",
3304 }),
32553305 [WS_METHODS.projectCloneCancel]: (input) =>
32563306 observeRpcEffect(
32573307 WS_METHODS.projectCloneCancel,
apps/web/src/components/CommandPalette.tsx+366-33
......@@ -10,6 +10,9 @@ import {
1010 getCloneDestinationPath,
1111 getCloneDirectoryName,
1212 getDefaultCloneUrl,
13 getNewProjectGitHubRepository,
14 getNewProjectGitHubTarget,
15 getNewProjectPathPreview,
1316 normalizePastedCloneUrl,
1417} from "@t3tools/client-runtime/operations/projects";
1518import { connectionStatusText } from "@t3tools/client-runtime/connection";
......@@ -43,8 +46,11 @@ import * as Option from "effect/Option";
4346import {
4447 ArrowLeftIcon,
4548 ChartNoAxesColumnIcon,
49 CheckIcon,
50 ChevronRightIcon,
4651 CornerLeftUpIcon,
4752 FileSearchIcon,
53 FolderGit2Icon,
4854 FolderIcon,
4955 FolderPlusIcon,
5056 MessageSquareDashedIcon,
......@@ -100,6 +106,7 @@ import { sourceControlEnvironment } from "../state/sourceControl";
100106import { useAtomCommand } from "../state/use-atom-command";
101107import { useAtomQueryRunner } from "../state/use-atom-query-runner";
102108import { useScratchProject } from "../hooks/useScratchProject";
109import { useNewProject } from "../hooks/useNewProject";
103110import { isScratchProject } from "@t3tools/client-runtime/state/projects";
104111import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
105112import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
......@@ -170,6 +177,7 @@ import { CommandPaletteContent } from "./CommandPaletteContent";
170177import { CommandPaletteResults } from "./CommandPaletteResults";
171178import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons";
172179import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
180import { Checkbox } from "./ui/checkbox";
173181import { ProjectFavicon } from "./ProjectFavicon";
174182import { ProjectFilePicker } from "./files/ProjectFilePicker";
175183import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";
......@@ -862,6 +870,17 @@ function OpenCommandPaletteDialog(props: {
862870 );
863871 const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false);
864872 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();
865884 const cloneLookupGeneration = useRef(0);
866885 const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);
867886 const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);
......@@ -1073,9 +1092,15 @@ function OpenCommandPaletteDialog(props: {
10731092 getFilesystemBrowsePath(
10741093 query,
10751094 browseEnvironmentPlatform,
1076 browseEnvironmentId !== null && !isRemoteProjectRepositoryStep,
1095 browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null,
10771096 ),
1078 [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query],
1097 [
1098 browseEnvironmentId,
1099 browseEnvironmentPlatform,
1100 isRemoteProjectRepositoryStep,
1101 newProjectFlow,
1102 query,
1103 ],
10791104 );
10801105 const isBrowsing = browsePath.isBrowsing;
10811106 const browseDirectoryPath = browsePath.directoryPath;
......@@ -1469,8 +1494,12 @@ function OpenCommandPaletteDialog(props: {
14691494 function popView(): void {
14701495 browseNavigation.invalidate();
14711496 setAddProjectCloneFlow(null);
1497 setNewProjectFlow(null);
14721498 if (viewStack.length <= 1) {
14731499 setAddProjectEnvironmentId(null);
1500 } else if (newProjectFlow?.sourcesEnvironmentId) {
1501 // The machine switcher may have moved off the sources view's machine.
1502 setAddProjectEnvironmentId(newProjectFlow.sourcesEnvironmentId);
14741503 }
14751504 setViewStack((previousViews) => previousViews.slice(0, -1));
14761505 setHighlightedItemValue(null);
......@@ -1531,6 +1560,28 @@ function OpenCommandPaletteDialog(props: {
15311560 [pushPaletteView],
15321561 );
15331562
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
15341585 const openSourceControlSettings = useCallback(() => {
15351586 setOpen(false);
15361587 void navigate({ to: "/settings/source-control" });
......@@ -1556,6 +1607,21 @@ function OpenCommandPaletteDialog(props: {
15561607 },
15571608 ];
15581609
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
15591625 const orderedSources: ReadonlyArray<AddProjectRemoteSource> = [
15601626 "url",
15611627 ...sortAddProjectProviderSources(readinessBySource),
......@@ -1626,7 +1692,13 @@ function OpenCommandPaletteDialog(props: {
16261692
16271693 return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }];
16281694 },
1629 [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone],
1695 [
1696 newProjectsRootFor,
1697 openSourceControlSettings,
1698 startAddProjectBrowse,
1699 startAddProjectClone,
1700 startNewProject,
1701 ],
16301702 );
16311703
16321704 const startAddProjectSourceSelection = useCallback(
......@@ -1665,24 +1737,36 @@ function OpenCommandPaletteDialog(props: {
16651737 ],
16661738 );
16671739
1668 const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map(
1669 (option) => ({
1670 kind: "action",
1671 value: `action:add-project:environment:${option.environmentId}`,
1672 searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""],
1673 title: option.label,
1674 description: option.isConnected
1675 ? option.isPrimary
1676 ? "This device"
1677 : option.environmentId
1678 : option.status,
1679 disabled: !option.isConnected,
1680 icon: <EnvironmentMachineIcon kind={option.machine} className={ITEM_ICON_CLASS} />,
1681 keepOpen: true,
1682 run: async () => {
1683 startAddProjectSourceSelection(option.environmentId);
1684 },
1685 }),
1740 const buildEnvironmentItem = (
1741 option: AddProjectEnvironmentOption,
1742 value: string,
1743 run: (environmentId: EnvironmentId) => void,
1744 ): CommandPaletteActionItem => ({
1745 kind: "action",
1746 value,
1747 searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""],
1748 title: option.label,
1749 description: option.isConnected
1750 ? option.isPrimary
1751 ? "This device"
1752 : option.environmentId
1753 : option.status,
1754 disabled: !option.isConnected,
1755 icon: <EnvironmentMachineIcon kind={option.machine} className={ITEM_ICON_CLASS} />,
1756 keepOpen: true,
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,
16861770 );
16871771
16881772 const addProjectEnvironmentGroups = useMemo<CommandPaletteView["groups"]>(
......@@ -1724,12 +1808,20 @@ function OpenCommandPaletteDialog(props: {
17241808 startAddProjectSourceSelection,
17251809 ]);
17261810
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
17271818 useLayoutEffect(() => {
17281819 if (openIntent?.kind !== "search") return;
17291820 browseNavigation.invalidate();
17301821 cloneLookupGeneration.current += 1;
17311822 setIsRemoteProjectLookingUp(false);
17321823 setAddProjectCloneFlow(null);
1824 setNewProjectFlow(null);
17331825 setViewStack([]);
17341826 setLinkedThreadSearch(openIntent);
17351827 setQuery(openIntent.query);
......@@ -1751,6 +1843,7 @@ function OpenCommandPaletteDialog(props: {
17511843 clearOpenIntent();
17521844 browseNavigation.invalidate();
17531845 setAddProjectCloneFlow(null);
1846 setNewProjectFlow(null);
17541847 setViewStack([]);
17551848 setQuery("");
17561849 const currentPrefix =
......@@ -1949,6 +2042,20 @@ function OpenCommandPaletteDialog(props: {
19492042 },
19502043 });
19512044
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
19522059 actionItems.push({
19532060 kind: "action",
19542061 value: "action:add-project",
......@@ -2081,6 +2188,7 @@ function OpenCommandPaletteDialog(props: {
20812188 cloneLookupGeneration.current += 1;
20822189 setIsRemoteProjectLookingUp(false);
20832190 setAddProjectCloneFlow(null);
2191 setNewProjectFlow(null);
20842192 setViewStack([]);
20852193 pushPaletteView({
20862194 addonIcon: <PaletteIcon className={ADDON_ICON_CLASS} />,
......@@ -2398,6 +2506,34 @@ function OpenCommandPaletteDialog(props: {
23982506 ],
23992507 );
24002508
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
24012537 function getDefaultCloneParentPath(environmentId: EnvironmentId): string {
24022538 return getAddProjectInitialQueryForEnvironment(environmentId);
24032539 }
......@@ -2681,8 +2817,135 @@ function OpenCommandPaletteDialog(props: {
26812817 };
26822818 }, [addProjectCloneFlow]);
26832819
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
26842941 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") {
26862949 displayedGroups = [];
26872950 } else if (addProjectCloneFlow?.step === "confirm") {
26882951 displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups;
......@@ -2691,8 +2954,10 @@ function OpenCommandPaletteDialog(props: {
26912954 }
26922955
26932956 const inputPlaceholder =
2694 remoteProjectInputPlaceholder(addProjectCloneFlow) ??
2695 getCommandPaletteInputPlaceholder(paletteMode);
2957 newProjectFlow !== null
2958 ? "Project name"
2959 : (remoteProjectInputPlaceholder(addProjectCloneFlow) ??
2960 getCommandPaletteInputPlaceholder(paletteMode));
26962961 const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse";
26972962 const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false;
26982963 const canSubmitBrowsePath =
......@@ -2799,6 +3064,21 @@ function OpenCommandPaletteDialog(props: {
27993064 return;
28003065 }
28013066
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
28023082 const shouldSubmitBrowsePath =
28033083 canSubmitBrowsePath &&
28043084 event.key === "Enter" &&
......@@ -2957,7 +3237,34 @@ function OpenCommandPaletteDialog(props: {
29573237 ]);
29583238
29593239 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" ? (
29613268 <Tooltip>
29623269 <TooltipTrigger
29633270 render={
......@@ -3029,11 +3336,17 @@ function OpenCommandPaletteDialog(props: {
30293336 ) : null;
30303337
30313338 const footerActionLabel =
3032 addProjectCloneFlow?.step === "repository"
3033 ? (remoteProjectButtonLabel ?? "Continue")
3034 : !canSubmitBrowsePath || hasHighlightedBrowseItem
3035 ? "Select"
3036 : undefined;
3339 newProjectFlow !== null
3340 ? highlightedItemValue === null
3341 ? "Create"
3342 : highlightedItemValue === newProjectGitHubToggleValue
3343 ? "Toggle"
3344 : "Select"
3345 : addProjectCloneFlow?.step === "repository"
3346 ? (remoteProjectButtonLabel ?? "Continue")
3347 : !canSubmitBrowsePath || hasHighlightedBrowseItem
3348 ? "Select"
3349 : undefined;
30373350
30383351 const footerTrailing = canOpenProjectFromFileManager ? (
30393352 <CommandFooterAction
......@@ -3048,9 +3361,11 @@ function OpenCommandPaletteDialog(props: {
30483361
30493362 return (
30503363 <CommandPaletteContent
3051 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`}
3364 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`}
30523365 aria-label="Command palette"
3053 autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"}
3366 autoHighlight={
3367 isBrowsing || isRemoteProjectCloneFlow || newProjectFlow !== null ? false : "always"
3368 }
30543369 footerActionLabel={footerActionLabel}
30553370 footerTrailing={footerTrailing}
30563371 inputAccessory={inputAccessory}
......@@ -3058,7 +3373,7 @@ function OpenCommandPaletteDialog(props: {
30583373 // The submit button is absolutely positioned over the field, so the
30593374 // inner input must reserve enough room for the full action label.
30603375 className:
3061 addProjectCloneFlow?.step === "repository"
3376 addProjectCloneFlow?.step === "repository" || newProjectFlow !== null
30623377 ? "*:data-[slot=autocomplete-input]:pe-32!"
30633378 : isBrowsing
30643379 ? browseInputEndPaddingClass({
......@@ -3093,6 +3408,24 @@ function OpenCommandPaletteDialog(props: {
30933408 showBackHint={isSubmenu}
30943409 value={query}
30953410 >
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}
30963429 {remoteProjectContext ? (
30973430 <div className="p-2 pb-0">
30983431 <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) {
4141 <CommandList>
4242 {props.groups.map((group) => (
4343 <CommandGroup items={group.items} key={group.value}>
44 <CommandGroupLabel>{group.label}</CommandGroupLabel>
44 {group.label ? <CommandGroupLabel>{group.label}</CommandGroupLabel> : null}
4545 <CommandCollection>
4646 {(item) =>
4747 item.disabled ? (
apps/web/src/components/chat/DraftHeroHeadline.tsx+1-1
......@@ -304,7 +304,7 @@ export function DraftHeroHeadline({
304304 {projectPickerEntries.length > 0 ? <MenuSeparator /> : null}
305305 <MenuItem onClick={openAddProject}>
306306 <FolderPlusIcon />
307 New project
307 Add project
308308 </MenuItem>
309309 </MenuPopup>
310310 </Menu>
apps/web/src/components/sidebar/SidebarThreadHeader.tsx+1-1
......@@ -127,7 +127,7 @@ export function SidebarThreadHeader({
127127 {hasProjects ? (
128128 <>
129129 {projectScope}
130 <SidebarHeaderIconButton label="New project" onClick={onNewProject}>
130 <SidebarHeaderIconButton label="Add project" onClick={onNewProject}>
131131 <FolderPlusIcon />
132132 </SidebarHeaderIconButton>
133133 </>
apps/web/src/hooks/useNewProject.ts created+147
......@@ -0,0 +1,147 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7import type { EnvironmentId } from "@t3tools/contracts";
8import { useCallback } from "react";
9
10import { stackedThreadToast, toastManager } from "~/components/ui/toast";
11import { waitForProject } from "~/state/entities";
12import { projectEnvironment } from "~/state/projects";
13import { sourceControlEnvironment } from "~/state/sourceControl";
14import { useAtomCommand } from "~/state/use-atom-command";
15import { useNewThreadHandler } from "./useHandleNewThread";
16
17function 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 */
31export 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
7979az login
8080```
8181
82## Clone or publish a project
82## Start, clone, or publish a project
83
84To 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
86repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README,
87an icon, and a first commit, then opens a new thread in it. The folder is named after the project,
88like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also
89publish it. If Git has no name or email on that machine, the project is created without the
90first commit.
8391
8492Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting
8593provider 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 {
88 SourceControlProviderKind,
99 SourceControlRepositoryInfo,
1010} from "@t3tools/contracts";
11import { newProjectFolderName } from "@t3tools/shared/path";
1112import * as Arr from "effect/Array";
1213import * as Option from "effect/Option";
1314import * as Order from "effect/Order";
......@@ -250,6 +251,36 @@ export function getCloneDestinationPath(
250251 return `${ensureBrowseDirectoryPath(directoryPath)}${name}`;
251252}
252253
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 */
258export 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 */
267export 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. */
276export 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
253284/**
254285 * Destination query after choosing a directory while the clone folder is
255286 * 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>(
9999 scheduler: projectScheduler,
100100 concurrency: { mode: "serial", key: ({ environmentId }) => environmentId },
101101 }),
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 }),
102109 writeFile: createEnvironmentRpcCommand(runtime, {
103110 label: "environment-data:projects:write-file",
104111 tag: WS_METHODS.projectsWriteFile,
packages/contracts/src/project.ts+14
......@@ -288,6 +288,20 @@ export const ProjectEnsureScratchResult = Schema.Struct({
288288});
289289export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type;
290290
291/** A project started from just a name, in a new folder the server makes. */
292export const ProjectCreateNewInput = Schema.Struct({
293 name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)),
294});
295export type ProjectCreateNewInput = typeof ProjectCreateNewInput.Type;
296
297export 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});
303export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type;
304
291305export class ProjectWriteFileError extends Schema.TaggedError<ProjectWriteFileError>()(
292306 "ProjectWriteFileError",
293307 {
packages/contracts/src/rpc.ts+11
......@@ -159,6 +159,8 @@ import {
159159 RelayClientStatusSchema,
160160} from "./relayClient.ts";
161161import {
162 ProjectCreateNewInput,
163 ProjectCreateNewResult,
162164 ProjectEnsureScratchResult,
163165 ProjectListEntriesError,
164166 ProjectListEntriesInput,
......@@ -295,6 +297,7 @@ export const WS_METHODS = {
295297 projectsSearchEntries: "projects.searchEntries",
296298 projectsWriteFile: "projects.writeFile",
297299 projectsEnsureScratch: "projects.ensureScratch",
300 projectsCreateNew: "projects.createNew",
298301
299302 // Shell methods
300303 shellOpenInEditor: "shell.openInEditor",
......@@ -1003,6 +1006,13 @@ const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, {
10031006 error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]),
10041007});
10051008
1009// Makes a folder under ServerConfig.newProjectsRoot with a first commit, then the project.
1010const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, {
1011 payload: ProjectCreateNewInput,
1012 success: ProjectCreateNewResult,
1013 error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]),
1014});
1015
10061016const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
10071017 payload: LaunchEditorInput,
10081018 error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]),
......@@ -1521,6 +1531,7 @@ export const WsRpcGroup = RpcGroup.make(
15211531 WsProjectsSearchContentsRpc,
15221532 WsProjectsSearchEntriesRpc,
15231533 WsProjectsEnsureScratchRpc,
1534 WsProjectsCreateNewRpc,
15241535 WsProjectsWriteFileRpc,
15251536 WsShellOpenInEditorRpc,
15261537 WsFilesystemBrowseRpc,
packages/contracts/src/server.ts+5
......@@ -617,6 +617,11 @@ export const ServerConfig = Schema.Struct({
617617 * and whose data dir is outside a Git checkout.
618618 */
619619 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),
620625 /**
621626 * Palettes published by this environment's machine. Never sent in a config
622627 * 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 {
44 isUncPath,
55 isWindowsAbsolutePath,
66 isWindowsDrivePath,
7 newProjectFolderName,
78 normalizeProjectPathForComparison,
89 normalizeProjectPathForDispatch,
910} from "./path.ts";
......@@ -43,4 +44,17 @@ describe("path helpers", () => {
4344 // Non-root drive paths keep their trailing separator trimmed as before.
4445 expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo");
4546 });
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 });
4660});
packages/shared/src/path.ts+21
......@@ -53,3 +53,24 @@ export function normalizeProjectPathForComparison(value: string): string {
5353 }
5454 return normalized;
5555}
56
57// Windows refuses these as file names, with or without an extension.
58const 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 */
65export 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}