diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7f508f486ce3afd4e555c9eba66f466309e6ffc3..4baeadb94238d0b7362b1a1ddf3b877fa76f909d 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -53,6 +53,7 @@ import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewR import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute"; import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; +import { AddProjectNewRoute } from "./features/projects/AddProjectNewRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; @@ -459,6 +460,10 @@ const NewTaskSheetStack = createNativeStackNavigator({ screen: AddProjectLocalRoute, linking: "add-project/local", }), + AddProjectNew: createNativeStackScreen({ + screen: AddProjectNewRoute, + linking: "add-project/new", + }), }, }); diff --git a/apps/mobile/src/features/projects/AddProjectNewRoute.tsx b/apps/mobile/src/features/projects/AddProjectNewRoute.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8bbd26f2e114f8e1c6eb664837b3f3c31733bd87 --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectNewRoute.tsx @@ -0,0 +1,12 @@ +import type { StaticScreenProps } from "@react-navigation/native"; +import { AddProjectNewScreen } from "./AddProjectScreen"; + +type AddProjectNewRouteParams = { + readonly environmentId?: string | string[]; +}; + +export function AddProjectNewRoute({ + route, +}: StaticScreenProps) { + return ; +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5b38cfa7bdd87f87a6f43524d956828f665e80ff..7cdcd0d334935a53dcbe087ff6bf0dcf1f271650 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -15,6 +15,9 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + getNewProjectGitHubRepository, + getNewProjectGitHubTarget, + getNewProjectPathPreview, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -61,6 +64,7 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; +import { ThemedSwitch } from "../../components/ThemedSwitch"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; @@ -77,6 +81,8 @@ interface EnvironmentOption { readonly platform: string; readonly machine: EnvironmentMachineKind; readonly baseDirectory: string | null; + /** Folder for projects started from just a name; null on servers without it. */ + readonly newProjectsRoot: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; @@ -399,6 +405,7 @@ function useEnvironmentOptions(): ReadonlyArray { platform: platformFromOs(config?.environment.platform.os ?? null), machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, + newProjectsRoot: config?.newProjectsRoot ?? null, connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, @@ -578,6 +585,28 @@ export function AddProjectSourceScreen() { {selectedEnvironment ? ( <> + {selectedEnvironment.newProjectsRoot !== null ? ( + + } + isFirst + onPress={() => + navigation.dispatch( + StackActions.push("AddProjectNew", { + environmentId: selectedEnvironment.environmentId, + }), + ) + } + /> + ) : null} } - isFirst + isFirst={selectedEnvironment.newProjectsRoot === null} onPress={() => navigation.dispatch( StackActions.push("AddProjectLocal", { @@ -898,6 +927,236 @@ function FolderBrowser(props: { ); } +/** + * New project: a name, then the server makes the folder, README, icon, and + * first commit. Optionally publishes it to GitHub as a private repository. + */ +export function AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) { + const navigation = useNavigation(); + // Starts on the machine picked in Add project; the rows below switch it. + const environmentOptions = useEnvironmentOptions().filter( + (option) => option.newProjectsRoot !== null, + ); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( + () => stringParam(props.environmentId) as EnvironmentId | null, + ); + const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId); + const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { + reportFailure: false, + }); + const discoveryState = useEnvironmentQuery( + environment === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: environment.environmentId, + input: {}, + }), + ); + const githubTarget = getNewProjectGitHubTarget(discoveryState.data); + const [name, setName] = useState(""); + const [publishesToGitHub, setPublishesToGitHub] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const trimmedName = name.trim(); + const pathPreview = + environment?.newProjectsRoot != null + ? getNewProjectPathPreview(environment.newProjectsRoot, trimmedName) + : null; + + // Shown when there is a choice, or when the selected machine went away and + // another one can take over. + const showMachines = + environmentOptions.length > 1 || (environment === null && environmentOptions.length > 0); + const machineRows = showMachines ? ( + + {environmentOptions.map((option, index) => { + const selected = option.environmentId === environment?.environmentId; + return ( + + } + selected={selected} + // The create in flight keeps the machine it started on. + disabled={isSubmitting} + isFirst={index === 0} + right={ + selected ? ( + + ) : null + } + onPress={() => setSelectedEnvironmentId(option.environmentId)} + /> + ); + })} + + ) : null; + + // State lags a render behind, so a double tap could start a second create. + const submittingRef = useRef(false); + const submit = async () => { + if (!environment || trimmedName.length === 0 || submittingRef.current) return; + submittingRef.current = true; + setError(null); + setIsSubmitting(true); + try { + const result = await createNew({ + environmentId: environment.environmentId, + input: { name: trimmedName }, + }); + if (AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); + return; + } + const { projectId, workspaceRoot, commitError } = result.value; + if (commitError !== undefined) { + Alert.alert("Created without a first commit", commitError); + } + if (publishesToGitHub && githubTarget !== null) { + void publishRepository({ + environmentId: environment.environmentId, + input: { + cwd: workspaceRoot, + provider: "github", + repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot), + visibility: "private", + }, + }).then((publishResult) => { + if (AsyncResult.isFailure(publishResult)) { + Alert.alert( + "Could not create the GitHub repository", + errorMessage(Cause.squash(publishResult.cause)), + ); + } + }); + } + // The draft screen resolves its project from the client store, so it + // must not open before the create event has arrived. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + // The project exists, so clearing the name keeps Create from making a `-2` copy. + setName(""); + setError( + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + return; + } + openNewTaskDraft(navigation, { + environmentId: environment.environmentId, + projectId, + title: trimmedName, + }); + } finally { + submittingRef.current = false; + setIsSubmitting(false); + } + }; + + return ( + + {error ? : null} + {environment ? ( + <> + void submit()} + /> + {pathPreview !== null ? ( + + {trimmedName.length > 0 + ? `Creates ${pathPreview}` + : `Goes in ${environment.newProjectsRoot}`} + {showMachines ? ` on ${environment.label}` : null} + + ) : null} + {machineRows} + {githubTarget !== null ? ( + + 0 && pathPreview !== null + ? getNewProjectGitHubRepository(githubTarget, pathPreview) + : githubTarget.account + } + icon={ + + } + isFirst + right={ + + } + onPress={() => setPublishesToGitHub((publishes) => !publishes)} + /> + + ) : null} + void submit()} + loading={isSubmitting} + /> + + + } + isFirst + // New project opens from Add project, so going back shows the + // other sources. A deep link has nothing behind it. + onPress={() => + navigation.canGoBack() + ? navigation.goBack() + : navigation.dispatch(StackActions.replace("AddProject")) + } + /> + + + ) : environmentOptions.length > 0 ? ( + machineRows + ) : ( + + )} + + ); +} + export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 61f70aac2836f10160d8bfad5782a409cec31dfc..27fde35b9e90009cd02709c8aba55996f9cb2fe1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -116,6 +116,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, diff --git a/apps/server/src/project/NewProject.test.ts b/apps/server/src/project/NewProject.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3286aa784c29a62db1062d1e9808d149ed2c477e --- /dev/null +++ b/apps/server/src/project/NewProject.test.ts @@ -0,0 +1,140 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { createNewProjectFolder } from "./NewProject.ts"; + +const TestLayer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-new-project-" })), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const GIT_ENV_KEYS = [ + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "EMAIL", +] as const; + +// Git reads the developer's own config (signing, default branch, identity) +// unless the test replaces it, so each test runs against an empty one. +const withGitEnv = ( + env: Partial>, + effect: Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const emptyConfig = yield* fileSystem.makeTempFileScoped({ prefix: "t3-gitconfig-" }); + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + const saved = GIT_ENV_KEYS.map((key) => [key, process.env[key]] as const); + for (const key of GIT_ENV_KEYS) delete process.env[key]; + Object.assign(process.env, { + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_NOSYSTEM: "1", + ...env, + }); + return saved; + }), + () => effect, + (saved) => + Effect.sync(() => { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }), + ); + }); + +const gitOutput = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + const result = yield* git.execute({ operation: "NewProject.test", cwd, args }); + return result.stdout.trim(); + }); + +it.layer(TestLayer)("createNewProjectFolder", (it) => { + it.effect("makes the folder, starter files, and first commit, and suffixes a taken name", () => + Effect.scoped( + withGitEnv( + { + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@test.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@test.com", + }, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.join( + yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-projects-" }), + "projects", + ); + + const first = yield* createNewProjectFolder({ root, name: "Pinball Stats" }); + const second = yield* createNewProjectFolder({ root, name: "pinball stats" }); + + assert.equal(first.workspaceRoot, path.join(root, "pinball-stats")); + assert.equal(second.workspaceRoot, path.join(root, "pinball-stats-2")); + assert.isUndefined(first.commitError); + const readme = yield* fileSystem.readFileString( + path.join(first.workspaceRoot, "README.md"), + ); + assert.include(readme, "# Pinball Stats"); + assert.include(readme, `src="assets/icon.svg"`); + const icon = yield* fileSystem.readFileString( + path.join(first.workspaceRoot, "assets", "icon.svg"), + ); + assert.include(icon, ">PS"); + assert.equal( + yield* gitOutput(first.workspaceRoot, ["log", "--format=%s"]), + "Initial commit", + ); + assert.equal( + yield* gitOutput(first.workspaceRoot, ["rev-parse", "--abbrev-ref", "HEAD"]), + "main", + ); + assert.equal(yield* gitOutput(first.workspaceRoot, ["status", "--porcelain"]), ""); + }), + ), + ), + ); + + it.effect("keeps the folder and reports why when Git cannot commit", () => + Effect.scoped( + // No identity anywhere, and Git may not guess one from the host name. + withGitEnv( + { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "user.useConfigOnly", + GIT_CONFIG_VALUE_0: "true", + }, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-projects-" }); + + const result = yield* createNewProjectFolder({ root, name: "No Identity" }); + + assert.include(result.commitError ?? "", "no name or email"); + assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, "README.md"))); + assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, ".git"))); + }), + ), + ), + ); +}); diff --git a/apps/server/src/project/NewProject.ts b/apps/server/src/project/NewProject.ts new file mode 100644 index 0000000000000000000000000000000000000000..d59be024e5e31f530292961c7f43234fe956caa9 --- /dev/null +++ b/apps/server/src/project/NewProject.ts @@ -0,0 +1,179 @@ +/** + * NewProject - makes the folder for a project started from just a name: + * `/` with a README, an icon, and a first commit. + * + * The icon lives at `assets/icon.svg`, a path ProjectFaviconResolver already + * checks after the usual web app icons. It travels with the repository, so + * every machine that clones the project shows the same icon. + * + * @module NewProject + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { newProjectFolderName } from "@t3tools/shared/path"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +// Tailwind 600 shades: dark enough for white initials on every hue. +const ICON_BACKGROUNDS = [ + "#dc2626", + "#ea580c", + "#d97706", + "#16a34a", + "#059669", + "#0d9488", + "#0891b2", + "#0284c7", + "#2563eb", + "#4f46e5", + "#7c3aed", + "#9333ea", + "#c026d3", + "#db2777", + "#e11d48", +] as const; + +const MAX_FOLDER_ATTEMPTS = 100; + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +/** A rounded square with the name's initials, colored by a hash of the name. */ +function newProjectIconSvg(name: string): string { + const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean); + const initials = + words + .slice(0, 2) + .map((word) => Array.from(word)[0] ?? "") + .join("") + .toUpperCase() || + Array.from(name.trim())[0] || + "?"; + let hash = 0; + for (const char of name) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0; + const background = ICON_BACKGROUNDS[hash % ICON_BACKGROUNDS.length]; + return [ + ``, + ` `, + ` ${escapeXml(initials)}`, + ``, + "", + ].join("\n"); +} + +function newProjectReadme(name: string): string { + return [ + ``, + "", + `# ${name}`, + "", + "Created in [T3 Code](https://t3.codes).", + "", + ].join("\n"); +} + +// Git's own identity message runs several lines; say what to do instead. +// Otherwise its last line ("error: gpg failed to sign the data") says enough. +function describeCommitFailure(stderr: string): string { + if (/identity unknown|tell me who you are|no (name|email) was given/i.test(stderr)) { + return "Git has no name or email on this machine. Set user.name and user.email, then commit."; + } + const lines = stderr + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + return lines.at(-1) ?? "Git could not make the first commit."; +} + +/** + * Claims a fresh folder for `name` under `root` (adding `-2`, `-3`, ... when + * the name is taken), writes the starter files, and makes the first commit. + * A failed commit (no Git identity, a signing prompt) keeps the folder and + * returns why, so the project still opens. Any other failure removes the + * folder it claimed. + */ +export const createNewProjectFolder = Effect.fn("NewProject.createNewProjectFolder")( + function* (input: { readonly root: string; readonly name: string }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + + yield* fileSystem.makeDirectory(input.root, { recursive: true }); + // Created without `recursive`, so the create itself claims the folder and + // two requests for the same name never share one. + const folderName = newProjectFolderName(input.name); + let workspaceRoot: string | null = null; + for (let attempt = 1; workspaceRoot === null && attempt <= MAX_FOLDER_ATTEMPTS; attempt++) { + const candidate = path.join( + input.root, + attempt === 1 ? folderName : `${folderName}-${attempt}`, + ); + const claimed = yield* fileSystem.makeDirectory(candidate).pipe( + Effect.as(true), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed(false), + ), + ); + if (claimed) workspaceRoot = candidate; + } + if (workspaceRoot === null) { + return yield* PlatformError.systemError({ + _tag: "AlreadyExists", + module: "FileSystem", + method: "makeDirectory", + description: `Every folder name for "${folderName}" is taken.`, + pathOrDescriptor: input.root, + }); + } + const cwd = workspaceRoot; + + return yield* Effect.gen(function* () { + const branch = + (yield* git + .readConfigValue(cwd, "init.defaultBranch") + .pipe(Effect.orElseSucceed(() => null))) ?? "main"; + yield* git.execute({ + operation: "NewProject.init", + cwd, + args: ["init", `--initial-branch=${branch}`], + timeoutMs: 10_000, + }); + yield* fileSystem.writeFileString(path.join(cwd, "README.md"), newProjectReadme(input.name)); + yield* fileSystem.makeDirectory(path.join(cwd, "assets")); + yield* fileSystem.writeFileString( + path.join(cwd, "assets", "icon.svg"), + newProjectIconSvg(input.name), + ); + // Named and forced so a global ignore rule (say `*.svg`) cannot drop one. + yield* git.execute({ + operation: "NewProject.add", + cwd, + args: ["add", "--force", "--", "README.md", "assets/icon.svg"], + timeoutMs: 10_000, + }); + const commitError = yield* git + .execute({ + operation: "NewProject.commit", + cwd, + args: ["commit", "--message", "Initial commit"], + allowNonZeroExit: true, + timeoutMs: 30_000, + }) + .pipe( + Effect.map((result) => + result.exitCode === 0 ? undefined : describeCommitFailure(result.stderr), + ), + Effect.catch((error) => Effect.succeed(error.message)), + ); + return { workspaceRoot: cwd, commitError }; + }).pipe(Effect.onError(() => fileSystem.remove(cwd, { recursive: true }).pipe(Effect.ignore))); + }, +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 930e7e7bb369fe1854994180d8259e05681ef078..fd505400c2e50fec2a374bf8b668bdf2f89bd4f9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5556,6 +5556,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("creates a project from just a name in the projects folder", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created: Array<{ readonly title: string; readonly workspaceRoot: string }> = []; + const gitCalls: Array = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + if (command.type === "project.create") { + created.push({ title: command.title, workspaceRoot: command.workspaceRoot }); + } + return { sequence: created.length }; + }), + }, + gitVcsDriver: { + readConfigValue: () => Effect.succeed(null), + execute: (input) => + Effect.sync(() => { + gitCalls.push(input.args.join(" ")); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }, + }, + }); + + yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + Effect.gen(function* () { + const root = (yield* client[WS_METHODS.serverGetConfig]({})).newProjectsRoot ?? ""; + const result = yield* client[WS_METHODS.projectsCreateNew]({ name: "Pinball Stats" }); + + assert.equal(result.workspaceRoot, path.join(root, "pinball-stats")); + assert.isUndefined(result.commitError); + assert.deepEqual(created, [ + { title: "Pinball Stats", workspaceRoot: result.workspaceRoot }, + ]); + assert.deepEqual(gitCalls, [ + "init --initial-branch=main", + "add --force -- README.md assets/icon.svg", + "commit --message Initial commit", + ]); + assert.isTrue( + yield* fileSystem.exists(path.join(result.workspaceRoot, "assets", "icon.svg")), + ); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("advertises the usable file manager and its reveal label", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1bdf80208e55885b95051ca2aef3b17af8530cda..f256dfaa4aa0dd3016a96eacdddd94d64396e84c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -49,6 +49,7 @@ import { OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, ProjectId, + type ProjectCreateNewInput, type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, @@ -143,6 +144,7 @@ import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; +import * as NewProject from "./project/NewProject.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; @@ -2018,6 +2020,49 @@ const makeWsRpcLayer = ( ); }); + // Projects started from just a name live beside Scratch and worktrees, + // away from folders the user organizes by hand. A nested repository is + // fine here (unlike Scratch) because each project gets its own `git init`. + const newProjectsRoot = path.resolve(config.baseDir, "projects"); + const createNewProject = (input: ProjectCreateNewInput) => + Effect.gen(function* () { + const folder = yield* NewProject.createNewProjectFolder({ + root: newProjectsRoot, + name: input.name, + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the project folder.", + cause, + }), + ), + ); + const projectId = ProjectId.make(yield* randomUUID); + yield* Effect.gen(function* () { + const command = yield* normalizeDispatchCommand({ + type: "project.create", + commandId: yield* serverCommandId("project-create-new"), + projectId, + title: input.name, + workspaceRoot: folder.workspaceRoot, + createdAt: yield* nowIso, + }); + yield* dispatchNormalizedCommand(command); + }).pipe( + // Only a rejected command means no project uses the folder. An + // interrupt can land after the command is queued, so keep it then. + Effect.tapError(() => + fileSystem.remove(folder.workspaceRoot, { recursive: true }).pipe(Effect.ignore), + ), + ); + return { + projectId, + workspaceRoot: folder.workspaceRoot, + ...(folder.commitError === undefined ? {} : { commitError: folder.commitError }), + }; + }); + // Only clients that answer /usage-limits themselves see it in the catalogs; // an older client would send the injected command to the provider. const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => @@ -2082,6 +2127,7 @@ const makeWsRpcLayer = ( threadSnapshotPagination: true, reasoningMessages: true, ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }), + newProjectsRoot, }; }); @@ -3252,6 +3298,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, { "rpc.aggregate": "orchestration", }), + [WS_METHODS.projectsCreateNew]: (input) => + observeRpcEffect(WS_METHODS.projectsCreateNew, createNewProject(input), { + "rpc.aggregate": "orchestration", + }), [WS_METHODS.projectCloneCancel]: (input) => observeRpcEffect( WS_METHODS.projectCloneCancel, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6ea939d666bb3311e4d02fb48443e4a37c017335..95f2df5928ae9198f0ee80c891655e1eb04f377e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -10,6 +10,9 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + getNewProjectGitHubRepository, + getNewProjectGitHubTarget, + getNewProjectPathPreview, normalizePastedCloneUrl, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; @@ -43,8 +46,11 @@ import * as Option from "effect/Option"; import { ArrowLeftIcon, ChartNoAxesColumnIcon, + CheckIcon, + ChevronRightIcon, CornerLeftUpIcon, FileSearchIcon, + FolderGit2Icon, FolderIcon, FolderPlusIcon, MessageSquareDashedIcon, @@ -100,6 +106,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; +import { useNewProject } from "../hooks/useNewProject"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; @@ -170,6 +177,7 @@ import { CommandPaletteContent } from "./CommandPaletteContent"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; @@ -862,6 +870,17 @@ function OpenCommandPaletteDialog(props: { ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + // The name step of New project: while set, the palette input is the name. + const [newProjectFlow, setNewProjectFlow] = useState<{ + readonly environmentId: EnvironmentId; + /** Machine of the Add project sources view under this step; null from the palette root. */ + readonly sourcesEnvironmentId: EnvironmentId | null; + } | null>(null); + const [newProjectPublishesToGitHub, setNewProjectPublishesToGitHub] = useState(false); + const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); + // State lags a render behind, so a repeated Enter could start a second create. + const newProjectSubmittingRef = useRef(false); + const createNewProject = useNewProject(); const cloneLookupGeneration = useRef(0); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); @@ -1073,9 +1092,15 @@ function OpenCommandPaletteDialog(props: { getFilesystemBrowsePath( query, browseEnvironmentPlatform, - browseEnvironmentId !== null && !isRemoteProjectRepositoryStep, + browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null, ), - [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + [ + browseEnvironmentId, + browseEnvironmentPlatform, + isRemoteProjectRepositoryStep, + newProjectFlow, + query, + ], ); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; @@ -1469,8 +1494,12 @@ function OpenCommandPaletteDialog(props: { function popView(): void { browseNavigation.invalidate(); setAddProjectCloneFlow(null); + setNewProjectFlow(null); if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); + } else if (newProjectFlow?.sourcesEnvironmentId) { + // The machine switcher may have moved off the sources view's machine. + setAddProjectEnvironmentId(newProjectFlow.sourcesEnvironmentId); } setViewStack((previousViews) => previousViews.slice(0, -1)); setHighlightedItemValue(null); @@ -1531,6 +1560,28 @@ function OpenCommandPaletteDialog(props: { [pushPaletteView], ); + /** Folder that holds an environment's name-only projects, or null when it has none. */ + const newProjectsRootFor = useCallback( + (environmentId: EnvironmentId | null): string | null => + environments.find((environment) => environment.environmentId === environmentId)?.serverConfig + ?.newProjectsRoot ?? null, + [environments], + ); + + const startNewProject = useCallback( + (environmentId: EnvironmentId, sourcesEnvironmentId: EnvironmentId | null): void => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); + setNewProjectFlow({ environmentId, sourcesEnvironmentId }); + setNewProjectPublishesToGitHub(false); + pushPaletteView({ + addonIcon: , + groups: [], + }); + }, + [pushPaletteView], + ); + const openSourceControlSettings = useCallback(() => { setOpen(false); void navigate({ to: "/settings/source-control" }); @@ -1556,6 +1607,21 @@ function OpenCommandPaletteDialog(props: { }, ]; + if (newProjectsRootFor(environmentId) !== null) { + sourceItems.unshift({ + kind: "action", + value: `action:add-project:${environmentId}:new`, + searchTerms: ["new project", "create", "empty", "repository", "git init"], + title: "New project", + description: "Start a new Git repository from a name", + icon: , + keepOpen: true, + run: async () => { + startNewProject(environmentId, environmentId); + }, + }); + } + const orderedSources: ReadonlyArray = [ "url", ...sortAddProjectProviderSources(readinessBySource), @@ -1626,7 +1692,13 @@ function OpenCommandPaletteDialog(props: { return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; }, - [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], + [ + newProjectsRootFor, + openSourceControlSettings, + startAddProjectBrowse, + startAddProjectClone, + startNewProject, + ], ); const startAddProjectSourceSelection = useCallback( @@ -1665,24 +1737,36 @@ function OpenCommandPaletteDialog(props: { ], ); - const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( - (option) => ({ - kind: "action", - value: `action:add-project:environment:${option.environmentId}`, - searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], - title: option.label, - description: option.isConnected - ? option.isPrimary - ? "This device" - : option.environmentId - : option.status, - disabled: !option.isConnected, - icon: , - keepOpen: true, - run: async () => { - startAddProjectSourceSelection(option.environmentId); - }, - }), + const buildEnvironmentItem = ( + option: AddProjectEnvironmentOption, + value: string, + run: (environmentId: EnvironmentId) => void, + ): CommandPaletteActionItem => ({ + kind: "action", + value, + searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], + title: option.label, + description: option.isConnected + ? option.isPrimary + ? "This device" + : option.environmentId + : option.status, + disabled: !option.isConnected, + icon: , + keepOpen: true, + run: async () => { + run(option.environmentId); + }, + }); + const addProjectEnvironmentItems = addProjectEnvironmentOptions.map((option) => + buildEnvironmentItem( + option, + `action:add-project:environment:${option.environmentId}`, + startAddProjectSourceSelection, + ), + ); + const newProjectEnvironmentOptions = addProjectEnvironmentOptions.filter( + (option) => option.isConnected && newProjectsRootFor(option.environmentId) !== null, ); const addProjectEnvironmentGroups = useMemo( @@ -1724,12 +1808,20 @@ function OpenCommandPaletteDialog(props: { startAddProjectSourceSelection, ]); + // New project starts on this device (options list it first); the name step + // lists the other machines when there is a choice. + const openNewProjectFlow = () => { + const firstOption = newProjectEnvironmentOptions[0]; + if (firstOption) startNewProject(firstOption.environmentId, null); + }; + useLayoutEffect(() => { if (openIntent?.kind !== "search") return; browseNavigation.invalidate(); cloneLookupGeneration.current += 1; setIsRemoteProjectLookingUp(false); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); setLinkedThreadSearch(openIntent); setQuery(openIntent.query); @@ -1751,6 +1843,7 @@ function OpenCommandPaletteDialog(props: { clearOpenIntent(); browseNavigation.invalidate(); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); setQuery(""); const currentPrefix = @@ -1949,6 +2042,20 @@ function OpenCommandPaletteDialog(props: { }, }); + if (newProjectEnvironmentOptions.length > 0) { + actionItems.push({ + kind: "action", + value: "action:new-project", + searchTerms: ["new project", "create project", "empty", "repository", "repo", "git init"], + title: "New project", + icon: , + keepOpen: true, + run: async () => { + openNewProjectFlow(); + }, + }); + } + actionItems.push({ kind: "action", value: "action:add-project", @@ -2081,6 +2188,7 @@ function OpenCommandPaletteDialog(props: { cloneLookupGeneration.current += 1; setIsRemoteProjectLookingUp(false); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); pushPaletteView({ addonIcon: , @@ -2398,6 +2506,34 @@ function OpenCommandPaletteDialog(props: { ], ); + const newProjectGitHubTarget = + newProjectFlow === null ? null : getNewProjectGitHubTarget(sourceControlDiscovery.data ?? null); + const newProjectName = query.trim(); + const canSubmitNewProject = + newProjectFlow !== null && + newProjectName.length > 0 && + !isCreatingNewProject && + canCreateProjectInEnvironment(browseEnvironment?.connection.phase); + + async function submitNewProject(): Promise { + if (newProjectFlow === null || !canSubmitNewProject || newProjectSubmittingRef.current) { + return; + } + newProjectSubmittingRef.current = true; + setIsCreatingNewProject(true); + try { + const created = await createNewProject({ + environmentId: newProjectFlow.environmentId, + name: newProjectName, + github: newProjectPublishesToGitHub ? newProjectGitHubTarget : null, + }); + if (created) setOpen(false); + } finally { + newProjectSubmittingRef.current = false; + setIsCreatingNewProject(false); + } + } + function getDefaultCloneParentPath(environmentId: EnvironmentId): string { return getAddProjectInitialQueryForEnvironment(environmentId); } @@ -2681,8 +2817,135 @@ function OpenCommandPaletteDialog(props: { }; }, [addProjectCloneFlow]); + const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; + const newProjectPathPreview = + newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); + const newProjectGitHubToggleValue = "new-project:github"; + // The name step's way out to folders and clones, for the selected machine. + // It replaces the name step (and a sources view for another machine under + // it), so Back returns to wherever New project was opened from. + const showExistingProjectSources = (environmentId: EnvironmentId) => { + const sourcesEnvironmentId = newProjectFlow?.sourcesEnvironmentId ?? null; + if (sourcesEnvironmentId === environmentId) { + popView(); + return; + } + if (sourcesEnvironmentId !== null) { + setViewStack((previousViews) => previousViews.slice(0, -1)); + } + popView(); + startAddProjectSourceSelection(environmentId); + }; + // Switching machines keeps the typed name; the GitHub option follows the + // machine because source control discovery reads addProjectEnvironmentId. + const switchNewProjectEnvironment = (environmentId: EnvironmentId) => { + setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId })); + setAddProjectEnvironmentId(environmentId); + }; + const selectedNewProjectEnvironment = + newProjectFlow === null + ? undefined + : newProjectEnvironmentOptions.find( + (option) => option.environmentId === newProjectFlow.environmentId, + ); + // Shown when there is a choice, or when the selected machine went away and + // another one can take over. + const showNewProjectMachines = + newProjectFlow !== null && + (newProjectEnvironmentOptions.length > 1 || + (selectedNewProjectEnvironment === undefined && newProjectEnvironmentOptions.length > 0)); + const newProjectEnvironmentLabel = showNewProjectMachines + ? (selectedNewProjectEnvironment?.label ?? + environmentLabelById.get(newProjectFlow.environmentId) ?? + null) + : null; + const newProjectMachineGroup: CommandPaletteView["groups"][number] | null = + !showNewProjectMachines || newProjectFlow === null + ? null + : { + value: "new-project-machines", + label: "Environments", + items: newProjectEnvironmentOptions.map((option) => ({ + ...buildEnvironmentItem( + option, + `new-project:environment:${option.environmentId}`, + switchNewProjectEnvironment, + ), + // The create in flight keeps the machine it started on. + ...(isCreatingNewProject ? { disabled: true } : {}), + ...(option.environmentId === newProjectFlow.environmentId + ? { + titleTrailingContent: ( + + ), + } + : {}), + })), + }; + const newProjectExistingGroup: CommandPaletteView["groups"][number] | null = + newProjectFlow === null + ? null + : { + value: "new-project-existing", + label: "", + items: [ + { + kind: "action", + value: "new-project:existing", + searchTerms: [], + title: "Add existing project", + description: "Open a folder or clone a repository", + icon: , + titleTrailingContent: ( + + ), + keepOpen: true, + run: async () => { + showExistingProjectSources(newProjectFlow.environmentId); + }, + }, + ], + }; + const newProjectOptionGroups: CommandPaletteView["groups"] = + newProjectGitHubTarget === null || newProjectPathPreview === null + ? [] + : [ + { + value: "new-project-options", + label: "Options", + items: [ + { + kind: "action", + value: newProjectGitHubToggleValue, + searchTerms: [], + title: "Create private repository on GitHub", + description: + newProjectName.length > 0 + ? getNewProjectGitHubRepository(newProjectGitHubTarget, newProjectPathPreview) + : (newProjectGitHubTarget.account ?? "Your GitHub account"), + icon: , + titleTrailingContent: ( + + + + ), + keepOpen: true, + run: async () => { + setNewProjectPublishesToGitHub((publishes) => !publishes); + }, + }, + ], + }, + ]; + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; - if (addProjectCloneFlow?.step === "repository") { + if (newProjectFlow !== null) { + displayedGroups = [ + ...(newProjectMachineGroup ? [newProjectMachineGroup] : []), + ...newProjectOptionGroups, + ...(newProjectExistingGroup ? [newProjectExistingGroup] : []), + ]; + } else if (addProjectCloneFlow?.step === "repository") { displayedGroups = []; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; @@ -2691,8 +2954,10 @@ function OpenCommandPaletteDialog(props: { } const inputPlaceholder = - remoteProjectInputPlaceholder(addProjectCloneFlow) ?? - getCommandPaletteInputPlaceholder(paletteMode); + newProjectFlow !== null + ? "Project name" + : (remoteProjectInputPlaceholder(addProjectCloneFlow) ?? + getCommandPaletteInputPlaceholder(paletteMode)); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; const canSubmitBrowsePath = @@ -2799,6 +3064,21 @@ function OpenCommandPaletteDialog(props: { return; } + // Enter creates the project unless an item below the name is + // highlighted, in which case it runs that item. + if ( + newProjectFlow !== null && + event.key === "Enter" && + highlightedItemValue === null && + // Enter that confirms an IME composition is part of typing the name. + !event.nativeEvent.isComposing && + event.keyCode !== 229 + ) { + event.preventDefault(); + void submitNewProject(); + return; + } + const shouldSubmitBrowsePath = canSubmitBrowsePath && event.key === "Enter" && @@ -2957,7 +3237,34 @@ function OpenCommandPaletteDialog(props: { ]); const inputAccessory = - addProjectCloneFlow?.step === "repository" ? ( + newProjectFlow !== null ? ( + + { + event.preventDefault(); + }} + onClick={() => { + void submitNewProject(); + }} + /> + } + > + {isCreatingNewProject ? "Creating" : "Create"} + + Enter + + + Create (Enter) + + ) : addProjectCloneFlow?.step === "repository" ? ( + {newProjectPathPreview !== null ? ( +
+
+ + + + {newProjectName.length > 0 ? newProjectName : "New project"} + + + {newProjectName.length > 0 + ? `Creates ${newProjectPathPreview}` + : `Goes in ${newProjectsRoot}`} + {newProjectEnvironmentLabel === null ? null : ` on ${newProjectEnvironmentLabel}`} + + +
+
+ ) : null} {remoteProjectContext ? (
Repository
diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index b94707f5eca6782e9a92c90c18bfa06ed39cefc6..4be4aee17ff723d0839a65fbd6ab07778fa994c4 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label ? {group.label} : null} {(item) => item.disabled ? ( diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 3d5b7bfd311d7f99f32ac111c73eec1a53b3c255..ac995a47a67032aea115d2397790381ebfbb9486 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -304,7 +304,7 @@ export function DraftHeroHeadline({ {projectPickerEntries.length > 0 ? : null} - New project + Add project diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index e552e64c19a39c6cfd843f6f716e8095182165d3..e2bd4bfb74e5820ce613dbf19f7858df9a3aeebc 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -127,7 +127,7 @@ export function SidebarThreadHeader({ {hasProjects ? ( <> {projectScope} - + diff --git a/apps/web/src/hooks/useNewProject.ts b/apps/web/src/hooks/useNewProject.ts new file mode 100644 index 0000000000000000000000000000000000000000..175609cbfc4217cff52ceb7b7237e539264be5df --- /dev/null +++ b/apps/web/src/hooks/useNewProject.ts @@ -0,0 +1,147 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { waitForProject } from "~/state/entities"; +import { projectEnvironment } from "~/state/projects"; +import { sourceControlEnvironment } from "~/state/sourceControl"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useNewThreadHandler } from "./useHandleNewThread"; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "An error occurred."; +} + +/** + * Starts a project from just a name. The server makes a folder under its + * `newProjectsRoot` with a README, an icon, and a first commit; this then + * opens a new thread draft in it. With `github`, it also publishes the + * repository as private, without holding up the draft. + * + * Resolves to whether the project was created. + */ +export function useNewProject() { + const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { + reportFailure: false, + }); + const handleNewThread = useNewThreadHandler(); + + const publishToGitHub = useCallback( + async (input: { + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + readonly account: string | null; + }) => { + const result = await publishRepository({ + environmentId: input.environmentId, + input: { + cwd: input.workspaceRoot, + provider: "github", + repository: getNewProjectGitHubRepository(input, input.workspaceRoot), + visibility: "private", + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create the GitHub repository", + description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`, + }), + ); + } + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Published to GitHub", + description: result.value.repository.nameWithOwner, + }), + ); + }, + [publishRepository], + ); + + return useCallback( + async (input: { + readonly environmentId: EnvironmentId; + readonly name: string; + readonly github: { readonly account: string | null } | null; + }): Promise => { + const result = await createNew({ + environmentId: input.environmentId, + input: { name: input.name }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create the project", + description: errorMessage(squashAtomCommandFailure(result)), + }), + ); + } + return false; + } + + const { projectId, workspaceRoot, commitError } = result.value; + // The folder sits in T3 Code's data directory, so always say where. + toastManager.add( + stackedThreadToast( + commitError === undefined + ? { type: "success", title: `Created ${input.name}`, description: workspaceRoot } + : { + type: "warning", + title: `Created ${input.name} without a first commit`, + description: `${commitError} The project is in ${workspaceRoot}.`, + }, + ), + ); + if (input.github) { + void publishToGitHub({ + environmentId: input.environmentId, + workspaceRoot, + account: input.github.account, + }); + } + + const projectRef = scopeProjectRef(input.environmentId, projectId); + // Drafts key off the project's stored path, so wait for the create event + // to reach the store before opening one. + const project = await waitForProject(projectRef).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: `${errorMessage(error)} It will appear in the sidebar once this client catches up.`, + }), + ); + return null; + }); + if (project === null) return true; + await handleNewThread(projectRef).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: errorMessage(error), + }), + ); + }); + return true; + }, + [createNew, handleNewThread, publishToGitHub], + ); +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 306632d9ca64483e3ff5e013fe83e1ea1763347b..3d068a232be717f61d39f6dbdf6092c63b437dec 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -79,7 +79,15 @@ az extension add --name azure-devops az login ``` -## Clone or publish a project +## Start, clone, or publish a project + +To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or +**New project** under **Add Project** on any client, and type a name. T3 Code makes a Git +repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README, +an icon, and a first commit, then opens a new thread in it. The folder is named after the project, +like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also +publish it. If Git has no name or email on that machine, the project is created without the +first commit. Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting provider or paste a Git URL, then choose where to save it. The project opens right away while the diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 597b0b5d7b4fba830d14e00aa085c5f6e0689fd5..ec03ea86dce1f3161c565705c2997380c1e5bd89 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -8,6 +8,7 @@ import type { SourceControlProviderKind, SourceControlRepositoryInfo, } from "@t3tools/contracts"; +import { newProjectFolderName } from "@t3tools/shared/path"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; @@ -250,6 +251,36 @@ export function getCloneDestinationPath( return `${ensureBrowseDirectoryPath(directoryPath)}${name}`; } +/** + * Where `projects.createNew` will put a project named `name`. The server adds + * `-2`, `-3`, ... when that folder is taken, so this is a preview. + */ +export function getNewProjectPathPreview(newProjectsRoot: string, name: string): string { + return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name)); +} + +/** + * The GitHub account a new project would be published under, or null when + * GitHub is not ready on that environment. A ready GitHub with an unknown + * account still publishes; `gh` picks the signed-in user. + */ +export function getNewProjectGitHubTarget( + discovery: SourceControlDiscoveryResult | null, +): { readonly account: string | null } | null { + if (!buildAddProjectRemoteSourceReadiness(discovery).github.ready) return null; + const github = discovery?.sourceControlProviders.find((provider) => provider.kind === "github"); + return { account: github ? Option.getOrNull(github.auth.account) : null }; +} + +/** `owner/folder` for publishing a new project, or just the folder for `gh` to place. */ +export function getNewProjectGitHubRepository( + target: { readonly account: string | null }, + workspaceRoot: string, +): string { + const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? ""; + return target.account ? `${target.account}/${folderName}` : folderName; +} + /** * Destination query after choosing a directory while the clone folder is * pinned in the path input. Selecting an existing directory with the pinned diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index d969a8c0d379e62cbac654dbc98cb8f645297179..5c39113501fb10d7af2b363b7aeee1e7b6ea86e5 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -99,6 +99,13 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), + // Makes a new folder and repository from just a name, then the project. + createNew: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:create-new", + tag: WS_METHODS.projectsCreateNew, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index f97924b084fba687164c4524727bb8b1f1c12a36..a36003eb11b12324cede1b3d5ef44ae408d35534 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -288,6 +288,20 @@ export const ProjectEnsureScratchResult = Schema.Struct({ }); export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type; +/** A project started from just a name, in a new folder the server makes. */ +export const ProjectCreateNewInput = Schema.Struct({ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)), +}); +export type ProjectCreateNewInput = typeof ProjectCreateNewInput.Type; + +export const ProjectCreateNewResult = Schema.Struct({ + projectId: ProjectId, + workspaceRoot: TrimmedNonEmptyString, + /** Why the first commit failed. The project and its files exist either way. */ + commitError: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type; + export class ProjectWriteFileError extends Schema.TaggedError()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 41331f0c46cf965a464b36b32705983d0578fc7e..c1b25459048e4f295b614340ff6478f5112c5f37 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -159,6 +159,8 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectCreateNewInput, + ProjectCreateNewResult, ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, @@ -295,6 +297,7 @@ export const WS_METHODS = { projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", projectsEnsureScratch: "projects.ensureScratch", + projectsCreateNew: "projects.createNew", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -1003,6 +1006,13 @@ const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), }); +// Makes a folder under ServerConfig.newProjectsRoot with a first commit, then the project. +const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, { + payload: ProjectCreateNewInput, + success: ProjectCreateNewResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1521,6 +1531,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsEnsureScratchRpc, + WsProjectsCreateNewRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 49faaaf44712d83580d26a07d1ce352965643811..c8983988ead1193464dd9dc90ac601cbdbbdaebe 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -617,6 +617,11 @@ export const ServerConfig = Schema.Struct({ * and whose data dir is outside a Git checkout. */ scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), + /** + * Folder that holds projects started from just a name. Present only on + * servers that answer projects.createNew. + */ + newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), /** * Palettes published by this environment's machine. Never sent in a config * snapshot: the theme stream emits the current set before any change, so a diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 799f225fb6e25cba3a265d24ae668b5b8ca588fd..b018a4e81d8cbf87fa8d5801724a5da6bd8b72cc 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -4,6 +4,7 @@ import { isUncPath, isWindowsAbsolutePath, isWindowsDrivePath, + newProjectFolderName, normalizeProjectPathForComparison, normalizeProjectPathForDispatch, } from "./path.ts"; @@ -43,4 +44,17 @@ describe("path helpers", () => { // Non-root drive paths keep their trailing separator trimmed as before. expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); }); + + it("names a new project's folder from any typed name", () => { + expect(newProjectFolderName("Pinball Stats")).toBe("pinball-stats"); + expect(newProjectFolderName(" Café & Crème! ")).toBe("cafe-creme"); + expect(newProjectFolderName("../../etc")).toBe("etc"); + // Nothing usable left, so the server falls back to a fixed name. + expect(newProjectFolderName("🎱🎱")).toBe("project"); + expect(newProjectFolderName(`${"a".repeat(63)} b`)).toBe("a".repeat(63)); + // Windows cannot make folders with device names. + expect(newProjectFolderName("Con")).toBe("con-project"); + expect(newProjectFolderName("LPT1")).toBe("lpt1-project"); + expect(newProjectFolderName("console")).toBe("console"); + }); }); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index b6758d814aa83eab9ec9fb1bfdb41c13ba6400cf..c8626eb5e14c4671aad0d89e39d8e08856920008 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -53,3 +53,24 @@ export function normalizeProjectPathForComparison(value: string): string { } return normalized; } + +// Windows refuses these as file names, with or without an extension. +const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/; + +/** + * Folder name for a project started from just a name ("Pinball Stats" becomes + * "pinball-stats"). The server uses it for `projects.createNew`, and clients + * use it to show the path before the server makes it. + */ +export function newProjectFolderName(name: string): string { + const slug = name + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .slice(0, 64) + .replace(/-+$/, ""); + if (slug.length === 0) return "project"; + return WINDOWS_RESERVED_NAME.test(slug) ? `${slug}-project` : slug; +}