add remote server launcher flow
This commit is contained in:
@@ -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 }> => {
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
166
packages/server/src/server/routes/remote-servers.ts
Normal file
166
packages/server/src/server/routes/remote-servers.ts
Normal 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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
34
packages/ui/src/lib/native/remote-window.ts
Normal file
34
packages/ui/src/lib/native/remote-window.ts
Normal 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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
6
packages/ui/src/types/global.d.ts
vendored
6
packages/ui/src/types/global.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user