Skip to content
Closed
5 changes: 5 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -459,6 +460,10 @@ const NewTaskSheetStack = createNativeStackNavigator({
screen: AddProjectLocalRoute,
linking: "add-project/local",
}),
AddProjectNew: createNativeStackScreen({
screen: AddProjectNewRoute,
linking: "add-project/new",
}),
},
});

Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/features/projects/AddProjectNewRoute.tsx
Original file line number Diff line number Diff line change
@@ -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<AddProjectNewRouteParams | undefined>) {
return <AddProjectNewScreen {...(route.params ?? {})} />;
}
261 changes: 260 additions & 1 deletion apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import {
getCloneDestinationPath,
getCloneDirectoryName,
getDefaultCloneUrl,
getNewProjectGitHubRepository,
getNewProjectGitHubTarget,
getNewProjectPathPreview,
normalizePastedCloneUrl,
resolveAddProjectPath,
sortAddProjectProviderSources,
Expand Down Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -399,6 +405,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
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,
Expand Down Expand Up @@ -578,6 +585,28 @@ export function AddProjectSourceScreen() {
{selectedEnvironment ? (
<>
<ListSection>
{selectedEnvironment.newProjectsRoot !== null ? (
<ListRow
title="New project"
subtitle="Start a new Git repository from a name"
icon={
<SymbolView
name="plus"
size={Platform.OS === "android" ? 24 : 17}
tintColorClassName="accent-icon"
type="monochrome"
/>
}
isFirst
onPress={() =>
navigation.dispatch(
StackActions.push("AddProjectNew", {
environmentId: selectedEnvironment.environmentId,
}),
)
}
/>
) : null}
<ListRow
title="Local folder"
subtitle="Browse a folder on disk"
Expand All @@ -589,7 +618,7 @@ export function AddProjectSourceScreen() {
type="monochrome"
/>
}
isFirst
isFirst={selectedEnvironment.newProjectsRoot === null}
onPress={() =>
navigation.dispatch(
StackActions.push("AddProjectLocal", {
Expand Down Expand Up @@ -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<string | null>(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 ? (
<ListSection>
{environmentOptions.map((option, index) => {
const selected = option.environmentId === environment?.environmentId;
return (
<ListRow
key={option.environmentId}
title={option.label}
icon={
<EnvironmentMachineSymbol
kind={option.machine}
size={Platform.OS === "android" ? 24 : 17}
tintColorClassName="accent-icon"
/>
}
selected={selected}
// The create in flight keeps the machine it started on.
disabled={isSubmitting}
isFirst={index === 0}
right={
selected ? (
<SymbolView
name="checkmark"
size={Platform.OS === "android" ? 20 : 14}
tintColorClassName="accent-icon"
type="monochrome"
/>
) : null
}
onPress={() => setSelectedEnvironmentId(option.environmentId)}
/>
);
})}
</ListSection>
) : 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 (
<AddProjectShell title="New project">
{error ? <ErrorBanner message={error} /> : null}
{environment ? (
<>
<TextInput
className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
value={name}
onChangeText={setName}
autoCorrect={false}
autoFocus
placeholder="Project name"
returnKeyType="done"
onSubmitEditing={() => void submit()}
/>
{pathPreview !== null ? (
<Text className="px-1 text-sm leading-snug text-foreground-muted" numberOfLines={2}>
{trimmedName.length > 0
? `Creates ${pathPreview}`
: `Goes in ${environment.newProjectsRoot}`}
{showMachines ? ` on ${environment.label}` : null}
</Text>
) : null}
{machineRows}
{githubTarget !== null ? (
<ListSection>
<ListRow
title="Create private repository on GitHub"
subtitle={
trimmedName.length > 0 && pathPreview !== null
? getNewProjectGitHubRepository(githubTarget, pathPreview)
: githubTarget.account
}
icon={
<SourceControlIcon
kind="github"
size={Platform.OS === "android" ? 24 : 18}
colorClassName="accent-icon"
/>
}
isFirst
right={
<ThemedSwitch
accessibilityLabel="Create private repository on GitHub"
value={publishesToGitHub}
onValueChange={setPublishesToGitHub}
/>
}
onPress={() => setPublishesToGitHub((publishes) => !publishes)}
/>
</ListSection>
) : null}
<PrimaryActionButton
label="Create project"
disabled={isSubmitting || trimmedName.length === 0}
onPress={() => void submit()}
loading={isSubmitting}
/>
<ListSection>
<ListRow
title="Add existing project"
subtitle="Open a folder or clone a repository"
icon={
<SymbolView
name="folder.badge.plus"
size={Platform.OS === "android" ? 24 : 17}
tintColorClassName="accent-icon"
type="monochrome"
/>
}
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"))
}
/>
</ListSection>
</>
) : environmentOptions.length > 0 ? (
machineRows
) : (
<EmptyEnvironmentState />
)}
</AddProjectShell>
);
}

export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {
const environment = useEnvironmentFromParam(props.environmentId);
const createProject = useCreateProject(environment);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading