Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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