1import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
2import type {
3 CommandId,
4 EnvironmentId,
5 ProjectMutation,
6 ProjectId,
7 ServerConfig,
8 SourceControlDiscoveryResult,
9 SourceControlRepositoryInfo,
10} from "@t3tools/contracts";
11import { SourceControlProviderKind } from "@t3tools/contracts";
12import { newProjectFolderName } from "@t3tools/shared/path";
13import * as Arr from "effect/Array";
14import * as Option from "effect/Option";
15import * as Order from "effect/Order";
16
17import {
18 appendBrowsePathSegment,
19 ensureBrowseDirectoryPath,
20 findProjectByPath,
21 inferProjectTitleFromPath,
22 isExplicitRelativeProjectPath,
23 isUnsupportedWindowsProjectPath,
24 resolveProjectPathForDispatch,
25} from "../state/projects.ts";
26import type { EnvironmentProject } from "../state/models.ts";
27import { sourceControlClients } from "../sourceControlClients.ts";
28import type { SourceControlClientDefinition } from "../sourceControlClients.ts";
29
30/** A host to clone from, or `url` for a pasted clone URL. */
31export type AddProjectRemoteSource = SourceControlProviderKind | "url";
32
33export function canCreateProjectInEnvironment(
34 connectionPhase: EnvironmentConnectionPhase | null | undefined,
35): boolean {
36 return connectionPhase === "connected";
37}
38
39/**
40 * The Scratch folder an environment offers threads without a project right
41 * now, or null while it is not connected or has none.
42 */
43export function availableScratchWorkspaceRoot(
44 connectionPhase: EnvironmentConnectionPhase | null | undefined,
45 serverConfig: Pick<ServerConfig, "scratchWorkspaceRoot"> | null | undefined,
46): string | null {
47 return canCreateProjectInEnvironment(connectionPhase)
48 ? (serverConfig?.scratchWorkspaceRoot ?? null)
49 : null;
50}
51
52export interface AddProjectRemoteSourceReadinessEntry {
53 readonly ready: boolean;
54 readonly hint: string | null;
55}
56
57/** Whether each clone source can be used on an environment, and why not. */
58export type AddProjectRemoteSourceReadiness = (
59 source: AddProjectRemoteSource,
60) => AddProjectRemoteSourceReadinessEntry;
61
62export type AddProjectCloneFlow =
63 | {
64 readonly step: "repository";
65 readonly environmentId: EnvironmentId;
66 readonly source: AddProjectRemoteSource;
67 }
68 | {
69 readonly step: "confirm";
70 readonly environmentId: EnvironmentId;
71 readonly source: AddProjectRemoteSource;
72 readonly repositoryInput: string;
73 readonly repository: SourceControlRepositoryInfo | null;
74 readonly remoteUrl: string;
75 };
76
77/** The hosts the clone picker offers, in the order the built-in definitions list them. */
78const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<SourceControlProviderKind> =
79 sourceControlClients.definitions.map((definition) => definition.kind);
80
81export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string {
82 return source === "url" ? "Git URL" : sourceControlClients.get(source).pickerLabel;
83}
84
85export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string {
86 return source === "url" ? "URL" : sourceControlClients.get(source).repositoryPathHint;
87}
88
89export function addProjectRemoteSourceProvider(
90 source: AddProjectRemoteSource,
91): SourceControlProviderKind | null {
92 return source === "url" ? null : source;
93}
94
95/** A clone source named in a route or link, or `url` for anything this client does not ship. */
96export function parseAddProjectRemoteSource(
97 value: string | null | undefined,
98): AddProjectRemoteSource {
99 return (value ? sourceControlClients.find(value)?.kind : undefined) ?? "url";
100}
101
102const GITHUB_REPOSITORY_SHORTHAND =
103 /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/;
104
105/** Treat the common owner/repository shorthand as a public GitHub HTTPS URL. */
106export function normalizePastedCloneUrl(input: string): string {
107 const trimmed = input.trim();
108 if (!GITHUB_REPOSITORY_SHORTHAND.test(trimmed)) return trimmed;
109 const repository = trimmed.endsWith(".git") ? trimmed : `${trimmed}.git`;
110 return `https://github.com/${repository}`;
111}
112
113/** The clone URL for the transport the repository's host defaults to. */
114export function getDefaultCloneUrl(
115 repository: Pick<SourceControlRepositoryInfo, "provider" | "url" | "sshUrl">,
116): string {
117 return sourceControlClients.get(repository.provider).defaultCloneTransport === "https"
118 ? repository.url
119 : repository.sshUrl;
120}
121
122export function sortAddProjectProviderSources(
123 readiness: AddProjectRemoteSourceReadiness,
124): ReadonlyArray<SourceControlProviderKind> {
125 return Arr.sort(
126 ADD_PROJECT_REMOTE_PROVIDER_SOURCES,
127 Order.mapInput(
128 Order.Struct({
129 ready: Order.flip(Order.Boolean),
130 label: Order.String,
131 }),
132 (source: SourceControlProviderKind) => ({
133 ready: readiness(source).ready,
134 label: addProjectRemoteSourceLabel(source),
135 }),
136 ),
137 );
138}
139
140const READY: AddProjectRemoteSourceReadinessEntry = { ready: true, hint: null };
141const UNAVAILABLE: AddProjectRemoteSourceReadinessEntry = {
142 ready: false,
143 hint: "Provider status unavailable. Open Source Control settings and rescan.",
144};
145
146export function buildAddProjectRemoteSourceReadiness(
147 discovery: SourceControlDiscoveryResult | null,
148): AddProjectRemoteSourceReadiness {
149 const providerByKind = new Map(
150 (discovery?.sourceControlProviders ?? []).map((provider) => [provider.kind, provider]),
151 );
152 return (source) => {
153 if (source === "url") return READY;
154 const provider = providerByKind.get(source);
155 if (!provider) return UNAVAILABLE;
156 if (provider.status !== "available") return { ready: false, hint: provider.installHint };
157 if (provider.auth.status === "unauthenticated") {
158 return {
159 ready: false,
160 hint:
161 Option.getOrNull(provider.auth.detail) ??
162 `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`,
163 };
164 }
165 return READY;
166 };
167}
168
169export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string {
170 const trimmed = baseDirectory?.trim() ?? "";
171 return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed);
172}
173
174/**
175 * Folder name `git clone` would pick, from either a looked-up repository or a
176 * pasted clone URL. Providers report `owner/repo`, Azure DevOps reports
177 * `org/project/repo`, and a URL can arrive in any form: `https://host/owner/
178 * repo.git`, `ssh://git@host:22/owner/repo`, `git@host:owner/repo.git`, with
179 * or without a query, a fragment or a trailing slash. The repository is always
180 * the last segment, minus the `.git` suffix.
181 */
182export function getCloneDirectoryName(repositoryOrRemoteUrl: string | null | undefined): string {
183 const withoutQuery = (repositoryOrRemoteUrl ?? "").split(/[?#]/)[0]?.trim() ?? "";
184 const schemeIndex = withoutQuery.indexOf("://");
185 // A remote URL carries a host before the repository path. The host is never
186 // the repository, so a link that stops at the host, or at a port, names
187 // nothing and the destination falls back to the browsed folder.
188 const hasHost = schemeIndex >= 0 || /^[^/\\:]+@[^/\\:]+:/.test(withoutQuery);
189 const pathPart = schemeIndex >= 0 ? withoutQuery.slice(schemeIndex + "://".length) : withoutQuery;
190 const segments = pathPart.split(/[/\\:]+/).filter((segment) => segment.trim().length > 0);
191 if (hasHost && segments.length < 2) {
192 return "";
193 }
194
195 const lastSegment = segments.at(-1)?.trim() ?? "";
196 // A port can only sit directly behind the authority, so it is a port only
197 // when nothing follows it. Deeper segments are path, even when numeric: the
198 // repository in `https://host/acme/123` really is named `123`.
199 if (hasHost && segments.length === 2 && /^\d+$/.test(lastSegment)) {
200 return "";
201 }
202 return lastSegment.endsWith(".git") ? lastSegment.slice(0, -".git".length) : lastSegment;
203}
204
205/**
206 * Clone destination proposed for a directory: the directory the user picked
207 * plus the repository folder inside it. Without a name the directory is the
208 * destination, which is what the raw clone URL flow keeps doing.
209 */
210export function getCloneDestinationPath(
211 directoryPath: string,
212 directoryName: string | null | undefined,
213): string {
214 const name = directoryName?.trim() ?? "";
215 if (name.length === 0) {
216 return directoryPath;
217 }
218 return `${ensureBrowseDirectoryPath(directoryPath)}${name}`;
219}
220
221/**
222 * Where `projects.createNew` will put a project named `name`. The server adds
223 * `-2`, `-3`, ... when that folder is taken, so this is a preview.
224 */
225export function getNewProjectPathPreview(newProjectsRoot: string, name: string): string {
226 return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name));
227}
228
229/** A host a new project can be published to, and who the repository goes under there. */
230export interface NewProjectPublishTarget {
231 readonly definition: SourceControlClientDefinition;
232 /** Null lets the host's CLI place it under the signed-in user. */
233 readonly owner: string | null;
234}
235
236/**
237 * The hosts a new project can be published to on that environment, in definition order so
238 * GitHub leads. A host is offered when it is ready and its account names where the repository
239 * goes.
240 */
241export function getNewProjectPublishTargets(
242 discovery: SourceControlDiscoveryResult | null,
243): ReadonlyArray<NewProjectPublishTarget> {
244 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
245 return sourceControlClients.definitions.flatMap((definition) => {
246 if (!readiness(definition.kind).ready) return [];
247 const provider = discovery?.sourceControlProviders.find(
248 (candidate) => candidate.kind === definition.kind,
249 );
250 const placement = definition.newRepositoryOwner(
251 provider ? Option.getOrNull(provider.auth.account) : null,
252 );
253 return placement === null ? [] : [{ definition, owner: placement.owner }];
254 });
255}
256
257/** The chosen host's target, or the first one when the choice is not on offer. */
258export function getNewProjectPublishTarget(
259 targets: ReadonlyArray<NewProjectPublishTarget>,
260 kind: SourceControlProviderKind | null,
261): NewProjectPublishTarget | null {
262 return targets.find((target) => target.definition.kind === kind) ?? targets[0] ?? null;
263}
264
265/** `owner/folder` for publishing a new project, or just the folder for the host to place. */
266export function getNewProjectRepository(
267 target: Pick<NewProjectPublishTarget, "owner">,
268 workspaceRoot: string,
269): string {
270 const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? "";
271 return target.owner ? `${target.owner}/${folderName}` : folderName;
272}
273
274/**
275 * Destination query after choosing a directory while the clone folder is
276 * pinned in the path input. Selecting an existing directory with the pinned
277 * name uses that directory directly instead of producing `repo/repo`.
278 */
279export function getCloneDestinationBrowsePath(input: {
280 readonly browseDirectoryPath: string;
281 readonly selectedDirectoryName: string;
282 readonly cloneDirectoryName: string;
283 readonly caseSensitive: boolean;
284}): string {
285 const selectedDirectoryPath = appendBrowsePathSegment(
286 input.browseDirectoryPath,
287 input.selectedDirectoryName,
288 );
289 const selectedDirectoryMatches = input.caseSensitive
290 ? input.selectedDirectoryName === input.cloneDirectoryName
291 : input.selectedDirectoryName.toLowerCase() === input.cloneDirectoryName.toLowerCase();
292 return selectedDirectoryMatches
293 ? selectedDirectoryPath
294 : getCloneDestinationPath(selectedDirectoryPath, input.cloneDirectoryName);
295}
296
297export function resolveAddProjectPath(input: {
298 readonly rawPath: string;
299 readonly currentProjectCwd?: string | null;
300 readonly platform: string;
301}): { readonly ok: true; readonly path: string } | { readonly ok: false; readonly error: string } {
302 const rawPath = input.rawPath.trim();
303 if (rawPath.length === 0) {
304 return { ok: false, error: "Enter a project path." };
305 }
306 if (isUnsupportedWindowsProjectPath(rawPath, input.platform)) {
307 return { ok: false, error: "Windows-style paths are only supported on Windows environments." };
308 }
309 if (isExplicitRelativeProjectPath(rawPath) && !input.currentProjectCwd) {
310 return { ok: false, error: "Relative paths require an active project in this environment." };
311 }
312 const path = resolveProjectPathForDispatch(rawPath, input.currentProjectCwd);
313 return path.length === 0 ? { ok: false, error: "Enter a project path." } : { ok: true, path };
314}
315
316export function findExistingAddProject(input: {
317 readonly projects: ReadonlyArray<EnvironmentProject>;
318 readonly environmentId: EnvironmentId;
319 readonly path: string;
320}): EnvironmentProject | null {
321 return (
322 findProjectByPath(
323 input.projects.filter((project) => project.environmentId === input.environmentId),
324 input.path,
325 ) ?? null
326 );
327}
328
329export function buildProjectCreateCommand(input: {
330 readonly commandId: CommandId;
331 readonly projectId: ProjectId;
332 readonly workspaceRoot: string;
333}): Extract<ProjectMutation, { type: "project.create" }> {
334 return {
335 type: "project.create",
336 commandId: input.commandId,
337 projectId: input.projectId,
338 title: inferProjectTitleFromPath(input.workspaceRoot),
339 workspaceRoot: input.workspaceRoot,
340 createWorkspaceRootIfMissing: true,
341 defaultModelSelection: null,
342 };
343}