Skip to content
Open
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
151 changes: 151 additions & 0 deletions packages/app/src/components/dialog-open-project-git.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { Button } from "@opencode-ai/ui/button"
import { Progress } from "@opencode-ai/ui/progress"
import { TextField } from "@opencode-ai/ui/text-field"
import { type JSX, Show, createEffect, onMount } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language"
import { type Platform, usePlatform } from "@/context/platform"
import { parseProjectInput, resolveCloneRepositoryUrl, suggestCloneTargetPath } from "./dialog-open-project.helpers"

type Props = {
title: string
lockMode?: boolean
value: string
target: string
targetRoot: string
targetManual: boolean
busy: boolean
setValue: (value: string) => void
setTarget: (value: string, manual?: boolean) => void
setTargetRoot: (value: string) => void
clearError: () => void
}

type CloneOpts = {
input: string
target: string
platform: Platform
sdk: ReturnType<typeof useGlobalSDK>
language: ReturnType<typeof useLanguage>
onSelect: (dir: string) => void
}

export async function cloneProject(opts: CloneOpts) {
const url = resolveCloneRepositoryUrl(opts.input)
if (!url) {
throw new Error(opts.language.t("dialog.project.open.error.gitInvalid"))
}
if (!opts.platform.cloneGitRepository) throw new Error(opts.language.t("common.requestFailed"))

const dir = parseProjectInput(opts.target)
const next = await opts.platform.cloneGitRepository(
url,
dir ? (opts.platform.normalizeProjectPath ? await opts.platform.normalizeProjectPath(dir) : dir) : undefined,
)

await opts.sdk.client.file.list({ directory: next, path: "" })
opts.onSelect(next)
}

export function DialogOpenProjectGit(props: Props) {
const language = useLanguage()
const platform = usePlatform()

onMount(() => {
if (!platform.getDefaultCloneDirectory) return
void platform.getDefaultCloneDirectory().then((root: string | null) => {
if (!root) return
props.setTargetRoot(root)
})
})

createEffect(() => {
if (props.targetManual) return
const root = parseProjectInput(props.targetRoot)
if (!root) return
props.setTarget(suggestCloneTargetPath(props.value, root))
})

const browse = async () => {
if (!platform.openDirectoryPickerDialog) return
const res = await platform.openDirectoryPickerDialog({
title: props.title,
multiple: false,
})

const path = Array.isArray(res) ? res[0] : res
if (!path) return
props.setTarget(path, true)
}

return (
<>
<Show when={props.lockMode}>
<div class="flex flex-col gap-2">
<div class="text-14-medium text-text-strong">
{language.t("dialog.project.open.git.label")}
<span class="text-text-muted"> ({language.t("dialog.project.open.git.helper")})</span>
</div>
<TextField
autofocus
type="text"
value={props.value}
label=""
placeholder={language.t("dialog.project.open.git.placeholder")}
onChange={(value: string) => {
props.setValue(value)
props.clearError()
}}
/>
</div>
</Show>

<div class="flex flex-col gap-2 -mt-1 rounded-md border border-border-base bg-surface-raised-base px-3 py-2.5">
<div class="text-14-medium text-text-strong">Local Path</div>
<div class="flex items-center gap-2">
<input
type="text"
class="flex-1 h-9 rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong"
value={props.target}
placeholder={props.targetRoot || "~/Documents/code"}
onInput={(event: JSX.InputEventUnion<HTMLInputElement, InputEvent>) =>
props.setTarget(event.currentTarget.value, true)
}
/>
<Button type="button" variant="secondary" size="large" class="min-w-24" onClick={browse}>
Choose...
</Button>
</div>
<div class="text-12-regular text-text-weak">{language.t("dialog.project.open.path.hint")}</div>
</div>

<Show when={!props.lockMode}>
<TextField
autofocus
type="text"
value={props.value}
label={language.t("dialog.project.open.git.label")}
placeholder={language.t("dialog.project.open.git.placeholder")}
onChange={(value: string) => {
props.setValue(value)
props.clearError()
}}
/>
</Show>

<Show when={props.busy}>
<Progress
indeterminate
aria-label={language.t("dialog.project.open.submit.cloning")}
class={
props.lockMode
? "-mt-2 gap-1 [&_[data-slot='progress-label']]:text-12-regular [&_[data-slot='progress-track']]:h-1"
: "-mt-1 [&_[data-slot='progress-track']]:h-1"
}
>
{language.t("dialog.project.open.submit.cloning")}
</Progress>
</Show>
</>
)
}
56 changes: 56 additions & 0 deletions packages/app/src/components/dialog-open-project.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test"
import {
cloneRepositoryName,
isGitRepositoryUrl,
nextProjectOpenMode,
parseProjectInput,
projectOpenError,
resolveCloneRepositoryUrl,
suggestCloneTargetPath,
} from "./dialog-open-project.helpers"

