| 1 | import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; |
| 2 | import type { |
| 3 | CommandId, |
| 4 | EnvironmentId, |
| 5 | ProjectMutation, |
| 6 | ProjectId, |
| 7 | ServerConfig, |
| 8 | SourceControlDiscoveryResult, |
| 9 | SourceControlRepositoryInfo, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 12 | import { newProjectFolderName } from "@t3tools/shared/path"; |
| 13 | import * as Arr from "effect/Array"; |
| 14 | import * as Option from "effect/Option"; |
| 15 | import * as Order from "effect/Order"; |
| 16 | |
| 17 | import { |
| 18 | appendBrowsePathSegment, |
| 19 | ensureBrowseDirectoryPath, |
| 20 | findProjectByPath, |
| 21 | inferProjectTitleFromPath, |
| 22 | isExplicitRelativeProjectPath, |
| 23 | isUnsupportedWindowsProjectPath, |
| 24 | resolveProjectPathForDispatch, |
| 25 | } from "../state/projects.ts"; |
| 26 | import type { EnvironmentProject } from "../state/models.ts"; |
| 27 | import { sourceControlClients } from "../sourceControlClients.ts"; |
| 28 | import type { SourceControlClientDefinition } from "../sourceControlClients.ts"; |
| 29 | |
| 30 | /** A host to clone from, or `url` for a pasted clone URL. */ |
| 31 | export type AddProjectRemoteSource = SourceControlProviderKind | "url"; |
| 32 | |
| 33 | export 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 | */ |
| 43 | export 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 | |
| 52 | export 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. */ |
| 58 | export type AddProjectRemoteSourceReadiness = ( |
| 59 | source: AddProjectRemoteSource, |
| 60 | ) => AddProjectRemoteSourceReadinessEntry; |
| 61 | |
| 62 | export 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. */ |
| 78 | const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<SourceControlProviderKind> = |
| 79 | sourceControlClients.definitions.map((definition) => definition.kind); |
| 80 | |
| 81 | export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string { |
| 82 | return source === "url" ? "Git URL" : sourceControlClients.get(source).pickerLabel; |
| 83 | } |
| 84 | |
| 85 | export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string { |
| 86 | return source === "url" ? "URL" : sourceControlClients.get(source).repositoryPathHint; |
| 87 | } |
| 88 | |
| 89 | export 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. */ |
| 96 | export function parseAddProjectRemoteSource( |
| 97 | value: string | null | undefined, |
| 98 | ): AddProjectRemoteSource { |
| 99 | return (value ? sourceControlClients.find(value)?.kind : undefined) ?? "url"; |
| 100 | } |
| 101 | |
| 102 | const 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. */ |
| 106 | export 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. */ |
| 114 | export 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 | |
| 122 | export 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 | |
| 140 | const READY: AddProjectRemoteSourceReadinessEntry = { ready: true, hint: null }; |
| 141 | const UNAVAILABLE: AddProjectRemoteSourceReadinessEntry = { |
| 142 | ready: false, |
| 143 | hint: "Provider status unavailable. Open Source Control settings and rescan.", |
| 144 | }; |
| 145 | |
| 146 | export 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 | |
| 169 | export 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 | */ |
| 182 | export 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 | */ |
| 210 | export 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 | */ |
| 225 | export 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. */ |
| 230 | export 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 | */ |
| 241 | export 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. */ |
| 258 | export 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. */ |
| 266 | export 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 | */ |
| 279 | export 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 | |
| 297 | export 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 | |
| 316 | export 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 | |
| 329 | export 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 | } |