From ac5e42d9479db25b772023d43f76d4dca04e3cd6 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 5 Mar 2026 19:48:14 +0000 Subject: [PATCH 1/3] feat(app): add in-app desktop clone project flow --- .../dialog-open-project.helpers.test.ts | 56 ++++ .../components/dialog-open-project.helpers.ts | 73 +++++ .../src/components/dialog-open-project.tsx | 299 ++++++++++++++++++ .../app/src/components/settings-general.tsx | 110 ++++++- packages/app/src/context/platform.tsx | 12 + packages/app/src/i18n/en.ts | 22 ++ packages/app/src/pages/layout.tsx | 39 +++ packages/desktop/package.json | 1 + packages/desktop/src-tauri/src/constants.rs | 1 + packages/desktop/src-tauri/src/lib.rs | 206 +++++++++++- packages/desktop/src-tauri/src/server.rs | 82 ++++- packages/desktop/src/bindings.ts | 3 + packages/desktop/src/index.tsx | 15 + packages/desktop/src/menu.ts | 5 + 14 files changed, 918 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/components/dialog-open-project.helpers.test.ts create mode 100644 packages/app/src/components/dialog-open-project.helpers.ts create mode 100644 packages/app/src/components/dialog-open-project.tsx diff --git a/packages/app/src/components/dialog-open-project.helpers.test.ts b/packages/app/src/components/dialog-open-project.helpers.test.ts new file mode 100644 index 00000000000..86e82ef7eda --- /dev/null +++ b/packages/app/src/components/dialog-open-project.helpers.test.ts @@ -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") + }) +}) diff --git a/packages/app/src/components/dialog-open-project.helpers.ts b/packages/app/src/components/dialog-open-project.helpers.ts new file mode 100644 index 00000000000..0f77cbb73e3 --- /dev/null +++ b/packages/app/src/components/dialog-open-project.helpers.ts @@ -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" +} diff --git a/packages/app/src/components/dialog-open-project.tsx b/packages/app/src/components/dialog-open-project.tsx new file mode 100644 index 00000000000..0b782aca947 --- /dev/null +++ b/packages/app/src/components/dialog-open-project.tsx @@ -0,0 +1,299 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Progress } from "@opencode-ai/ui/progress" +import { Spinner } from "@opencode-ai/ui/spinner" +import { TextField } from "@opencode-ai/ui/text-field" +import { createStore } from "solid-js/store" +import { createEffect, onMount, Show } from "solid-js" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { + parseProjectInput, + projectOpenError, + resolveCloneRepositoryUrl, + suggestCloneTargetPath, +} from "./dialog-open-project.helpers" + +type Mode = "git" | "path" + +export function DialogOpenProject(props: { + onSelect: (directory: string) => void + mode?: Mode + lockMode?: boolean + title?: string +}) { + const dialog = useDialog() + const platform = usePlatform() + const language = useLanguage() + const sdk = useGlobalSDK() + const title = () => props.title ?? language.t("command.project.open") + + const [store, setStore] = createStore({ + mode: (props.mode ?? "git") as Mode, + value: "", + target: "", + targetRoot: "", + targetManual: false, + busy: false, + error: "", + }) + + onMount(() => { + if (!platform.getDefaultCloneDirectory) return + void platform.getDefaultCloneDirectory().then((root) => { + if (!root) return + setStore("targetRoot", root) + }) + }) + + createEffect(() => { + if (store.mode !== "git") return + if (store.targetManual) return + const root = parseProjectInput(store.targetRoot) + if (!root) return + setStore("target", suggestCloneTargetPath(store.value, root)) + }) + + async function browse() { + if (!platform.openDirectoryPickerDialog) return + const result = await platform.openDirectoryPickerDialog({ + title: title(), + multiple: false, + }) + + if (Array.isArray(result)) { + if (!result[0]) return + setStore("value", result[0]) + setStore("error", "") + return + } + + if (!result) return + setStore("value", result) + setStore("error", "") + } + + async function browseTarget() { + if (!platform.openDirectoryPickerDialog) return + const result = await platform.openDirectoryPickerDialog({ + title: title(), + multiple: false, + }) + + if (Array.isArray(result)) { + if (!result[0]) return + setStore("target", result[0]) + setStore("targetManual", true) + return + } + + if (!result) return + setStore("target", result) + setStore("targetManual", true) + } + + async function openPath(input: string) { + if (!input) throw new Error(language.t("dialog.project.open.error.pathRequired")) + const directory = platform.normalizeProjectPath ? await platform.normalizeProjectPath(input) : input + + await sdk.client.file.list({ directory, path: "" }) + props.onSelect(directory) + } + + async function clone(input: string) { + const url = resolveCloneRepositoryUrl(input) + if (!url) { + throw new Error(language.t("dialog.project.open.error.gitInvalid")) + } + if (!platform.cloneGitRepository) throw new Error(language.t("common.requestFailed")) + + const target = parseProjectInput(store.target) + const directory = await platform.cloneGitRepository( + url, + target ? (platform.normalizeProjectPath ? await platform.normalizeProjectPath(target) : target) : undefined, + ) + + await sdk.client.file.list({ directory, path: "" }) + props.onSelect(directory) + } + + async function submit(e: SubmitEvent) { + e.preventDefault() + if (store.busy) return + + setStore("busy", true) + setStore("error", "") + + await Promise.resolve() + .then(async () => { + const value = parseProjectInput(store.value) + if (store.mode === "git") { + await clone(value) + dialog.close() + return + } + + await openPath(value) + dialog.close() + }) + .catch((error) => { + setStore("error", projectOpenError(error)) + }) + .finally(() => { + setStore("busy", false) + }) + } + + return ( + +
+ +
+
+ {language.t("dialog.project.open.git.label")} + ({language.t("dialog.project.open.git.helper")}) +
+ { + setStore("value", value) + if (!store.error) return + setStore("error", "") + }} + /> +
+ +
+
Local Path
+
+ { + setStore("target", event.currentTarget.value) + setStore("targetManual", true) + }} + /> + +
+
{language.t("dialog.project.open.path.hint")}
+
+
+ + +
+ + +
+
+ + + { + setStore("value", value) + if (!store.error) return + setStore("error", "") + }} + /> + + + +
+ +
+
+ + +
{store.error}
+
+ + + + {language.t("dialog.project.open.submit.cloning")} + + + +
+ + +
+
+
+ ) +} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 42ee4092f68..d5dc91fbc60 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,9 +1,10 @@ -import { Component, Show, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" +import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" @@ -45,9 +46,72 @@ export const SettingsGeneral: Component = () => { const [store, setStore] = createStore({ checking: false, + clonePath: "", + cloneBusy: false, + cloneDirty: false, }) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") + const desktopClonePath = createMemo( + () => platform.platform === "desktop" && !!platform.getDefaultCloneDirectory && !!platform.setDefaultCloneDirectory, + ) + const [clonePathResource, clonePathActions] = createResource(() => + desktopClonePath() ? platform.getDefaultCloneDirectory?.() : null, + ) + + createEffect(() => { + const path = clonePathResource.latest + if (!path) return + if (store.cloneDirty) return + setStore("clonePath", path) + }) + + const saveClonePath = async () => { + const setClonePath = platform.setDefaultCloneDirectory + if (!setClonePath) return + setStore("cloneBusy", true) + const path = store.clonePath.trim() + await Promise.resolve() + .then(async () => { + await setClonePath(path || null) + setStore("cloneDirty", false) + await clonePathActions.refetch() + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + .finally(() => setStore("cloneBusy", false)) + } + + const resetClonePath = async () => { + const setClonePath = platform.setDefaultCloneDirectory + if (!setClonePath) return + setStore("cloneBusy", true) + await Promise.resolve() + .then(async () => { + await setClonePath(null) + setStore("cloneDirty", false) + await clonePathActions.refetch() + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + .finally(() => setStore("cloneBusy", false)) + } + + const chooseClonePath = async () => { + if (!platform.openDirectoryPickerDialog) return + const result = await platform.openDirectoryPickerDialog({ + title: language.t("settings.desktop.clonePath.title"), + multiple: false, + }) + const value = Array.isArray(result) ? result[0] : result + if (!value) return + setStore("clonePath", value) + setStore("cloneDirty", true) + } const check = () => { if (!platform.checkUpdate) return @@ -456,6 +520,48 @@ export const SettingsGeneral: Component = () => { ) + const DesktopProjectsSection = () => ( + +
+

{language.t("settings.desktop.section.projects")}

+ +
+ +
+ { + setStore("clonePath", value) + setStore("cloneDirty", true) + }} + /> + + + +
+
+
+
+
+ ) + return (
@@ -473,6 +579,8 @@ export const SettingsGeneral: Component = () => { + + {/* {(_) => { const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.()) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 86f3321e464..9d7fe5fdadf 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -24,6 +24,18 @@ export type Platform = { /** Open a local path in a local app (desktop only) */ openPath?(path: string, app?: string): Promise + /** Normalize user-provided project path for the current platform */ + normalizeProjectPath?(path: string): Promise + + /** Clone a remote git repository and return the local directory */ + cloneGitRepository?(url: string, directory?: string): Promise + + /** Get default local clone directory for this platform */ + getDefaultCloneDirectory?(): Promise + + /** Set default local clone directory for this platform */ + setDefaultCloneDirectory?(path: string | null): Promise | void + /** Restart the app */ restart(): Promise diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739df..2177a40aa49 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -23,6 +23,24 @@ export const dict = { "command.sidebar.toggle": "Toggle sidebar", "command.project.open": "Open project", + "command.project.clone": "Clone project", + "command.project.new": "New project", + "dialog.project.open.mode.git": "Clone from Git URL", + "dialog.project.open.mode.path": "Open local path", + "dialog.project.open.git.label": "Repository URL", + "dialog.project.open.git.placeholder": "https://gitlab.com/group/repo.git", + "dialog.project.open.git.helper": "gitlab.com/group/repo, github.com/org/repo, org/repo", + "dialog.project.open.path.label": "Project path", + "dialog.project.open.path.placeholder": "~/projects/my-app", + "dialog.project.open.path.hint": "Default location is set in Settings > General > Projects.", + "dialog.project.open.path.browse": "Choose folder", + "dialog.project.open.submit.git": "Clone and open", + "dialog.project.open.submit.path": "Open project", + "dialog.project.open.submit.cloning": "Cloning...", + "dialog.project.open.submit.opening": "Opening...", + "dialog.project.open.error.gitRequired": "Enter a Git repository URL", + "dialog.project.open.error.gitInvalid": "Enter a valid Git repository URL", + "dialog.project.open.error.pathRequired": "Enter a local path", "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", "command.settings.open": "Open settings", @@ -616,8 +634,12 @@ export const dict = { "settings.tab.general": "General", "settings.tab.shortcuts": "Shortcuts", "settings.desktop.section.wsl": "WSL", + "settings.desktop.section.projects": "Projects", "settings.desktop.wsl.title": "WSL integration", "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", + "settings.desktop.clonePath.title": "Default clone location", + "settings.desktop.clonePath.description": "Used by the Clone project dialog when choosing a local destination.", + "settings.desktop.clonePath.placeholder": "~/Documents/code", "settings.general.section.appearance": "Appearance", "settings.general.section.notifications": "System notifications", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2019ca4e5a8..d00ca3f74d8 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -55,6 +55,7 @@ import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogEditProject } from "@/components/dialog-edit-project" +import { DialogOpenProject } from "@/components/dialog-open-project" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" @@ -879,6 +880,13 @@ export default function Layout(props: ParentProps) { keybind: "mod+o", onSelect: () => chooseProject(), }, + { + id: "project.clone", + title: language.t("command.project.clone"), + category: language.t("command.category.project"), + keybind: "mod+shift+o", + onSelect: () => chooseCloneProject(), + }, { id: "provider.connect", title: language.t("command.provider.connect"), @@ -1294,6 +1302,37 @@ export default function Layout(props: ParentProps) { } } + function chooseCloneProject() { + if (!(platform.platform === "desktop" && server.isLocal() && platform.cloneGitRepository)) { + void chooseProject() + return + } + + function resolve(result: string | string[] | null) { + if (Array.isArray(result)) { + for (const directory of result) { + openProject(directory, false) + } + navigateToProject(result[0]) + return + } + + if (result) openProject(result) + } + + dialog.show( + () => ( + resolve(directory)} + /> + ), + () => resolve(null), + ) + } + const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => { if (directory === root) return diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 49699ff85e9..06aae1a061b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -6,6 +6,7 @@ "license": "MIT", "scripts": { "typecheck": "tsgo -b", + "test:clone": "cargo test --manifest-path src-tauri/Cargo.toml test_clone_with_git_real_repository -- --nocapture", "predev": "bun ./scripts/predev.ts", "dev": "vite", "build": "bun run typecheck && vite build", diff --git a/packages/desktop/src-tauri/src/constants.rs b/packages/desktop/src-tauri/src/constants.rs index 9d50d00e202..fdb69e2e65d 100644 --- a/packages/desktop/src-tauri/src/constants.rs +++ b/packages/desktop/src-tauri/src/constants.rs @@ -2,6 +2,7 @@ use tauri_plugin_window_state::StateFlags; pub const SETTINGS_STORE: &str = "opencode.settings.dat"; pub const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; +pub const DEFAULT_CLONE_DIRECTORY_KEY: &str = "defaultCloneDirectory"; pub const WSL_ENABLED_KEY: &str = "wslEnabled"; pub const UPDATER_ENABLED: bool = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 137692cdf73..d1d6c11a136 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -35,7 +35,9 @@ use tokio::{ use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli}; use crate::constants::*; -use crate::server::get_saved_server_url; +use crate::server::{get_default_clone_directory, get_saved_server_url}; +#[cfg(target_os = "windows")] +use crate::server::get_wsl_config; use crate::windows::{LoadingWindow, MainWindow}; #[derive(Clone, serde::Serialize, specta::Type, Debug)] @@ -314,6 +316,185 @@ fn wsl_path(path: String, mode: Option) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +#[cfg(target_os = "windows")] +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn repo_name(url: &str) -> String { + let trimmed = url.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return "repository".to_string(); + } + + let tail = trimmed.rsplit(['/', ':']).next().unwrap_or("repository"); + let name = tail.strip_suffix(".git").unwrap_or(tail).trim(); + if name.is_empty() { + return "repository".to_string(); + } + + name.to_string() +} + +fn clone_with_git(url: &str, target: &str) -> Result<(), String> { + tracing::info!(%url, %target, "Running git clone"); + let output = Command::new("git") + .args(["clone", "--", url, target]) + .output() + .map_err(|e| format!("Failed to run git clone: {e}"))?; + + if output.status.success() { + tracing::info!(%target, "git clone completed"); + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !stderr.is_empty() { + tracing::warn!(%url, %target, stderr = %stderr, "git clone failed"); + return Err(stderr); + } + + tracing::warn!(%url, %target, "git clone failed without stderr"); + Err("git clone failed".to_string()) +} + +#[cfg(target_os = "windows")] +fn wsl_run(command: &str) -> Result { + Command::new("wsl") + .args(["-e", "sh", "-lc", command]) + .output() + .map_err(|e| format!("Failed to run WSL command: {e}")) +} + +#[cfg(target_os = "windows")] +fn clone_with_wsl(url: &str, base: Option<&str>) -> Result { + let root = if let Some(base) = base.filter(|v| !v.trim().is_empty()) { + base.trim().to_string() + } else { + let output = wsl_run("printf %s \"$HOME\"")?; + if !output.status.success() { + return Err("Failed to resolve WSL home directory".to_string()); + } + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + return Err("Failed to resolve WSL home directory".to_string()); + } + value + }; + + let mkdir = format!("mkdir -p {}", shell_quote(&root)); + let mkdir_output = wsl_run(&mkdir)?; + if !mkdir_output.status.success() { + return Err("Failed to create clone destination directory".to_string()); + } + + let name = repo_name(url); + let mut index = 1usize; + let target = loop { + let candidate = if index == 1 { + format!("{root}/{name}") + } else { + format!("{root}/{name}-{index}") + }; + + let cmd = format!("[ -d {} ]", shell_quote(&candidate)); + let output = wsl_run(&cmd)?; + if !output.status.success() { + break candidate; + } + + index += 1; + }; + + let cmd = format!("git clone -- {} {}", shell_quote(url), shell_quote(&target)); + let output = wsl_run(&cmd)?; + if output.status.success() { + return Ok(target); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !stderr.is_empty() { + return Err(stderr); + } + + Err("git clone failed".to_string()) +} + +#[tauri::command] +#[specta::specta] +fn clone_git_repository(app: AppHandle, url: String, directory: Option) -> Result { + let url = url.trim().to_string(); + if url.is_empty() { + return Err("Repository URL cannot be empty".to_string()); + } + + tracing::info!(%url, ?directory, "clone_git_repository requested"); + + #[cfg(target_os = "windows")] + { + if get_wsl_config(app.clone()).is_ok_and(|v| v.enabled) { + return clone_with_wsl(&url, directory.as_deref()); + } + } + + let name = repo_name(&url); + let target = if let Some(directory) = directory.filter(|v| !v.trim().is_empty()) { + let path = PathBuf::from(directory.trim()); + if path.exists() { + std::fs::create_dir_all(&path) + .map_err(|e| format!("Failed to create clone destination directory: {e}"))?; + + let mut index = 1usize; + loop { + let candidate = if index == 1 { + path.join(&name) + } else { + path.join(format!("{name}-{index}")) + }; + + if !candidate.exists() { + break candidate; + } + + index += 1; + } + } else { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create clone destination directory: {e}"))?; + } + } + path + } + } else { + let root = PathBuf::from(get_default_clone_directory(app.clone())?); + std::fs::create_dir_all(&root) + .map_err(|e| format!("Failed to create clone destination directory: {e}"))?; + + let mut index = 1usize; + loop { + let candidate = if index == 1 { + root.join(&name) + } else { + root.join(format!("{name}-{index}")) + }; + + if !candidate.exists() { + break candidate; + } + + index += 1; + } + }; + + let target = target.to_string_lossy().to_string(); + tracing::info!(%target, "Selected clone destination"); + clone_with_git(&url, &target)?; + tracing::info!(%target, "clone_git_repository succeeded"); + Ok(target) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = make_specta_builder(); @@ -402,6 +583,9 @@ fn make_specta_builder() -> tauri_specta::Builder { markdown::parse_markdown_command, check_app_exists, wsl_path, + clone_git_repository, + server::get_default_clone_directory, + server::set_default_clone_directory, resolve_app_path, open_path ]) @@ -428,6 +612,26 @@ fn test_export_types() { export_types(&builder); } +#[cfg(test)] +#[test] +fn test_clone_with_git_real_repository() { + let root = std::env::temp_dir().join(format!( + "opencode-desktop-clone-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).expect("failed to create temporary clone directory"); + + let target = root.join("opencode"); + let target_str = target.to_string_lossy().to_string(); + let result = clone_with_git("https://github.com/anomalyco/opencode.git", &target_str); + result.expect("failed to clone https://github.com/anomalyco/opencode.git"); + assert!(target.join(".git").exists(), "expected cloned repository to contain .git"); + + if let Err(error) = std::fs::remove_dir_all(&root) { + tracing::warn!(path = %root.display(), %error, "failed to remove clone test directory"); + } +} + #[derive(tauri_specta::Event, serde::Deserialize, specta::Type)] struct LoadingWindowComplete; diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 2c43c1cc8c7..eac82408d05 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -1,14 +1,18 @@ use std::time::{Duration, Instant}; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_store::StoreExt; use tokio::task::JoinHandle; +use tokio::time::timeout; use crate::{ cli, cli::CommandChild, - constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY}, + constants::{ + DEFAULT_CLONE_DIRECTORY_KEY, DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY, + }, + windows::MainWindow, }; #[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug, Default)] @@ -85,19 +89,84 @@ pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> { Ok(()) } +fn clone_directory_default(app: &AppHandle) -> Result { + let home = app + .path() + .home_dir() + .map_err(|e| format!("Failed to resolve home directory: {e}"))?; + + #[cfg(target_os = "linux")] + { + return Ok(home.join("code").to_string_lossy().to_string()); + } + + #[cfg(not(target_os = "linux"))] + { + Ok(home + .join("Documents") + .join("code") + .to_string_lossy() + .to_string()) + } +} + +#[tauri::command] +#[specta::specta] +pub fn get_default_clone_directory(app: AppHandle) -> Result { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + let configured = store + .get(DEFAULT_CLONE_DIRECTORY_KEY) + .and_then(|v| v.as_str().map(str::to_string)) + .filter(|v| !v.trim().is_empty()); + if let Some(configured) = configured { + return Ok(configured); + } + clone_directory_default(&app) +} + +#[tauri::command] +#[specta::specta] +pub fn set_default_clone_directory(app: AppHandle, directory: Option) -> Result<(), String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + if let Some(directory) = directory + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + store.set(DEFAULT_CLONE_DIRECTORY_KEY, serde_json::Value::String(directory)); + } else { + store.delete(DEFAULT_CLONE_DIRECTORY_KEY); + } + + store + .save() + .map_err(|e| format!("Failed to save settings: {}", e))?; + + Ok(()) +} + pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option { if let Some(url) = get_default_server_url(app.clone()).ok().flatten() { tracing::info!(%url, "Using desktop-specific custom URL"); return Some(url); } - if let Some(cli_config) = cli::get_config(app).await - && let Some(url) = get_server_url_from_config(&cli_config) + let cli_config = timeout(Duration::from_secs(5), cli::get_config(app)).await; + if let Ok(Some(config)) = &cli_config + && let Some(url) = get_server_url_from_config(config) { tracing::info!(%url, "Using custom server URL from config"); return Some(url); } + if cli_config.is_err() { + tracing::warn!("Timed out reading CLI config, skipping custom server URL detection"); + } + None } @@ -231,6 +300,11 @@ pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool { return true; } + if app.get_webview_window(MainWindow::LABEL).is_none() { + tracing::warn!(%url, "Configured server is unavailable during startup; falling back to local server"); + return false; + } + const RETRY: &str = "Retry"; let res = app.dialog() diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 80548173e92..c69f5309147 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -17,6 +17,9 @@ export const commands = { parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), checkAppExists: (appName: string) => __TAURI_INVOKE("check_app_exists", { appName }), wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE("wsl_path", { path, mode }), + cloneGitRepository: (url: string, directory: string | null) => __TAURI_INVOKE("clone_git_repository", { url, directory }), + getDefaultCloneDirectory: () => __TAURI_INVOKE("get_default_clone_directory"), + setDefaultCloneDirectory: (directory: string | null) => __TAURI_INVOKE("set_default_clone_directory", { directory }), resolveAppPath: (appName: string) => __TAURI_INVOKE("resolve_app_path", { appName }), openPath: (path: string, appName: string | null) => __TAURI_INVOKE("open_path", { path, appName }), }; diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 9afabe918b1..c598f3abaad 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -114,6 +114,21 @@ const createPlatform = (): Platform => { openLink(url: string) { void shellOpen(url).catch(() => undefined) }, + async normalizeProjectPath(path: string) { + if (os === "windows" && window.__OPENCODE__?.wsl) { + return commands.wslPath(path, "linux").catch(() => path) + } + return path + }, + cloneGitRepository(url: string, directory?: string) { + return commands.cloneGitRepository(url, directory ?? null) + }, + async getDefaultCloneDirectory() { + return commands.getDefaultCloneDirectory().catch(() => null) + }, + async setDefaultCloneDirectory(path: string | null) { + await commands.setDefaultCloneDirectory(path) + }, async openPath(path: string, app?: string) { await commands.openPath(path, app ?? null) }, diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index de6a1d6a76c..a9cdb7d3045 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -69,6 +69,11 @@ export async function createMenu(trigger: (id: string) => void) { accelerator: "Shift+Cmd+S", action: () => trigger("session.new"), }), + await MenuItem.new({ + text: "Clone Project...", + accelerator: "Shift+Cmd+O", + action: () => trigger("project.clone"), + }), await MenuItem.new({ text: t("desktop.menu.file.openProject"), accelerator: "Cmd+O", From ec76c801f98461d3d658ff17527b5f7bff2b7192 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 21:31:39 +0000 Subject: [PATCH 2/3] refactor: split desktop clone UI and platform helpers --- .../components/dialog-open-project-git.tsx | 151 ++++++++++++++ .../src/components/dialog-open-project.tsx | 197 ++++++------------ .../settings-general-clone-path.tsx | 122 +++++++++++ .../app/src/components/settings-general.tsx | 130 +----------- packages/app/src/components/settings-row.tsx | 19 ++ packages/desktop/src/env.d.ts | 11 + packages/desktop/src/index.tsx | 61 +----- packages/desktop/src/platform/project.ts | 93 +++++++++ 8 files changed, 467 insertions(+), 317 deletions(-) create mode 100644 packages/app/src/components/dialog-open-project-git.tsx create mode 100644 packages/app/src/components/settings-general-clone-path.tsx create mode 100644 packages/app/src/components/settings-row.tsx create mode 100644 packages/desktop/src/env.d.ts create mode 100644 packages/desktop/src/platform/project.ts diff --git a/packages/app/src/components/dialog-open-project-git.tsx b/packages/app/src/components/dialog-open-project-git.tsx new file mode 100644 index 00000000000..b145c00a4cb --- /dev/null +++ b/packages/app/src/components/dialog-open-project-git.tsx @@ -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 + language: ReturnType + 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 ( + <> + +
+
+ {language.t("dialog.project.open.git.label")} + ({language.t("dialog.project.open.git.helper")}) +
+ { + props.setValue(value) + props.clearError() + }} + /> +
+
+ +
+
Local Path
+
+ ) => + props.setTarget(event.currentTarget.value, true) + } + /> + +
+
{language.t("dialog.project.open.path.hint")}
+
+ + + { + props.setValue(value) + props.clearError() + }} + /> + + + + + {language.t("dialog.project.open.submit.cloning")} + + + + ) +} diff --git a/packages/app/src/components/dialog-open-project.tsx b/packages/app/src/components/dialog-open-project.tsx index 0b782aca947..9c21809f94d 100644 --- a/packages/app/src/components/dialog-open-project.tsx +++ b/packages/app/src/components/dialog-open-project.tsx @@ -1,20 +1,15 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" -import { Progress } from "@opencode-ai/ui/progress" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { createStore } from "solid-js/store" -import { createEffect, onMount, Show } from "solid-js" +import { Show } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { - parseProjectInput, - projectOpenError, - resolveCloneRepositoryUrl, - suggestCloneTargetPath, -} from "./dialog-open-project.helpers" +import { parseProjectInput, projectOpenError } from "./dialog-open-project.helpers" +import { cloneProject, DialogOpenProjectGit } from "./dialog-open-project-git" type Mode = "git" | "path" @@ -40,22 +35,6 @@ export function DialogOpenProject(props: { error: "", }) - onMount(() => { - if (!platform.getDefaultCloneDirectory) return - void platform.getDefaultCloneDirectory().then((root) => { - if (!root) return - setStore("targetRoot", root) - }) - }) - - createEffect(() => { - if (store.mode !== "git") return - if (store.targetManual) return - const root = parseProjectInput(store.targetRoot) - if (!root) return - setStore("target", suggestCloneTargetPath(store.value, root)) - }) - async function browse() { if (!platform.openDirectoryPickerDialog) return const result = await platform.openDirectoryPickerDialog({ @@ -75,25 +54,6 @@ export function DialogOpenProject(props: { setStore("error", "") } - async function browseTarget() { - if (!platform.openDirectoryPickerDialog) return - const result = await platform.openDirectoryPickerDialog({ - title: title(), - multiple: false, - }) - - if (Array.isArray(result)) { - if (!result[0]) return - setStore("target", result[0]) - setStore("targetManual", true) - return - } - - if (!result) return - setStore("target", result) - setStore("targetManual", true) - } - async function openPath(input: string) { if (!input) throw new Error(language.t("dialog.project.open.error.pathRequired")) const directory = platform.normalizeProjectPath ? await platform.normalizeProjectPath(input) : input @@ -102,23 +62,6 @@ export function DialogOpenProject(props: { props.onSelect(directory) } - async function clone(input: string) { - const url = resolveCloneRepositoryUrl(input) - if (!url) { - throw new Error(language.t("dialog.project.open.error.gitInvalid")) - } - if (!platform.cloneGitRepository) throw new Error(language.t("common.requestFailed")) - - const target = parseProjectInput(store.target) - const directory = await platform.cloneGitRepository( - url, - target ? (platform.normalizeProjectPath ? await platform.normalizeProjectPath(target) : target) : undefined, - ) - - await sdk.client.file.list({ directory, path: "" }) - props.onSelect(directory) - } - async function submit(e: SubmitEvent) { e.preventDefault() if (store.busy) return @@ -130,7 +73,14 @@ export function DialogOpenProject(props: { .then(async () => { const value = parseProjectInput(store.value) if (store.mode === "git") { - await clone(value) + await cloneProject({ + input: value, + target: store.target, + platform, + sdk, + language, + onSelect: props.onSelect, + }) dialog.close() return } @@ -150,44 +100,25 @@ export function DialogOpenProject(props: {
-
-
- {language.t("dialog.project.open.git.label")} - ({language.t("dialog.project.open.git.helper")}) -
- { - setStore("value", value) - if (!store.error) return - setStore("error", "") - }} - /> -
- -
-
Local Path
-
- { - setStore("target", event.currentTarget.value) - setStore("targetManual", true) - }} - /> - -
-
{language.t("dialog.project.open.path.hint")}
-
+ setStore("value", value)} + setTarget={(value, manual) => { + setStore("target", value) + if (manual !== undefined) setStore("targetManual", manual) + }} + setTargetRoot={(value) => setStore("targetRoot", value)} + clearError={() => { + if (!store.error) return + setStore("error", "") + }} + />
@@ -220,26 +151,42 @@ export function DialogOpenProject(props: { - { + setStore("value", value) + if (!store.error) return + setStore("error", "") + }} + /> } - onChange={(value: string) => { - setStore("value", value) - if (!store.error) return - setStore("error", "") - }} - /> + > + setStore("value", value)} + setTarget={(value, manual) => { + setStore("target", value) + if (manual !== undefined) setStore("targetManual", manual) + }} + setTargetRoot={(value) => setStore("targetRoot", value)} + clearError={() => { + if (!store.error) return + setStore("error", "") + }} + /> + @@ -254,20 +201,6 @@ export function DialogOpenProject(props: {
{store.error}
- - - {language.t("dialog.project.open.submit.cloning")} - - -
+ + +
+ + +
+
+ + ) +} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 0a0462a2536..e86c0d847d3 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,10 +1,9 @@ -import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" -import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" @@ -13,7 +12,9 @@ import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" import { playSound, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" +import { SettingsGeneralClonePath } from "./settings-general-clone-path" import { SettingsList } from "./settings-list" +import { SettingsRow } from "./settings-row" let demoSoundState = { cleanup: undefined as (() => void) | undefined, @@ -47,72 +48,9 @@ export const SettingsGeneral: Component = () => { const [store, setStore] = createStore({ checking: false, - clonePath: "", - cloneBusy: false, - cloneDirty: false, }) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") - const desktopClonePath = createMemo( - () => platform.platform === "desktop" && !!platform.getDefaultCloneDirectory && !!platform.setDefaultCloneDirectory, - ) - const [clonePathResource, clonePathActions] = createResource(() => - desktopClonePath() ? platform.getDefaultCloneDirectory?.() : null, - ) - - createEffect(() => { - const path = clonePathResource.latest - if (!path) return - if (store.cloneDirty) return - setStore("clonePath", path) - }) - - const saveClonePath = async () => { - const setClonePath = platform.setDefaultCloneDirectory - if (!setClonePath) return - setStore("cloneBusy", true) - const path = store.clonePath.trim() - await Promise.resolve() - .then(async () => { - await setClonePath(path || null) - setStore("cloneDirty", false) - await clonePathActions.refetch() - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - .finally(() => setStore("cloneBusy", false)) - } - - const resetClonePath = async () => { - const setClonePath = platform.setDefaultCloneDirectory - if (!setClonePath) return - setStore("cloneBusy", true) - await Promise.resolve() - .then(async () => { - await setClonePath(null) - setStore("cloneDirty", false) - await clonePathActions.refetch() - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - .finally(() => setStore("cloneBusy", false)) - } - - const chooseClonePath = async () => { - if (!platform.openDirectoryPickerDialog) return - const result = await platform.openDirectoryPickerDialog({ - title: language.t("settings.desktop.clonePath.title"), - multiple: false, - }) - const value = Array.isArray(result) ? result[0] : result - if (!value) return - setStore("clonePath", value) - setStore("cloneDirty", true) - } const check = () => { if (!platform.checkUpdate) return @@ -543,48 +481,6 @@ export const SettingsGeneral: Component = () => { ) - const DesktopProjectsSection = () => ( - -
-

{language.t("settings.desktop.section.projects")}

- -
- -
- { - setStore("clonePath", value) - setStore("cloneDirty", true) - }} - /> - - - -
-
-
-
-
- ) - return (
@@ -602,7 +498,7 @@ export const SettingsGeneral: Component = () => { - + {/* {(_) => { @@ -673,21 +569,3 @@ export const SettingsGeneral: Component = () => {
) } - -interface SettingsRowProps { - title: string | JSX.Element - description: string | JSX.Element - children: JSX.Element -} - -const SettingsRow: Component = (props) => { - return ( -
-
- {props.title} - {props.description} -
-
{props.children}
-
- ) -} diff --git a/packages/app/src/components/settings-row.tsx b/packages/app/src/components/settings-row.tsx new file mode 100644 index 00000000000..ce42cf8aace --- /dev/null +++ b/packages/app/src/components/settings-row.tsx @@ -0,0 +1,19 @@ +import { Component, type JSX } from "solid-js" + +export interface SettingsRowProps { + title: string | JSX.Element + description: string | JSX.Element + children: JSX.Element +} + +export const SettingsRow: Component = (props) => { + return ( +
+
+ {props.title} + {props.description} +
+
{props.children}
+
+ ) +} diff --git a/packages/desktop/src/env.d.ts b/packages/desktop/src/env.d.ts new file mode 100644 index 00000000000..abc37b60054 --- /dev/null +++ b/packages/desktop/src/env.d.ts @@ -0,0 +1,11 @@ +export {} + +declare global { + interface Window { + __OPENCODE__?: { + deepLinks?: string[] + updaterEnabled?: boolean + wsl?: boolean + } + } +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index a8268883f4e..a413bc37fff 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -13,7 +13,6 @@ import type { AsyncStorage } from "@solid-primitives/storage" import { getCurrentWindow } from "@tauri-apps/api/window" import { readImage } from "@tauri-apps/plugin-clipboard-manager" import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" -import { open, save } from "@tauri-apps/plugin-dialog" import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { type as ostype } from "@tauri-apps/plugin-os" @@ -25,6 +24,7 @@ import { createResource, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../package.json" import { initI18n, t } from "./i18n" +import { createProjectPlatform } from "./platform/project" import { UPDATER_ENABLED } from "./updater" import { webviewZoom } from "./webview-zoom" import "./styles.css" @@ -64,73 +64,16 @@ const createPlatform = (): Platform => { return undefined })() - const wslHome = async () => { - if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined - return commands.wslPath("~", "windows").catch(() => undefined) - } - - const handleWslPicker = async (result: T | null): Promise => { - if (!result || !window.__OPENCODE__?.wsl) return result - if (Array.isArray(result)) { - return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any - } - return commands.wslPath(result, "linux").catch(() => result) as any - } - return { platform: "desktop", os, version: pkg.version, - async openDirectoryPickerDialog(opts) { - const defaultPath = await wslHome() - const result = await open({ - directory: true, - multiple: opts?.multiple ?? false, - title: opts?.title ?? t("desktop.dialog.chooseFolder"), - defaultPath, - }) - return await handleWslPicker(result) - }, - - async openFilePickerDialog(opts) { - const result = await open({ - directory: false, - multiple: opts?.multiple ?? false, - title: opts?.title ?? t("desktop.dialog.chooseFile"), - }) - return handleWslPicker(result) - }, - - async saveFilePickerDialog(opts) { - const result = await save({ - title: opts?.title ?? t("desktop.dialog.saveFile"), - defaultPath: opts?.defaultPath, - }) - return handleWslPicker(result) - }, + ...createProjectPlatform(os), openLink(url: string) { void shellOpen(url).catch(() => undefined) }, - async normalizeProjectPath(path: string) { - if (os === "windows" && window.__OPENCODE__?.wsl) { - return commands.wslPath(path, "linux").catch(() => path) - } - return path - }, - cloneGitRepository(url: string, directory?: string) { - return commands.cloneGitRepository(url, directory ?? null) - }, - async getDefaultCloneDirectory() { - return commands.getDefaultCloneDirectory().catch(() => null) - }, - async setDefaultCloneDirectory(path: string | null) { - await commands.setDefaultCloneDirectory(path) - }, - async openPath(path: string, app?: string) { - await commands.openPath(path, app ?? null) - }, back() { window.history.back() diff --git a/packages/desktop/src/platform/project.ts b/packages/desktop/src/platform/project.ts new file mode 100644 index 00000000000..e7f6b768bfc --- /dev/null +++ b/packages/desktop/src/platform/project.ts @@ -0,0 +1,93 @@ +import type { Platform } from "@opencode-ai/app" +import { open, save } from "@tauri-apps/plugin-dialog" +import { commands } from "../bindings" +import { t } from "../i18n" + +type DirectoryOpts = Parameters>[0] +type FileOpts = Parameters>[0] +type SaveOpts = Parameters>[0] + +async function home(os: Platform["os"]) { + if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined + return commands.wslPath("~", "windows").catch(() => undefined) +} + +async function map(path: string | string[] | null) { + if (!path || !window.__OPENCODE__?.wsl) return path + if (Array.isArray(path)) { + return Promise.all(path.map((item) => commands.wslPath(item, "linux").catch(() => item))) + } + return commands.wslPath(path, "linux").catch(() => path) +} + +async function file(path: string | null) { + if (!path || !window.__OPENCODE__?.wsl) return path + return commands.wslPath(path, "linux").catch(() => path) +} + +export function createProjectPlatform( + os: Platform["os"], +): Pick< + Platform, + | "openDirectoryPickerDialog" + | "openFilePickerDialog" + | "saveFilePickerDialog" + | "normalizeProjectPath" + | "cloneGitRepository" + | "getDefaultCloneDirectory" + | "setDefaultCloneDirectory" + | "openPath" +> { + return { + async openDirectoryPickerDialog(opts: DirectoryOpts) { + const path = await home(os) + const res = await open({ + directory: true, + multiple: opts?.multiple ?? false, + title: opts?.title ?? t("desktop.dialog.chooseFolder"), + defaultPath: path, + }) + return map(res) + }, + + async openFilePickerDialog(opts: FileOpts) { + const res = await open({ + directory: false, + multiple: opts?.multiple ?? false, + title: opts?.title ?? t("desktop.dialog.chooseFile"), + }) + return map(res) + }, + + async saveFilePickerDialog(opts: SaveOpts) { + const res = await save({ + title: opts?.title ?? t("desktop.dialog.saveFile"), + defaultPath: opts?.defaultPath, + }) + return file(res) + }, + + async normalizeProjectPath(path: string) { + if (os === "windows" && window.__OPENCODE__?.wsl) { + return commands.wslPath(path, "linux").catch(() => path) + } + return path + }, + + cloneGitRepository(url: string, dir?: string) { + return commands.cloneGitRepository(url, dir ?? null) + }, + + async getDefaultCloneDirectory() { + return commands.getDefaultCloneDirectory().catch(() => null) + }, + + async setDefaultCloneDirectory(path: string | null) { + await commands.setDefaultCloneDirectory(path) + }, + + async openPath(path: string, app?: string) { + await commands.openPath(path, app ?? null) + }, + } +} From db7f88830c293cf6b0b3a4f8a07700a9c825ef62 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 19 Mar 2026 14:54:14 +0000 Subject: [PATCH 3/3] fix(app): restore clone dialog typecheck --- packages/app/src/components/dialog-open-project-git.tsx | 6 ++---- packages/app/src/components/settings-general.tsx | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/dialog-open-project-git.tsx b/packages/app/src/components/dialog-open-project-git.tsx index b145c00a4cb..8fed5b58058 100644 --- a/packages/app/src/components/dialog-open-project-git.tsx +++ b/packages/app/src/components/dialog-open-project-git.tsx @@ -1,7 +1,7 @@ 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 { Show, createEffect, onMount } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" import { type Platform, usePlatform } from "@/context/platform" @@ -108,9 +108,7 @@ export function DialogOpenProjectGit(props: Props) { 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) => - props.setTarget(event.currentTarget.value, true) - } + onInput={(event) => props.setTarget(event.currentTarget.value, true)} />