describe("dialog-open-project helpers", () => {
test("trims project input", () => {
expect(parseProjectInput(" ~/code/foo ")).toBe("~/code/foo")
})

test("validates supported git url formats", () => {
expect(isGitRepositoryUrl("https://github.com/anomalyco/opencode.git")).toBeTrue()
expect(isGitRepositoryUrl("ssh://git@github.com/anomalyco/opencode.git")).toBeTrue()
expect(isGitRepositoryUrl("git@github.com:anomalyco/opencode.git")).toBeTrue()
expect(isGitRepositoryUrl("~/code/opencode")).toBeFalse()
})

test("switches mode between git and path", () => {
expect(nextProjectOpenMode("git")).toBe("path")
expect(nextProjectOpenMode("path")).toBe("git")
})

test("normalizes unknown errors", () => {
expect(projectOpenError(new Error("boom"))).toBe("boom")
expect(projectOpenError("broken")).toBe("broken")
expect(projectOpenError(null)).toBe("Unknown error")
})

test("resolves clone urls for github and gitlab", () => {
expect(resolveCloneRepositoryUrl("anomalyco/opencode")).toBe("https://github.com/anomalyco/opencode.git")
expect(resolveCloneRepositoryUrl("gitlab.com/group/subgroup/project")).toBe(
"https://gitlab.com/group/subgroup/project.git",
)
expect(resolveCloneRepositoryUrl("gitlab:group/project")).toBe("https://gitlab.com/group/project.git")
expect(resolveCloneRepositoryUrl("github:anomalyco/opencode")).toBe("https://github.com/anomalyco/opencode.git")
})

test("extracts repository name for clone target", () => {
expect(cloneRepositoryName("https://github.com/Infatoshi/magic.rs")).toBe("magic.rs")
expect(cloneRepositoryName("gitlab.com/group/subgroup/project")).toBe("project")
expect(cloneRepositoryName("invalid input")).toBe("")
})

test("suggests clone path from root and repository", () => {
expect(suggestCloneTargetPath("https://github.com/Infatoshi/magic.rs", "/Users/me/Documents/code")).toBe(
"/Users/me/Documents/code/magic.rs",
)
expect(suggestCloneTargetPath("", "/Users/me/Documents/code")).toBe("/Users/me/Documents/code")
})
})
73 changes: 73 additions & 0 deletions packages/app/src/components/dialog-open-project.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
export type ProjectOpenMode = "git" | "path"

const gitPattern = /^(https?:\/\/|ssh:\/\/|git@).+/i
const providerHost = {
github: "github.com",
gitlab: "gitlab.com",
} as const

function withGitSuffix(value: string) {
if (value.endsWith(".git")) return value
return `${value}.git`
}

export function parseProjectInput(value: string) {
return value.trim()
}

export function isGitRepositoryUrl(value: string) {
return gitPattern.test(parseProjectInput(value))
}

export function nextProjectOpenMode(mode: ProjectOpenMode) {
if (mode === "git") return "path"
return "git"
}

export function resolveCloneRepositoryUrl(value: string) {
const input = parseProjectInput(value)
if (!input) return ""
if (isGitRepositoryUrl(input)) return input

const provider = /^(github|gitlab):([a-z0-9._-]+(?:\/[a-z0-9._-]+)+)$/i.exec(input)
if (provider) {
const host = providerHost[provider[1].toLowerCase() as keyof typeof providerHost]
return withGitSuffix(`https://${host}/${provider[2]}`)
}

const hostPath = /^([^\s/:]+(?:\.[^\s/:]+)+)\/([a-z0-9._-]+(?:\/[a-z0-9._-]+)+)$/i.exec(input)
if (hostPath) {
return withGitSuffix(`https://${hostPath[1]}/${hostPath[2]}`)
}

const short = /^([a-z0-9._-]+)\/([a-z0-9._-]+)$/i.exec(input)
if (short) {
return withGitSuffix(`https://github.com/${short[1]}/${short[2]}`)
}

return ""
}

export function cloneRepositoryName(value: string) {
const url = resolveCloneRepositoryUrl(value)
if (!url) return ""
const parts = url.replace(/[\\/]+$/, "").split("/")
const tail = parts.at(-1) ?? ""
return tail.replace(/\.git$/i, "")
}

export function suggestCloneTargetPath(value: string, root: string) {
const base = parseProjectInput(root)
if (!base) return ""
const name = cloneRepositoryName(value)
if (!name) return base
const sep = base.includes("\\") && !base.includes("/") ? "\\" : "/"
const trimmed = base.replace(/[\\/]+$/, "")
return `${trimmed}${sep}${name}`
}

export function projectOpenError(error: unknown) {
if (error instanceof Error && error.message) return error.message
if (typeof error === "string" && error) return error
return "Unknown error"
}
Loading
Loading