add remote server launcher flow

This commit is contained in:
Shantur Rathore
2026-04-02 16:08:54 +01:00
parent 893d5f9296
commit 69d9e95bee
19 changed files with 1150 additions and 111 deletions

View File

@@ -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<void>
}).__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 }> => {

View File

@@ -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<number, Set<string>>()
const allowedInsecureOrigins = new Set<string>()
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<string>()
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
}
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) {

View File

@@ -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)

View File

@@ -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"

View File

@@ -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,

View File

@@ -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<RemoteServerProbeResponse> {
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<unknown>()
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
}

View File

@@ -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<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
}
#[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<R: Runtime>(webview: &Webview<R>, 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::<AppState>();
let allowed = state.remote_origins.lock();
if let Some(origin) = allowed.get(window_label) {
return origin == &url.origin().ascii_serialization();
}
false
}
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, 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<R: Runtime>(webview: &Webview<R>, 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::<AppState>()
.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::<AppState>()
.remote_origins
.lock()
.remove(&label);
}
});
Ok(())
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
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() {

View File

@@ -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<FolderSelectionViewProps> = (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<HomeTab>("local")
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
const [serverName, setServerName] = createSignal("")
const [serverUrl, setServerUrl] = createSignal("")
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
const [isSavingServer, setIsSavingServer] = createSignal(false)
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
@@ -236,6 +259,87 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (props) => {
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
{/* Right column: recent folders */}
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
<p class="panel-subtitle">
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</div>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div class="panel-header !gap-0 !p-0">
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
<button
type="button"
class="border-r border-base px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "local",
"text-muted hover:text-secondary": activeTab() !== "local",
}}
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("local")}
>
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
class="panel-title text-base"
style={{
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
<div class="flex items-center gap-2 w-full px-1">
{t("folderSelection.recent.title")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</button>
<button
type="button"
class="px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "servers",
"text-muted hover:text-secondary": activeTab() !== "servers",
}}
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("servers")}
>
<div
class="panel-title text-base"
style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t("folderSelection.servers.count", { count: remoteServers().length })}
</p>
</button>
</div>
</div>
<Show
when={activeTab() === "local"}
fallback={
<Show
when={remoteServers().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Globe class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
type="button"
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
onClick={openServerDialog}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.recent.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</button>
</div>
}
>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto">
<For each={remoteServers()}>
{(server) => (
<div class="panel-list-item">
<div class="flex items-center gap-2 w-full px-1">
<button class="panel-list-item-content flex-1" onClick={() => void handleConnectSavedServer(server.id)}>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center gap-2 mb-1">
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">{server.baseUrl}</span>
</div>
</div>
<Show when={connectingServerId() === server.id} fallback={<kbd class="kbd"></kbd>}>
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
</Show>
</div>
</button>
<button
onClick={() => removeRemoteServerProfile(server.id)}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.servers.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
)}
</For>
</div>
</Show>
}
>
<Show
when={folders().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.recent.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</Show>
</div>
@@ -567,27 +788,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
</div>
<div class="panel-body">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>
{props.isLoading
? t("folderSelection.browse.buttonOpening")
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
<div class="panel-body flex flex-col gap-3">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>
{props.isLoading
? t("folderSelection.browse.buttonOpening")
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
<button
onClick={openServerDialog}
class="button-primary w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
</div>
{/* OpenCode settings section */}
@@ -663,6 +894,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
<div>
<Dialog.Title class="text-xl font-semibold text-primary">
{t("folderSelection.servers.dialog.title")}
</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
{t("folderSelection.servers.dialog.description")}
</Dialog.Description>
</div>
<label class="flex flex-col gap-2 text-sm text-secondary">
<span>{t("folderSelection.servers.dialog.name")}</span>
<input
class="selector-input w-full"
value={serverName()}
onInput={(event) => setServerName(event.currentTarget.value)}
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
/>
</label>
<label class="flex flex-col gap-2 text-sm text-secondary">
<span>{t("folderSelection.servers.dialog.url")}</span>
<input
class="selector-input w-full"
value={serverUrl()}
onInput={(event) => setServerUrl(event.currentTarget.value)}
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
/>
</label>
<label class="flex items-start gap-3 text-sm text-secondary">
<input
type="checkbox"
checked={skipTlsVerify()}
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
/>
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
</label>
<Show when={serverDialogError()}>
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
</Show>
<div class="flex items-center justify-end gap-3">
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
{t("folderSelection.servers.dialog.cancel")}
</button>
<button
class="selector-button selector-button-secondary w-auto px-4"
disabled={isSavingServer()}
onClick={() => void handleSaveServer(false)}
>
{t("folderSelection.servers.dialog.save")}
</button>
<button
class="selector-button selector-button-secondary w-auto px-4"
disabled={isSavingServer()}
onClick={() => void handleSaveServer(true)}
>
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
<span class="inline-flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
{t("folderSelection.servers.dialog.connecting")}
</span>
</Show>
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</>
)
}

View File

@@ -11,6 +11,8 @@ import type {
SpeechSynthesisResponse,
SpeechTranscriptionResponse,
ServerMeta,
RemoteServerProbeRequest,
RemoteServerProbeResponse,
VoiceModeStateResponse,
WorkspaceCreateRequest,
WorkspaceDescriptor,
@@ -194,6 +196,12 @@ export const serverApi = {
fetchServerMeta(): Promise<ServerMeta> {
return request<ServerMeta>("/api/meta")
},
probeRemoteServer(payload: RemoteServerProbeRequest): Promise<RemoteServerProbeResponse> {
return request<RemoteServerProbeResponse>("/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")
},

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">): Promise<void> {
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")
}

View File

@@ -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<RemoteServerProfile>(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<ModelPreference>((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<UiConfigBucket>({})
const [serverConfigBucket, setServerConfigBucket] = createSignal<ServerConfigBucket>({})
const [uiStateBucket, setUiStateBucket] = createSignal<UiStateBucket>({})
@@ -324,6 +387,7 @@ const uiState = createMemo(() => normalizeUiState(uiStateBucket()))
const preferences = uiSettings
const recentFolders = createMemo<RecentFolder[]>(() => uiState().recentFolders)
const opencodeBinaries = createMemo<OpenCodeBinary[]>(() => uiState().opencodeBinaries)
const remoteServers = createMemo<RemoteServerProfile[]>(() => uiState().remoteServers)
let loadPromise: Promise<void> | 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<RemoteServerProfile> {
const profile = buildRemoteServerProfile(input, remoteServers())
await patchStateOwner("ui", { remoteServers: buildRemoteServerList(profile, remoteServers()) })
return profile
}
async function markRemoteServerConnected(id: string): Promise<void> {
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,

View File

@@ -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 {