1import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
2import type {
3 CommandId,
4 EnvironmentId,
5 ProjectMutation,
6 ProjectId,
7 ServerConfig,
8 ServerSettings,
9 SourceControlDiscoveryResult,
10 SourceControlRepositoryInfo,
11} from "@t3tools/contracts";
12import { SourceControlProviderKind } from "@t3tools/contracts";
13import { cloneUrlRepositoryPath } from "@t3tools/shared/cloneUrl";
14import { newProjectFolderName } from "@t3tools/shared/path";
15import * as Arr from "effect/Array";
16import * as Option from "effect/Option";
17import * as Order from "effect/Order";
18
19import {
20 appendBrowsePathSegment,
21 ensureBrowseDirectoryPath,
22 findProjectByPath,
23 getBrowseParentPath,
24 inferProjectTitleFromPath,
25 isExplicitRelativeProjectPath,
26 isUnsupportedWindowsProjectPath,
27 resolveProjectPathForDispatch,
28} from "../state/projects.ts";
29import type { EnvironmentProject } from "../state/models.ts";
30import { sourceControlClients } from "../sourceControlClients.ts";
31import type { SourceControlDefinition } from "../sourceControlClients.ts";
32
33export type AddProjectRemoteSource = SourceControlProviderKind | "url";
34
35export function canCreateProjectInEnvironment(
36 connectionPhase: EnvironmentConnectionPhase | null | undefined,
37): boolean {
38 return connectionPhase === "connected";
39}
40
41export function availableScratchWorkspaceRoot(
42 connectionPhase: EnvironmentConnectionPhase | null | undefined,
43 serverConfig: Pick<ServerConfig, "scratchWorkspaceRoot"> | null | undefined,
44): string | null {
45 return canCreateProjectInEnvironment(connectionPhase)
46 ? (serverConfig?.scratchWorkspaceRoot ?? null)
47 : null;
48}
49
50export interface AddProjectRemoteSourceReadinessEntry {
51 readonly ready: boolean;
52 readonly hint: string | null;
53}
54
55export type AddProjectRemoteSourceReadiness = (
56 source: AddProjectRemoteSource,
57) => AddProjectRemoteSourceReadinessEntry;
58
59export type AddProjectCloneFlow =
60 | {
61 readonly step: "repository";
62 readonly environmentId: EnvironmentId;
63 readonly source: AddProjectRemoteSource;
64 }
65 | {
66 readonly step: "confirm";
67 readonly environmentId: EnvironmentId;
68 readonly source: AddProjectRemoteSource;
69 readonly repositoryInput: string;
70 readonly repository: SourceControlRepositoryInfo | null;
71 readonly remoteUrl: string;
72 };
73
74const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<SourceControlProviderKind> =
75 sourceControlClients.definitions.map((definition) => definition.kind);
76
77export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string {
78 return source === "url" ? "Git URL" : sourceControlClients.get(source).pickerLabel;
79}
80
81export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string {
82 return source === "url" ? "URL" : sourceControlClients.get(source).repositoryPathHint;
83}
84
85export function addProjectRemoteSourceProvider(
86 source: AddProjectRemoteSource,
87): SourceControlProviderKind | null {
88 return source === "url" ? null : source;
89}
90
91export function parseAddProjectRemoteSource(
92 value: string | null | undefined,
93): AddProjectRemoteSource {
94 return (value ? sourceControlClients.find(value)?.kind : undefined) ?? "url";
95}
96
97const REPOSITORY_SHORTHAND = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/;
98
99/**
100 * The clone URL of `owner/name` on the public host of the host a repository reads as before it
101 * reports one. Null when that host has no public host.
102 */
103export function defaultHostCloneUrl(repository: string): string | null {
104 const definition = sourceControlClients.get(undefined);
105 if (definition.publicHost === null) return null;
106 const path = repository.endsWith(".git") ? repository : `${repository}.git`;
107 return definition.defaultCloneTransport === "https"
108 ? `https://${definition.publicHost}/${path}`
109 : `git@${definition.publicHost}:${path}`;
110}
111
112/** Owner present, repository name missing: `owner/` or `owner//`. */
113const INCOMPLETE_GITHUB_OWNER_PATH = /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,38})\/+$/;
114
115const INCOMPLETE_GITHUB_OWNER_REPOSITORY_MESSAGE = "Enter the full owner/repository name.";
116
117/**
118 * Message to show when a GitHub add-project field is only `owner/`.
119 * Lookup would send that to GitHub and come back as a generic failure.
120 * Returns null for complete names, bare repo names, URLs, and other sources.
121 */
122export function githubIncompleteOwnerRepositoryMessage(
123 source: AddProjectRemoteSource,
124 input: string,
125): string | null {
126 if (source !== "github") return null;
127 if (!INCOMPLETE_GITHUB_OWNER_PATH.test(input.trim())) return null;
128 return INCOMPLETE_GITHUB_OWNER_REPOSITORY_MESSAGE;
129}
130
131export function normalizePastedCloneUrl(input: string): string {
132 const trimmed = input.trim();
133 if (REPOSITORY_SHORTHAND.test(trimmed)) return defaultHostCloneUrl(trimmed) ?? trimmed;
134 return sourceControlClients.repositoryUrlFromPage(trimmed) ?? trimmed;
135}
136
137export function getDefaultCloneUrl(
138 repository: Pick<SourceControlRepositoryInfo, "provider" | "url" | "sshUrl">,
139): string {
140 return sourceControlClients.get(repository.provider).defaultCloneTransport === "https"
141 ? repository.url
142 : repository.sshUrl;
143}
144
145export type OfferedSourceControlProviders = ServerSettings["offeredSourceControlProviders"];
146
147/** Whether the project pickers offer a host. Only the default host is on until the user turns others on. */
148export function isSourceControlProviderOffered(
149 offered: OfferedSourceControlProviders | undefined,
150 kind: SourceControlProviderKind,
151): boolean {
152 return offered?.[kind] ?? kind === sourceControlClients.get(undefined).kind;
153}
154
155export function sortAddProjectProviderSources(
156 readiness: AddProjectRemoteSourceReadiness,
157 offered: OfferedSourceControlProviders | undefined,
158): ReadonlyArray<SourceControlProviderKind> {
159 return Arr.sort(
160 ADD_PROJECT_REMOTE_PROVIDER_SOURCES.filter((source) =>
161 isSourceControlProviderOffered(offered, source),
162 ),
163 Order.mapInput(
164 Order.Struct({
165 ready: Order.flip(Order.Boolean),
166 label: Order.String,
167 }),
168 (source: SourceControlProviderKind) => ({
169 ready: readiness(source).ready,
170 label: addProjectRemoteSourceLabel(source),
171 }),
172 ),
173 );
174}
175
176const READY: AddProjectRemoteSourceReadinessEntry = { ready: true, hint: null };
177const UNAVAILABLE: AddProjectRemoteSourceReadinessEntry = {
178 ready: false,
179 hint: "Provider status unavailable. Open Source Control settings and rescan.",
180};
181
182export function buildAddProjectRemoteSourceReadiness(
183 discovery: SourceControlDiscoveryResult | null,
184): AddProjectRemoteSourceReadiness {
185 const providerByKind = new Map(
186 (discovery?.sourceControlProviders ?? []).map((provider) => [provider.kind, provider]),
187 );
188 return (source) => {
189 if (source === "url") return READY;
190 const provider = providerByKind.get(source);
191 if (!provider) return UNAVAILABLE;
192 if (provider.status !== "available") return { ready: false, hint: provider.installHint };
193 if (provider.auth.status === "unauthenticated") {
194 return {
195 ready: false,
196 hint:
197 Option.getOrNull(provider.auth.detail) ??
198 `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`,
199 };
200 }
201 return READY;
202 };
203}
204
205export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string {
206 const trimmed = baseDirectory?.trim() ?? "";
207 return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed);
208}
209
210export function getCloneDirectoryName(repositoryOrRemoteUrl: string | null | undefined): string {
211 const withoutQuery = (repositoryOrRemoteUrl ?? "").split(/[?#]/)[0]?.trim() ?? "";
212 const schemeIndex = withoutQuery.indexOf("://");
213 const hasHost = schemeIndex >= 0 || /^[^/\\:]+@[^/\\:]+:/.test(withoutQuery);
214 const pathPart = schemeIndex >= 0 ? withoutQuery.slice(schemeIndex + "://".length) : withoutQuery;
215 const segments = pathPart.split(/[/\\:]+/).filter((segment) => segment.trim().length > 0);
216 if (hasHost && segments.length < 2) {
217 return "";
218 }
219
220 const lastSegment = segments.at(-1)?.trim() ?? "";
221 if (hasHost && segments.length === 2 && /^\d+$/.test(lastSegment)) {
222 return "";
223 }
224 return lastSegment.endsWith(".git") ? lastSegment.slice(0, -".git".length) : lastSegment;
225}
226
227/** The folder a clone gets below its base: `<repo>`, or `<host>/<owner>/<repo>` like ghq. */
228export function getCloneDirectoryPath(
229 remoteUrl: string,
230 ghqCloneLayout: boolean,
231 repository = remoteUrl,
232): string {
233 return (
234 (ghqCloneLayout ? cloneUrlRepositoryPath(remoteUrl) : null) ?? getCloneDirectoryName(repository)
235 );
236}
237
238/** The clone's own folder, which folder browsing keeps at the end of the destination. */
239export function getCloneDirectoryLeafName(
240 remoteUrl: string,
241 ghqCloneLayout: boolean,
242 repository = remoteUrl,
243): string {
244 return getCloneDirectoryPath(remoteUrl, ghqCloneLayout, repository).split("/").at(-1) ?? "";
245}
246
247export function getCloneDestinationPath(
248 directoryPath: string,
249 directoryName: string | null | undefined,
250): string {
251 const name = directoryName?.trim() ?? "";
252 if (name.length === 0) {
253 return directoryPath;
254 }
255 const base = ensureBrowseDirectoryPath(directoryPath);
256 return `${base}${base.endsWith("\\") ? name.replaceAll("/", "\\") : name}`;
257}
258
259export function getNewProjectPathPreview(newProjectsRoot: string, name: string): string {
260 return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name));
261}
262
263export interface NewProjectPublishTarget {
264 readonly definition: SourceControlDefinition;
265 readonly owner: string | null;
266}
267
268export function getNewProjectPublishTargets(
269 discovery: SourceControlDiscoveryResult | null,
270 offered: OfferedSourceControlProviders | undefined,
271): ReadonlyArray<NewProjectPublishTarget> {
272 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
273 return sourceControlClients.definitions.flatMap((definition) => {
274 if (!isSourceControlProviderOffered(offered, definition.kind)) return [];
275 if (!readiness(definition.kind).ready) return [];
276 const provider = discovery?.sourceControlProviders.find(
277 (candidate) => candidate.kind === definition.kind,
278 );
279 const placement = definition.newRepositoryOwner(
280 provider ? Option.getOrNull(provider.auth.account) : null,
281 );
282 return placement === null ? [] : [{ definition, owner: placement.owner }];
283 });
284}
285
286export function getNewProjectPublishTarget(
287 targets: ReadonlyArray<NewProjectPublishTarget>,
288 kind: SourceControlProviderKind | null,
289): NewProjectPublishTarget | null {
290 return targets.find((target) => target.definition.kind === kind) ?? targets[0] ?? null;
291}
292
293export function getNewProjectRepository(
294 target: Pick<NewProjectPublishTarget, "owner">,
295 workspaceRoot: string,
296): string {
297 const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? "";
298 return target.owner ? `${target.owner}/${folderName}` : folderName;
299}
300
301export function getCloneDestinationBrowsePath(input: {
302 readonly browseDirectoryPath: string;
303 readonly selectedDirectoryName: string;
304 readonly cloneDirectoryName: string;
305 readonly caseSensitive: boolean;
306}): string {
307 const selectedDirectoryPath = appendBrowsePathSegment(
308 input.browseDirectoryPath,
309 input.selectedDirectoryName,
310 );
311 const selectedDirectoryMatches = input.caseSensitive
312 ? input.selectedDirectoryName === input.cloneDirectoryName
313 : input.selectedDirectoryName.toLowerCase() === input.cloneDirectoryName.toLowerCase();
314 return selectedDirectoryMatches
315 ? selectedDirectoryPath
316 : getCloneDestinationPath(selectedDirectoryPath, input.cloneDirectoryName);
317}
318
319// The native picker returns a full path, not a name under the browsed directory.
320// Split it and reuse the in-app rules so a folder already named after the
321// repository stays that folder instead of becoming repo/repo.
322export function getCloneDestinationForPickedFolder(input: {
323 readonly pickedPath: string;
324 readonly cloneDirectoryName: string;
325 readonly caseSensitive: boolean;
326}): string {
327 const parentPath = getBrowseParentPath(input.pickedPath);
328 if (parentPath === null) {
329 return getCloneDestinationPath(input.pickedPath, input.cloneDirectoryName);
330 }
331 return getCloneDestinationBrowsePath({
332 browseDirectoryPath: parentPath,
333 selectedDirectoryName: inferProjectTitleFromPath(input.pickedPath),
334 cloneDirectoryName: input.cloneDirectoryName,
335 caseSensitive: input.caseSensitive,
336 });
337}
338
339export function resolveAddProjectPath(input: {
340 readonly rawPath: string;
341 readonly currentProjectCwd?: string | null;
342 readonly platform: string;
343}): { readonly ok: true; readonly path: string } | { readonly ok: false; readonly error: string } {
344 const rawPath = input.rawPath.trim();
345 if (rawPath.length === 0) {
346 return { ok: false, error: "Enter a project path." };
347 }
348 if (isUnsupportedWindowsProjectPath(rawPath, input.platform)) {
349 return { ok: false, error: "Windows-style paths are only supported on Windows environments." };
350 }
351 if (isExplicitRelativeProjectPath(rawPath) && !input.currentProjectCwd) {
352 return { ok: false, error: "Relative paths require an active project in this environment." };
353 }
354 const path = resolveProjectPathForDispatch(rawPath, input.currentProjectCwd);
355 return path.length === 0 ? { ok: false, error: "Enter a project path." } : { ok: true, path };
356}
357
358export function findExistingAddProject(input: {
359 readonly projects: ReadonlyArray<EnvironmentProject>;
360 readonly environmentId: EnvironmentId;
361 readonly path: string;
362}): EnvironmentProject | null {
363 return (
364 findProjectByPath(
365 input.projects.filter((project) => project.environmentId === input.environmentId),
366 input.path,
367 ) ?? null
368 );
369}
370
371export function buildProjectCreateCommand(input: {
372 readonly commandId: CommandId;
373 readonly projectId: ProjectId;
374 readonly workspaceRoot: string;
375}): Extract<ProjectMutation, { type: "project.create" }> {
376 return {
377 type: "project.create",
378 commandId: input.commandId,
379 projectId: input.projectId,
380 title: inferProjectTitleFromPath(input.workspaceRoot),
381 workspaceRoot: input.workspaceRoot,
382 createWorkspaceRootIfMissing: true,
383 defaultModelSelection: null,
384 };
385}