diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 10e4b3c6..5189bad3 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -117,6 +117,28 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }), ) + ipcMain.handle( + "remote:openWindow", + async ( + _event, + payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }, + ): Promise<{ ok: boolean }> => { + const opener = (mainWindow as BrowserWindow & { + __codenomadOpenRemoteWindow?: (payload: { + id: string + name: string + baseUrl: string + skipTlsVerify: boolean + }) => Promise + }).__codenomadOpenRemoteWindow + if (!opener) { + throw new Error("Remote window opening is not available") + } + await opener(payload) + return { ok: true } + }, + ) + ipcMain.handle( "notifications:show", async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 55e3dbad..c051e9dd 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -21,6 +21,8 @@ let pendingCliUrl: string | null = null let pendingBootstrapToken: string | null = null let showingLoadingScreen = false let preloadingView: BrowserView | null = null +const remoteWindowOrigins = new Map>() +const allowedInsecureOrigins = new Set() if (isMac) { app.commandLine.appendSwitch("disable-spell-checking") @@ -93,8 +95,13 @@ function loadLoadingScreen(window: BrowserWindow) { }) } -function getAllowedRendererOrigins(): string[] { +function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] { const origins = new Set() + if (window) { + for (const origin of remoteWindowOrigins.get(window.id) ?? []) { + origins.add(origin) + } + } const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL] for (const candidate of rendererCandidates) { if (!candidate) { @@ -109,13 +116,13 @@ function getAllowedRendererOrigins(): string[] { return Array.from(origins) } -function shouldOpenExternally(url: string): boolean { +function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean { try { const parsed = new URL(url) if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return true } - const allowedOrigins = getAllowedRendererOrigins() + const allowedOrigins = getAllowedRendererOrigins(window) return !allowedOrigins.includes(parsed.origin) } catch { return false @@ -128,7 +135,7 @@ function setupNavigationGuards(window: BrowserWindow) { } window.webContents.setWindowOpenHandler(({ url }) => { - if (shouldOpenExternally(url)) { + if (shouldOpenExternally(url, window)) { handleExternal(url) return { action: "deny" } } @@ -136,13 +143,26 @@ function setupNavigationGuards(window: BrowserWindow) { }) window.webContents.on("will-navigate", (event, url) => { - if (shouldOpenExternally(url)) { + if (shouldOpenExternally(url, window)) { event.preventDefault() handleExternal(url) } }) } +function setWindowAllowedOrigin(window: BrowserWindow, url: string) { + try { + const origin = new URL(url).origin + remoteWindowOrigins.set(window.id, new Set([origin])) + } catch (error) { + console.warn("[cli] failed to store allowed origin", url, error) + } +} + +function clearWindowAllowedOrigin(window: BrowserWindow) { + remoteWindowOrigins.delete(window.id) +} + let cachedPreloadPath: string | null = null function getPreloadPath() { if (cachedPreloadPath && existsSync(cachedPreloadPath)) { @@ -207,25 +227,29 @@ function createWindow() { }, }) - setupNavigationGuards(mainWindow) + const window = mainWindow + + setupNavigationGuards(window) if (isMac) { - mainWindow.webContents.session.setSpellCheckerEnabled(false) + window.webContents.session.setSpellCheckerEnabled(false) } showingLoadingScreen = true currentCliUrl = null - loadLoadingScreen(mainWindow) + clearWindowAllowedOrigin(window) + loadLoadingScreen(window) if (process.env.NODE_ENV === "development") { - mainWindow.webContents.openDevTools({ mode: "detach" }) + window.webContents.openDevTools({ mode: "detach" }) } - createApplicationMenu(mainWindow) - setupCliIPC(mainWindow, cliManager) + createApplicationMenu(window) + setupCliIPC(window, cliManager) - mainWindow.on("closed", () => { + window.on("closed", () => { destroyPreloadingView() + clearWindowAllowedOrigin(window) mainWindow = null currentCliUrl = null pendingCliUrl = null @@ -322,10 +346,65 @@ function finalizeCliSwap(url: string) { return } + const window = mainWindow showingLoadingScreen = false currentCliUrl = url + setWindowAllowedOrigin(window, url) pendingCliUrl = null - mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) + window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) +} + +function buildRemoteWindowTitle(name: string, baseUrl: string) { + try { + const parsed = new URL(baseUrl) + return `${name} - ${parsed.host}` + } catch { + return `${name} - ${baseUrl}` + } +} + +function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) { + const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) + const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) + const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) + return `${escapedName}

${escapedName}

Could not connect to the remote server.

${escapedMessage}

${escapedUrl}
` +} + +async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) { + const targetUrl = new URL(payload.baseUrl) + const title = buildRemoteWindowTitle(payload.name, payload.baseUrl) + const window = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 800, + minHeight: 600, + backgroundColor: "#1a1a1a", + icon: getIconPath(), + title, + webPreferences: { + preload: getPreloadPath(), + contextIsolation: true, + nodeIntegration: false, + spellcheck: !isMac, + }, + }) + + setWindowAllowedOrigin(window, targetUrl.toString()) + if (payload.skipTlsVerify) { + allowedInsecureOrigins.add(targetUrl.origin) + } + + setupNavigationGuards(window) + window.on("closed", () => { + clearWindowAllowedOrigin(window) + }) + + try { + await window.loadURL(targetUrl.toString()) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`) + } } let bootstrapExchangeInFlight = false @@ -504,6 +583,22 @@ app.whenReady().then(() => { } createWindow() + ;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow + + app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => { + try { + const origin = new URL(url).origin + if (allowedInsecureOrigins.has(origin)) { + event.preventDefault() + console.warn("[cli] allowing insecure remote certificate for", origin, error) + callback(true) + return + } + } catch { + // ignore + } + callback(false) + }) app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 06cb9cad..4cfbe2bd 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -23,6 +23,7 @@ const electronAPI = { requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), + openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), } contextBridge.exposeInMainWorld("electronAPI", electronAPI) diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index e901e335..f5114ae0 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -244,6 +244,32 @@ export interface VoiceModeStateResponse { enabled: boolean } +export interface RemoteServerProfile { + id: string + name: string + baseUrl: string + skipTlsVerify: boolean + createdAt: string + updatedAt: string + lastConnectedAt?: string +} + +export interface RemoteServerProbeRequest { + baseUrl: string + skipTlsVerify?: boolean +} + +export interface RemoteServerProbeResponse { + ok: boolean + reachable: boolean + normalizedUrl: string + skipTlsVerify: boolean + requiresAuth: boolean + authenticated: boolean + error?: string + errorCode?: string +} + export type WorkspaceEventType = | "workspace.created" | "workspace.started" diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 61f82535..9aaf3816 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -22,6 +22,7 @@ import { registerPluginRoutes } from "./routes/plugin" import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" +import { registerRemoteServerRoutes } from "./routes/remote-servers" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" @@ -270,6 +271,7 @@ export function createHttpServer(deps: HttpServerDeps) { eventBus: deps.eventBus, workspaceManager: deps.workspaceManager, }) + registerRemoteServerRoutes(app, { logger: apiLogger }) registerSpeechRoutes(app, { speechService: deps.speechService }) registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, diff --git a/packages/server/src/server/routes/remote-servers.ts b/packages/server/src/server/routes/remote-servers.ts new file mode 100644 index 00000000..86c00569 --- /dev/null +++ b/packages/server/src/server/routes/remote-servers.ts @@ -0,0 +1,166 @@ +import { Agent, fetch } from "undici" +import type { FastifyInstance } from "fastify" +import { z } from "zod" +import type { Logger } from "../../logger" +import type { RemoteServerProbeResponse } from "../../api-types" + +interface RouteDeps { + logger: Logger +} + +const ProbeSchema = z.object({ + baseUrl: z.string().min(1), + skipTlsVerify: z.boolean().optional(), +}) + +const PROBE_TIMEOUT_MS = 8_000 + +export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) { + app.post("/api/remote-servers/probe", async (request, reply) => { + try { + const body = ProbeSchema.parse(request.body ?? {}) + return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify)) + } catch (error) { + deps.logger.warn({ err: error }, "Failed to probe remote server") + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid request" } + } + }) +} + +async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise { + const normalizedUrl = normalizeBaseUrl(baseUrl) + const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) + const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined + + try { + const response = await fetch(probeUrl, { + method: "GET", + dispatcher, + signal: controller.signal, + headers: { + Accept: "application/json", + }, + }) + + if (!response.ok) { + return { + ok: false, + reachable: true, + normalizedUrl, + skipTlsVerify, + requiresAuth: false, + authenticated: false, + error: `Remote server returned HTTP ${response.status}`, + errorCode: "http_error", + } + } + + const payload = (await response.json()) as { authenticated?: unknown } + if (typeof payload?.authenticated !== "boolean") { + return { + ok: false, + reachable: true, + normalizedUrl, + skipTlsVerify, + requiresAuth: false, + authenticated: false, + error: "Remote server did not return a valid CodeNomad auth response", + errorCode: "invalid_server", + } + } + + return { + ok: true, + reachable: true, + normalizedUrl, + skipTlsVerify, + requiresAuth: !payload.authenticated, + authenticated: payload.authenticated, + } + } catch (error) { + const message = describeProbeError(error) + return { + ok: false, + reachable: false, + normalizedUrl, + skipTlsVerify, + requiresAuth: false, + authenticated: false, + error: message.message, + errorCode: message.code, + } + } finally { + clearTimeout(timeout) + await dispatcher?.close().catch(() => {}) + } +} + +function normalizeBaseUrl(input: string): string { + const parsed = new URL(input.trim()) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Server URL must use http:// or https://") + } + + parsed.hash = "" + parsed.search = "" + parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/" + const value = parsed.toString() + return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "") +} + +function describeProbeError(error: unknown): { code: string; message: string } { + const chain = unwrapErrorChain(error) + const detailed = + chain.find((entry) => { + const code = (entry?.code ?? "").toString() + return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE" + }) ?? chain[0] + + const code = (detailed?.code ?? "").toString() + const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim() + + if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") { + return { + code: "tls_error", + message: "Certificate check failed while connecting to the remote server.", + } + } + + return { + code: + code === "ERR_INVALID_URL" + ? "invalid_url" + : code === "ECONNREFUSED" + ? "connection_refused" + : code === "ENOTFOUND" + ? "dns_error" + : code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR" + ? "timeout" + : code + ? code.toLowerCase() + : "probe_failed", + message: exactMessage || "Failed to connect to the remote server.", + } +} + +function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> { + const results: Array<{ code?: unknown; message?: string }> = [] + let current: unknown = error + const seen = new Set() + + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current) + const entry = current as { code?: unknown; message?: string; cause?: unknown } + results.push({ code: entry.code, message: entry.message }) + current = entry.cause + } + + if (results.length === 0 && error instanceof Error) { + results.push({ message: error.message }) + } + + return results +} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 7dfd12b0..e4d68aad 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -6,13 +6,14 @@ use cli_manager::{CliProcessManager, CliStatus}; use keepawake::KeepAwake; use serde::Deserialize; use serde_json::json; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::webview::Webview; -use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry}; +use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry}; use tauri_plugin_global_shortcut::{ Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState, }; @@ -41,6 +42,16 @@ pub struct AppState { pub manager: CliProcessManager, pub wake_lock: Mutex>, pub zoom_level: Mutex, + pub remote_origins: Mutex>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteWindowPayload { + id: String, + name: String, + base_url: String, + skip_tls_verify: bool, } #[derive(Debug, Default, Deserialize)] @@ -118,11 +129,26 @@ fn should_allow_internal(url: &Url) -> bool { } } -fn intercept_navigation(webview: &Webview, url: &Url) -> bool { +fn should_allow_window_origin(app_handle: &AppHandle, window_label: &str, url: &Url) -> bool { if should_allow_internal(url) { return true; } + let state = app_handle.state::(); + let allowed = state.remote_origins.lock(); + if let Some(origin) = allowed.get(window_label) { + return origin == &url.origin().ascii_serialization(); + } + + false +} + +fn intercept_navigation(webview: &Webview, url: &Url) -> bool { + let window_label = webview.label().to_string(); + if should_allow_window_origin(&webview.app_handle(), &window_label, url) { + return true; + } + if let Err(err) = webview .app_handle() .opener() @@ -133,6 +159,45 @@ fn intercept_navigation(webview: &Webview, url: &Url) -> bool { false } +#[tauri::command] +fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> { + if payload.skip_tls_verify && payload.base_url.starts_with("https://") { + return Err( + "Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect." + .to_string(), + ); + } + + let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?; + let label = format!("remote-{}", payload.id); + let title = format!("{} - {}", payload.name, parsed.host_str().unwrap_or(payload.base_url.as_str())); + + app.state::() + .remote_origins + .lock() + .insert(label.clone(), parsed.origin().ascii_serialization()); + + let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone())) + .title(title) + .inner_size(1400.0, 900.0) + .min_inner_size(800.0, 600.0) + .build() + .map_err(|err| err.to_string())?; + + let app_handle = app.clone(); + window.on_window_event(move |event| { + if let WindowEvent::Destroyed = event { + app_handle + .state::() + .remote_origins + .lock() + .remove(&label); + } + }); + + Ok(()) +} + fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec { paths .iter() @@ -286,6 +351,7 @@ fn main() { manager: CliProcessManager::new(), wake_lock: Mutex::new(None), zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL), + remote_origins: Mutex::new(HashMap::new()), }) .setup(|app| { set_windows_app_user_model_id(); @@ -323,7 +389,8 @@ fn main() { cli_get_status, cli_restart, wake_lock_start, - wake_lock_stop + wake_lock_stop, + open_remote_window ]) .on_menu_event(|app_handle, event| { match event.id().0.as_str() { diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 2dcc4c1a..f9db2029 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -1,6 +1,7 @@ +import { Dialog } from "@kobalte/core/dialog" import { Select } from "@kobalte/core/select" import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" -import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid" +import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid" import { useConfig } from "../stores/preferences" import DirectoryBrowserDialog from "./directory-browser-dialog" import Kbd from "./kbd" @@ -14,11 +15,15 @@ import { useI18n, type Locale } from "../lib/i18n" import { showAlertDialog } from "../stores/alerts" import { openSettings, settingsOpen } from "../stores/settings-screen" import { openExternalUrl } from "../lib/external-url" +import { serverApi } from "../lib/api-client" +import { openRemoteServerWindow } from "../lib/native/remote-window" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad" const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945" +type HomeTab = "local" | "servers" + interface FolderSelectionViewProps { onSelectFolder: (folder: string, binaryPath?: string) => void @@ -27,12 +32,30 @@ interface FolderSelectionViewProps { } const FolderSelectionView: Component = (props) => { - const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig() + const { + recentFolders, + removeRecentFolder, + preferences, + updatePreferences, + serverSettings, + remoteServers, + saveRemoteServerProfile, + markRemoteServerConnected, + removeRemoteServerProfile, + } = useConfig() const { t, locale } = useI18n() const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) + const [activeTab, setActiveTab] = createSignal("local") + const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false) + const [serverName, setServerName] = createSignal("") + const [serverUrl, setServerUrl] = createSignal("") + const [skipTlsVerify, setSkipTlsVerify] = createSignal(false) + const [serverDialogError, setServerDialogError] = createSignal(null) + const [isSavingServer, setIsSavingServer] = createSignal(false) + const [connectingServerId, setConnectingServerId] = createSignal(null) const nativeDialogsAvailable = supportsNativeDialogs() let recentListRef: HTMLDivElement | undefined @@ -236,6 +259,87 @@ const FolderSelectionView: Component = (props) => { props.onSelectFolder(path, selectedBinary()) } + function resetServerDialog() { + setServerName("") + setServerUrl("") + setSkipTlsVerify(false) + setServerDialogError(null) + } + + function openServerDialog() { + resetServerDialog() + setIsServerDialogOpen(true) + } + + async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) { + const trimmedName = input.name.trim() + const trimmedUrl = input.baseUrl.trim() + if (!trimmedName || !trimmedUrl) { + throw new Error(t("folderSelection.servers.dialog.errorRequired")) + } + + const probe = await serverApi.probeRemoteServer({ + baseUrl: trimmedUrl, + skipTlsVerify: input.skipTlsVerify, + }) + + if (!probe.ok) { + throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect")) + } + + const profile = await saveRemoteServerProfile({ + id: input.id, + name: trimmedName, + baseUrl: probe.normalizedUrl, + skipTlsVerify: input.skipTlsVerify, + }) + + if (openWindow) { + await openRemoteServerWindow(profile) + await markRemoteServerConnected(profile.id) + } + + return profile + } + + async function handleSaveServer(openWindow: boolean) { + if (isSavingServer()) return + setIsSavingServer(true) + setServerDialogError(null) + try { + await probeAndOpenServer( + { + name: serverName(), + baseUrl: serverUrl(), + skipTlsVerify: skipTlsVerify(), + }, + openWindow, + ) + setIsServerDialogOpen(false) + resetServerDialog() + } catch (error) { + setServerDialogError(error instanceof Error ? error.message : String(error)) + } finally { + setIsSavingServer(false) + } + } + + async function handleConnectSavedServer(id: string) { + const target = remoteServers().find((entry) => entry.id === id) + if (!target || connectingServerId()) return + setConnectingServerId(id) + try { + await probeAndOpenServer(target, true) + } catch (error) { + showAlertDialog(error instanceof Error ? error.message : String(error), { + title: t("folderSelection.servers.errorTitle"), + variant: "warning", + }) + } finally { + setConnectingServerId(null) + } + } + async function handleBrowse() { if (isLoading()) return setFocusMode("new") @@ -476,90 +580,207 @@ const FolderSelectionView: Component = (props) => {
{/* Right column: recent folders */}
- 0} - fallback={ -
-
- -
-

{t("folderSelection.empty.title")}

-

{t("folderSelection.empty.description")}

-
- } - >
-
-

{t("folderSelection.recent.title")}

-

- {t( - folders().length === 1 - ? "folderSelection.recent.subtitle.one" - : "folderSelection.recent.subtitle.other", - { count: folders().length }, - )} -

-
-
(recentListRef = el)} - > - - {(folder, index) => ( +
+
+ + +
+
+ + 0} + fallback={ +
+
+ +
+

{t("folderSelection.servers.empty.title")}

+

{t("folderSelection.servers.empty.description")}

-
+ } + > +
+ + {(server) => ( +
+
+ + +
+
+ )} +
- )} -
-
+ + } + > + 0} + fallback={ +
+
+ +
+

{t("folderSelection.empty.title")}

+

{t("folderSelection.empty.description")}

+
+ } + > +
(recentListRef = el)} + > + + {(folder, index) => ( +
+
+ + +
+
+ )} +
+
+
+
-
@@ -567,27 +788,37 @@ const FolderSelectionView: Component = (props) => {
-
- +
+ + +
{/* OpenCode settings section */} @@ -663,6 +894,82 @@ const FolderSelectionView: Component = (props) => { onClose={() => setIsFolderBrowserOpen(false)} onSelect={handleBrowserSelect} /> + + !open && setIsServerDialogOpen(false)}> + + +
+ +
+ + {t("folderSelection.servers.dialog.title")} + + + {t("folderSelection.servers.dialog.description")} + +
+ + + + + + + + + {(message) =>

{message()}

} +
+ +
+ + + +
+
+
+
+
) } diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 10bb29ea..f227d9c5 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -11,6 +11,8 @@ import type { SpeechSynthesisResponse, SpeechTranscriptionResponse, ServerMeta, + RemoteServerProbeRequest, + RemoteServerProbeResponse, VoiceModeStateResponse, WorkspaceCreateRequest, WorkspaceDescriptor, @@ -194,6 +196,12 @@ export const serverApi = { fetchServerMeta(): Promise { return request("/api/meta") }, + probeRemoteServer(payload: RemoteServerProbeRequest): Promise { + return request("/api/remote-servers/probe", { + method: "POST", + body: JSON.stringify(payload), + }) + }, fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> { return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status") }, diff --git a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts index e548f92c..57e1c71c 100644 --- a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "Select any folder on your computer", "folderSelection.browse.button": "Browse Folders", "folderSelection.browse.buttonOpening": "Opening...", + "folderSelection.actions.title": "Open Folder or Connect Server", + "folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server", + "folderSelection.actions.connectButton": "Connect CodeNomad Server", "folderSelection.advancedSettings": "Advanced Settings", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,31 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "Select Workspace", "folderSelection.dialog.description": "Select workspace to start coding.", + + "folderSelection.tabs.local": "Local Folders", + "folderSelection.tabs.servers": "Servers", + "folderSelection.servers.title": "Saved Servers", + "folderSelection.servers.subtitle": "Open a saved remote CodeNomad server in a new window", + "folderSelection.servers.count": "{count} Servers", + "folderSelection.servers.empty.title": "No Saved Servers", + "folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device", + "folderSelection.servers.connectTitle": "Connect to Server", + "folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window", + "folderSelection.servers.connectButton": "Connect to Server", + "folderSelection.servers.remove": "Remove saved server", + "folderSelection.servers.skipTls": "Self-signed TLS", + "folderSelection.servers.errorTitle": "Remote Connection Failed", + "folderSelection.servers.dialog.title": "Connect to Server", + "folderSelection.servers.dialog.description": "Add a remote CodeNomad server and optionally open it right away.", + "folderSelection.servers.dialog.name": "Server name", + "folderSelection.servers.dialog.namePlaceholder": "Production Server", + "folderSelection.servers.dialog.url": "Server URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Skip TLS verification for self-signed certificates.", + "folderSelection.servers.dialog.cancel": "Cancel", + "folderSelection.servers.dialog.save": "Save", + "folderSelection.servers.dialog.connect": "Connect", + "folderSelection.servers.dialog.connecting": "Connecting...", + "folderSelection.servers.dialog.errorRequired": "Server name and URL are required.", + "folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts index 56948be4..eecaf8f8 100644 --- a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador", "folderSelection.browse.button": "Explorar carpetas", "folderSelection.browse.buttonOpening": "Abriendo...", + "folderSelection.actions.title": "Abrir carpeta o conectar servidor", + "folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad", + "folderSelection.actions.connectButton": "Conectar servidor CodeNomad", "folderSelection.advancedSettings": "Configuración avanzada", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,31 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "Seleccionar workspace", "folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.", + + "folderSelection.tabs.local": "Carpetas locales", + "folderSelection.tabs.servers": "Servidores", + "folderSelection.servers.title": "Servidores guardados", + "folderSelection.servers.subtitle": "Abre un servidor remoto de CodeNomad guardado en una ventana nueva", + "folderSelection.servers.count": "{count} servidores", + "folderSelection.servers.empty.title": "No hay servidores guardados", + "folderSelection.servers.empty.description": "Añade un servidor remoto para volver a conectarte rápidamente desde este dispositivo", + "folderSelection.servers.connectTitle": "Conectar a un servidor", + "folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva", + "folderSelection.servers.connectButton": "Conectar a un servidor", + "folderSelection.servers.remove": "Eliminar servidor guardado", + "folderSelection.servers.skipTls": "TLS autofirmado", + "folderSelection.servers.errorTitle": "Falló la conexión remota", + "folderSelection.servers.dialog.title": "Conectar a un servidor", + "folderSelection.servers.dialog.description": "Añade un servidor remoto de CodeNomad y ábrelo ahora si quieres.", + "folderSelection.servers.dialog.name": "Nombre del servidor", + "folderSelection.servers.dialog.namePlaceholder": "Servidor de producción", + "folderSelection.servers.dialog.url": "URL del servidor", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Omitir la verificación TLS para certificados autofirmados.", + "folderSelection.servers.dialog.cancel": "Cancelar", + "folderSelection.servers.dialog.save": "Guardar", + "folderSelection.servers.dialog.connect": "Conectar", + "folderSelection.servers.dialog.connecting": "Conectando...", + "folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.", + "folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts index cd1f2cdc..46301a89 100644 --- a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur", "folderSelection.browse.button": "Parcourir les dossiers", "folderSelection.browse.buttonOpening": "Ouverture...", + "folderSelection.actions.title": "Ouvrir un dossier ou connecter un serveur", + "folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad", + "folderSelection.actions.connectButton": "Connecter un serveur CodeNomad", "folderSelection.advancedSettings": "Paramètres avancés", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,31 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "Sélectionner l'espace de travail", "folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.", + + "folderSelection.tabs.local": "Dossiers locaux", + "folderSelection.tabs.servers": "Serveurs", + "folderSelection.servers.title": "Serveurs enregistrés", + "folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre", + "folderSelection.servers.count": "{count} serveurs", + "folderSelection.servers.empty.title": "Aucun serveur enregistré", + "folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil", + "folderSelection.servers.connectTitle": "Se connecter à un serveur", + "folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre", + "folderSelection.servers.connectButton": "Se connecter à un serveur", + "folderSelection.servers.remove": "Supprimer le serveur enregistré", + "folderSelection.servers.skipTls": "TLS auto-signé", + "folderSelection.servers.errorTitle": "Échec de la connexion distante", + "folderSelection.servers.dialog.title": "Se connecter à un serveur", + "folderSelection.servers.dialog.description": "Ajoutez un serveur CodeNomad distant et ouvrez-le immédiatement si vous le souhaitez.", + "folderSelection.servers.dialog.name": "Nom du serveur", + "folderSelection.servers.dialog.namePlaceholder": "Serveur de production", + "folderSelection.servers.dialog.url": "URL du serveur", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Ignorer la vérification TLS pour les certificats auto-signés.", + "folderSelection.servers.dialog.cancel": "Annuler", + "folderSelection.servers.dialog.save": "Enregistrer", + "folderSelection.servers.dialog.connect": "Se connecter", + "folderSelection.servers.dialog.connecting": "Connexion...", + "folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.", + "folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.", } as const diff --git a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts index 489430a9..6979dab5 100644 --- a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך", "folderSelection.browse.button": "עיון בתיקיות", "folderSelection.browse.buttonOpening": "פותח...", + "folderSelection.actions.title": "פתח תיקייה או התחבר לשרת", + "folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad", + "folderSelection.actions.connectButton": "התחבר לשרת CodeNomad", "folderSelection.advancedSettings": "הגדרות מתקדמות", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,31 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "בחר סביבת עבודה", "folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.", + + "folderSelection.tabs.local": "תיקיות מקומיות", + "folderSelection.tabs.servers": "שרתים", + "folderSelection.servers.title": "שרתים שמורים", + "folderSelection.servers.subtitle": "פתח שרת CodeNomad מרוחק שמור בחלון חדש", + "folderSelection.servers.count": "{count} שרתים", + "folderSelection.servers.empty.title": "אין שרתים שמורים", + "folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה", + "folderSelection.servers.connectTitle": "התחבר לשרת", + "folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש", + "folderSelection.servers.connectButton": "התחבר לשרת", + "folderSelection.servers.remove": "הסר שרת שמור", + "folderSelection.servers.skipTls": "TLS בחתימה עצמית", + "folderSelection.servers.errorTitle": "החיבור המרוחק נכשל", + "folderSelection.servers.dialog.title": "התחבר לשרת", + "folderSelection.servers.dialog.description": "הוסף שרת CodeNomad מרוחק ופתח אותו מיד אם תרצה.", + "folderSelection.servers.dialog.name": "שם השרת", + "folderSelection.servers.dialog.namePlaceholder": "שרת ייצור", + "folderSelection.servers.dialog.url": "כתובת השרת", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "דלג על אימות TLS עבור תעודות בחתימה עצמית.", + "folderSelection.servers.dialog.cancel": "ביטול", + "folderSelection.servers.dialog.save": "שמור", + "folderSelection.servers.dialog.connect": "התחבר", + "folderSelection.servers.dialog.connecting": "מתחבר...", + "folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.", + "folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts index 4c05e401..749436bc 100644 --- a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "コンピュータ上の任意のフォルダを選択", "folderSelection.browse.button": "フォルダを参照", "folderSelection.browse.buttonOpening": "開いています...", + "folderSelection.actions.title": "フォルダを開くかサーバーに接続", + "folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します", + "folderSelection.actions.connectButton": "CodeNomad サーバーに接続", "folderSelection.advancedSettings": "詳細設定", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,31 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "ワークスペースを選択", "folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。", + + "folderSelection.tabs.local": "ローカルフォルダ", + "folderSelection.tabs.servers": "サーバー", + "folderSelection.servers.title": "保存済みサーバー", + "folderSelection.servers.subtitle": "保存したリモート CodeNomad サーバーを新しいウィンドウで開きます", + "folderSelection.servers.count": "{count} サーバー", + "folderSelection.servers.empty.title": "保存済みサーバーはありません", + "folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーを追加してください", + "folderSelection.servers.connectTitle": "サーバーに接続", + "folderSelection.servers.connectSubtitle": "リモート CodeNomad サーバーを保存して新しいウィンドウで開きます", + "folderSelection.servers.connectButton": "サーバーに接続", + "folderSelection.servers.remove": "保存したサーバーを削除", + "folderSelection.servers.skipTls": "自己署名 TLS", + "folderSelection.servers.errorTitle": "リモート接続に失敗しました", + "folderSelection.servers.dialog.title": "サーバーに接続", + "folderSelection.servers.dialog.description": "リモート CodeNomad サーバーを追加し、必要に応じてすぐに開きます。", + "folderSelection.servers.dialog.name": "サーバー名", + "folderSelection.servers.dialog.namePlaceholder": "本番サーバー", + "folderSelection.servers.dialog.url": "サーバー URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "自己署名証明書の TLS 検証をスキップします。", + "folderSelection.servers.dialog.cancel": "キャンセル", + "folderSelection.servers.dialog.save": "保存", + "folderSelection.servers.dialog.connect": "接続", + "folderSelection.servers.dialog.connecting": "接続中...", + "folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。", + "folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts index 4a005938..89f1636e 100644 --- a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "Выберите любую папку на компьютере", "folderSelection.browse.button": "Обзор папок", "folderSelection.browse.buttonOpening": "Открытие…", + "folderSelection.actions.title": "Открыть папку или подключить сервер", + "folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad", + "folderSelection.actions.connectButton": "Подключить сервер CodeNomad", "folderSelection.advancedSettings": "Расширенные настройки", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,31 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "Выберите рабочее пространство", "folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.", + + "folderSelection.tabs.local": "Локальные папки", + "folderSelection.tabs.servers": "Серверы", + "folderSelection.servers.title": "Сохраненные серверы", + "folderSelection.servers.subtitle": "Откройте сохраненный удаленный сервер CodeNomad в новом окне", + "folderSelection.servers.count": "{count} серверов", + "folderSelection.servers.empty.title": "Нет сохраненных серверов", + "folderSelection.servers.empty.description": "Добавьте удаленный сервер, чтобы быстро подключаться к нему с этого устройства", + "folderSelection.servers.connectTitle": "Подключиться к серверу", + "folderSelection.servers.connectSubtitle": "Сохраните удаленный сервер CodeNomad и откройте его в новом окне", + "folderSelection.servers.connectButton": "Подключиться к серверу", + "folderSelection.servers.remove": "Удалить сохраненный сервер", + "folderSelection.servers.skipTls": "Самоподписанный TLS", + "folderSelection.servers.errorTitle": "Ошибка удаленного подключения", + "folderSelection.servers.dialog.title": "Подключиться к серверу", + "folderSelection.servers.dialog.description": "Добавьте удаленный сервер CodeNomad и при желании сразу откройте его.", + "folderSelection.servers.dialog.name": "Имя сервера", + "folderSelection.servers.dialog.namePlaceholder": "Продакшн сервер", + "folderSelection.servers.dialog.url": "URL сервера", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Пропустить проверку TLS для самоподписанных сертификатов.", + "folderSelection.servers.dialog.cancel": "Отмена", + "folderSelection.servers.dialog.save": "Сохранить", + "folderSelection.servers.dialog.connect": "Подключиться", + "folderSelection.servers.dialog.connecting": "Подключение...", + "folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.", + "folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts index 1c765fe9..12602f48 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "选择你电脑上的任意文件夹", "folderSelection.browse.button": "浏览文件夹", "folderSelection.browse.buttonOpening": "正在打开...", + "folderSelection.actions.title": "打开文件夹或连接服务器", + "folderSelection.actions.subtitle": "打开本地文件夹或连接到 CodeNomad 服务器", + "folderSelection.actions.connectButton": "连接 CodeNomad 服务器", "folderSelection.advancedSettings": "高级设置", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,31 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "选择工作区", "folderSelection.dialog.description": "选择工作区以开始编码。", + + "folderSelection.tabs.local": "本地文件夹", + "folderSelection.tabs.servers": "服务器", + "folderSelection.servers.title": "已保存的服务器", + "folderSelection.servers.subtitle": "在新窗口中打开已保存的远程 CodeNomad 服务器", + "folderSelection.servers.count": "{count} 个服务器", + "folderSelection.servers.empty.title": "没有已保存的服务器", + "folderSelection.servers.empty.description": "添加远程服务器,以便在此设备上快速重新连接", + "folderSelection.servers.connectTitle": "连接到服务器", + "folderSelection.servers.connectSubtitle": "保存远程 CodeNomad 服务器并在新窗口中打开它", + "folderSelection.servers.connectButton": "连接到服务器", + "folderSelection.servers.remove": "删除已保存服务器", + "folderSelection.servers.skipTls": "自签名 TLS", + "folderSelection.servers.errorTitle": "远程连接失败", + "folderSelection.servers.dialog.title": "连接到服务器", + "folderSelection.servers.dialog.description": "添加远程 CodeNomad 服务器,并可选择立即打开。", + "folderSelection.servers.dialog.name": "服务器名称", + "folderSelection.servers.dialog.namePlaceholder": "生产服务器", + "folderSelection.servers.dialog.url": "服务器 URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "为自签名证书跳过 TLS 验证。", + "folderSelection.servers.dialog.cancel": "取消", + "folderSelection.servers.dialog.save": "保存", + "folderSelection.servers.dialog.connect": "连接", + "folderSelection.servers.dialog.connecting": "连接中...", + "folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。", + "folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。", } as const diff --git a/packages/ui/src/lib/native/remote-window.ts b/packages/ui/src/lib/native/remote-window.ts new file mode 100644 index 00000000..96b36cd5 --- /dev/null +++ b/packages/ui/src/lib/native/remote-window.ts @@ -0,0 +1,34 @@ +import { invoke } from "@tauri-apps/api/core" +import type { RemoteServerProfile } from "../../../../server/src/api-types" +import { runtimeEnv } from "../runtime-env" + +export interface RemoteWindowOpenPayload { + id: string + name: string + baseUrl: string + skipTlsVerify: boolean +} + +export async function openRemoteServerWindow(profile: Pick): Promise { + const payload: RemoteWindowOpenPayload = { + id: profile.id, + name: profile.name, + baseUrl: profile.baseUrl, + skipTlsVerify: profile.skipTlsVerify, + } + + if (runtimeEnv.host === "electron") { + const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI + if (typeof api?.openRemoteWindow === "function") { + await api.openRemoteWindow(payload) + return + } + } + + if (runtimeEnv.host === "tauri") { + await invoke("open_remote_window", { payload }) + return + } + + window.open(profile.baseUrl, "_blank", "noopener,noreferrer") +} diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index a1ef1c72..21209c75 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,6 +1,7 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" import { storage, type OwnerBucket } from "../lib/storage" +import type { RemoteServerProfile } from "../../../server/src/api-types" import { ensureInstanceConfigLoaded, getInstanceConfig, @@ -104,6 +105,7 @@ interface ServerConfigBucket { interface UiStateBucket { recentFolders?: RecentFolder[] opencodeBinaries?: OpenCodeBinary[] + remoteServers?: RemoteServerProfile[] models?: { recents?: ModelPreference[] favorites?: ModelPreference[] @@ -114,6 +116,7 @@ interface UiStateBucket { interface NormalizedUiState { recentFolders: RecentFolder[] opencodeBinaries: OpenCodeBinary[] + remoteServers: RemoteServerProfile[] models: { recents: ModelPreference[] favorites: ModelPreference[] @@ -252,6 +255,29 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { const label = typeof (b as any).label === "string" ? (b as any).label : undefined return { path: p, version, label, lastUsed } }), + remoteServers: cloneArray(source.remoteServers, (server) => { + if (!server || typeof server !== "object") return null + const id = typeof (server as any).id === "string" ? (server as any).id.trim() : "" + const name = typeof (server as any).name === "string" ? (server as any).name.trim() : "" + const baseUrl = typeof (server as any).baseUrl === "string" ? (server as any).baseUrl.trim() : "" + if (!id || !name || !baseUrl) return null + const createdAt = typeof (server as any).createdAt === "string" ? (server as any).createdAt : new Date().toISOString() + const updatedAt = typeof (server as any).updatedAt === "string" ? (server as any).updatedAt : createdAt + const lastConnectedAt = typeof (server as any).lastConnectedAt === "string" ? (server as any).lastConnectedAt : undefined + return { + id, + name, + baseUrl, + skipTlsVerify: Boolean((server as any).skipTlsVerify), + createdAt, + updatedAt, + lastConnectedAt, + } + }).sort((a, b) => { + const left = a.lastConnectedAt ?? a.updatedAt + const right = b.lastConnectedAt ?? b.updatedAt + return right.localeCompare(left) + }), models: { recents: cloneArray((source.models as any)?.recents, (m) => { if (!m || typeof m !== "object") return null @@ -311,6 +337,43 @@ function buildBinaryList(binaryPath: string, version: string | undefined, source return [nextEntry, ...source].slice(0, 10) } +interface RemoteServerProfileInput { + id?: string + name: string + baseUrl: string + skipTlsVerify: boolean +} + +function buildRemoteServerProfile(input: RemoteServerProfileInput, source: RemoteServerProfile[]): RemoteServerProfile { + const existing = input.id ? source.find((entry) => entry.id === input.id) : undefined + const now = new Date().toISOString() + return { + id: existing?.id ?? input.id ?? createRandomId(), + name: input.name.trim(), + baseUrl: input.baseUrl.trim(), + skipTlsVerify: Boolean(input.skipTlsVerify), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + lastConnectedAt: existing?.lastConnectedAt, + } +} + +function buildRemoteServerList(profile: RemoteServerProfile, source: RemoteServerProfile[]): RemoteServerProfile[] { + const remaining = source.filter((entry) => entry.id !== profile.id) + return [profile, ...remaining].sort((a, b) => { + const left = a.lastConnectedAt ?? a.updatedAt + const right = b.lastConnectedAt ?? b.updatedAt + return right.localeCompare(left) + }) +} + +function createRandomId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID() + } + return `remote-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + const [uiConfigBucket, setUiConfigBucket] = createSignal({}) const [serverConfigBucket, setServerConfigBucket] = createSignal({}) const [uiStateBucket, setUiStateBucket] = createSignal({}) @@ -324,6 +387,7 @@ const uiState = createMemo(() => normalizeUiState(uiStateBucket())) const preferences = uiSettings const recentFolders = createMemo(() => uiState().recentFolders) const opencodeBinaries = createMemo(() => uiState().opencodeBinaries) +const remoteServers = createMemo(() => uiState().remoteServers) let loadPromise: Promise | null = null @@ -467,6 +531,29 @@ function removeRecentFolder(folderPath: string): void { void patchStateOwner("ui", { recentFolders: next }).catch((error) => log.error("Failed to remove recent folder", error)) } +async function saveRemoteServerProfile(input: RemoteServerProfileInput): Promise { + const profile = buildRemoteServerProfile(input, remoteServers()) + await patchStateOwner("ui", { remoteServers: buildRemoteServerList(profile, remoteServers()) }) + return profile +} + +async function markRemoteServerConnected(id: string): Promise { + const current = remoteServers().find((entry) => entry.id === id) + if (!current) return + const now = new Date().toISOString() + const updated: RemoteServerProfile = { + ...current, + updatedAt: now, + lastConnectedAt: now, + } + await patchStateOwner("ui", { remoteServers: buildRemoteServerList(updated, remoteServers()) }) +} + +function removeRemoteServerProfile(id: string): void { + const next = remoteServers().filter((entry) => entry.id !== id) + void patchStateOwner("ui", { remoteServers: next }).catch((error) => log.error("Failed to remove remote server", error)) +} + function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void { const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : serverSettings().opencodeBinary const nextFolders = buildRecentFolderList(folderPath, recentFolders()) @@ -630,11 +717,15 @@ interface ConfigContextValue { // ui-owned state recentFolders: typeof recentFolders opencodeBinaries: typeof opencodeBinaries + remoteServers: typeof remoteServers uiState: typeof uiState addRecentFolder: typeof addRecentFolder removeRecentFolder: typeof removeRecentFolder addOpenCodeBinary: typeof addOpenCodeBinary removeOpenCodeBinary: typeof removeOpenCodeBinary + saveRemoteServerProfile: typeof saveRemoteServerProfile + markRemoteServerConnected: typeof markRemoteServerConnected + removeRemoteServerProfile: typeof removeRemoteServerProfile recordWorkspaceLaunch: typeof recordWorkspaceLaunch addRecentModelPreference: typeof addRecentModelPreference isFavoriteModelPreference: typeof isFavoriteModelPreference @@ -679,11 +770,15 @@ const configContextValue: ConfigContextValue = { updateSpeechSettings, recentFolders, opencodeBinaries, + remoteServers, uiState, addRecentFolder, removeRecentFolder, addOpenCodeBinary, removeOpenCodeBinary, + saveRemoteServerProfile, + markRemoteServerConnected, + removeRemoteServerProfile, recordWorkspaceLaunch, addRecentModelPreference, isFavoriteModelPreference, diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index 258f85fe..8e6a475e 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -33,6 +33,12 @@ declare global { setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }> showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }> + openRemoteWindow?: (payload: { + id: string + name: string + baseUrl: string + skipTlsVerify: boolean + }) => Promise<{ ok: boolean }> } interface File {