Compare commits
9 Commits
v0.13.3-de
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a4c3df16 | ||
|
|
10506920ac | ||
|
|
92c029d744 | ||
|
|
6eb3246d37 | ||
|
|
5c90de84de | ||
|
|
455a59f693 | ||
|
|
a89da02d6b | ||
|
|
69d9e95bee | ||
|
|
893d5f9296 |
@@ -117,6 +117,28 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
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(
|
ipcMain.handle(
|
||||||
"notifications:show",
|
"notifications:show",
|
||||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
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 pendingBootstrapToken: string | null = null
|
||||||
let showingLoadingScreen = false
|
let showingLoadingScreen = false
|
||||||
let preloadingView: BrowserView | null = null
|
let preloadingView: BrowserView | null = null
|
||||||
|
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||||
|
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
app.commandLine.appendSwitch("disable-spell-checking")
|
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>()
|
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]
|
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||||
for (const candidate of rendererCandidates) {
|
for (const candidate of rendererCandidates) {
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
@@ -109,13 +116,13 @@ function getAllowedRendererOrigins(): string[] {
|
|||||||
return Array.from(origins)
|
return Array.from(origins)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldOpenExternally(url: string): boolean {
|
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const allowedOrigins = getAllowedRendererOrigins()
|
const allowedOrigins = getAllowedRendererOrigins(window)
|
||||||
return !allowedOrigins.includes(parsed.origin)
|
return !allowedOrigins.includes(parsed.origin)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -128,7 +135,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
if (shouldOpenExternally(url)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
handleExternal(url)
|
handleExternal(url)
|
||||||
return { action: "deny" }
|
return { action: "deny" }
|
||||||
}
|
}
|
||||||
@@ -136,13 +143,54 @@ function setupNavigationGuards(window: BrowserWindow) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
window.webContents.on("will-navigate", (event, url) => {
|
window.webContents.on("will-navigate", (event, url) => {
|
||||||
if (shouldOpenExternally(url)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleExternal(url)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
|
||||||
|
try {
|
||||||
|
const origin = new URL(url).origin
|
||||||
|
insecureWindowOrigins.set(window.id, new Set([origin]))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to store insecure origin", url, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowInsecureOrigin(window: BrowserWindow) {
|
||||||
|
insecureWindowOrigins.delete(window.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsecureOriginAllowed(url: string) {
|
||||||
|
try {
|
||||||
|
const targetOrigin = new URL(url).origin
|
||||||
|
for (const origins of insecureWindowOrigins.values()) {
|
||||||
|
if (origins.has(targetOrigin)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
let cachedPreloadPath: string | null = null
|
let cachedPreloadPath: string | null = null
|
||||||
function getPreloadPath() {
|
function getPreloadPath() {
|
||||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||||
@@ -207,25 +255,30 @@ function createWindow() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
setupNavigationGuards(mainWindow)
|
const window = mainWindow
|
||||||
|
|
||||||
|
setupNavigationGuards(window)
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
loadLoadingScreen(mainWindow)
|
clearWindowAllowedOrigin(window)
|
||||||
|
loadLoadingScreen(window)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
window.webContents.openDevTools({ mode: "detach" })
|
||||||
}
|
}
|
||||||
|
|
||||||
createApplicationMenu(mainWindow)
|
createApplicationMenu(window)
|
||||||
setupCliIPC(mainWindow, cliManager)
|
setupCliIPC(window, cliManager)
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
window.on("closed", () => {
|
||||||
destroyPreloadingView()
|
destroyPreloadingView()
|
||||||
|
clearWindowAllowedOrigin(window)
|
||||||
|
clearWindowInsecureOrigin(window)
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
pendingCliUrl = null
|
pendingCliUrl = null
|
||||||
@@ -322,10 +375,66 @@ function finalizeCliSwap(url: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const window = mainWindow
|
||||||
showingLoadingScreen = false
|
showingLoadingScreen = false
|
||||||
currentCliUrl = url
|
currentCliUrl = url
|
||||||
|
setWindowAllowedOrigin(window, url)
|
||||||
pendingCliUrl = null
|
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) {
|
||||||
|
addWindowInsecureOrigin(window, targetUrl.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNavigationGuards(window)
|
||||||
|
window.on("closed", () => {
|
||||||
|
clearWindowAllowedOrigin(window)
|
||||||
|
clearWindowInsecureOrigin(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
|
let bootstrapExchangeInFlight = false
|
||||||
@@ -504,6 +613,17 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||||
|
|
||||||
|
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||||
|
if (isInsecureOriginAllowed(url)) {
|
||||||
|
event.preventDefault()
|
||||||
|
console.warn("[cli] allowing insecure remote certificate for", url, error)
|
||||||
|
callback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback(false)
|
||||||
|
})
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const electronAPI = {
|
|||||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
|
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||||
|
|||||||
@@ -244,6 +244,32 @@ export interface VoiceModeStateResponse {
|
|||||||
enabled: boolean
|
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 =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const PreferencesSchema = z
|
|||||||
showUsageMetrics: z.boolean().default(true),
|
showUsageMetrics: z.boolean().default(true),
|
||||||
autoCleanupBlankSessions: z.boolean().default(true),
|
autoCleanupBlankSessions: z.boolean().default(true),
|
||||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||||
|
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
||||||
|
|
||||||
// OS notifications
|
// OS notifications
|
||||||
osNotificationsEnabled: z.boolean().default(false),
|
osNotificationsEnabled: z.boolean().default(false),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { registerPluginRoutes } from "./routes/plugin"
|
|||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { registerSpeechRoutes } from "./routes/speech"
|
import { registerSpeechRoutes } from "./routes/speech"
|
||||||
|
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
@@ -270,6 +271,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
|
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
registerPluginRoutes(app, {
|
registerPluginRoutes(app, {
|
||||||
workspaceManager: deps.workspaceManager,
|
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
|
||||||
|
}
|
||||||
@@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
|||||||
if (typeof listeningMode === "string") {
|
if (typeof listeningMode === "string") {
|
||||||
serverConfig.listeningMode = listeningMode
|
serverConfig.listeningMode = listeningMode
|
||||||
}
|
}
|
||||||
|
const logLevel = preferences.logLevel
|
||||||
|
if (typeof logLevel === "string") {
|
||||||
|
serverConfig.logLevel = logLevel
|
||||||
|
}
|
||||||
const lastUsedBinary = preferences.lastUsedBinary
|
const lastUsedBinary = preferences.lastUsedBinary
|
||||||
if (typeof lastUsedBinary === "string") {
|
if (typeof lastUsedBinary === "string") {
|
||||||
serverConfig.opencodeBinary = lastUsedBinary
|
serverConfig.opencodeBinary = lastUsedBinary
|
||||||
@@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
|||||||
const moved = new Set([
|
const moved = new Set([
|
||||||
"environmentVariables",
|
"environmentVariables",
|
||||||
"listeningMode",
|
"listeningMode",
|
||||||
|
"logLevel",
|
||||||
"lastUsedBinary",
|
"lastUsedBinary",
|
||||||
"modelRecents",
|
"modelRecents",
|
||||||
"modelFavorites",
|
"modelFavorites",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import type { EventBus } from "../events/bus"
|
import type { EventBus } from "../events/bus"
|
||||||
import type { ConfigLocation } from "../config/location"
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { z } from "zod"
|
||||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||||
import { migrateSettingsLayout } from "./migrate"
|
import { migrateSettingsLayout } from "./migrate"
|
||||||
import type { WorkspaceEventPayload } from "../api-types"
|
import type { WorkspaceEventPayload } from "../api-types"
|
||||||
@@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config"
|
|||||||
|
|
||||||
export type DocKind = "config" | "state"
|
export type DocKind = "config" | "state"
|
||||||
|
|
||||||
|
const CanonicalLogLevelSchema = z.preprocess(
|
||||||
|
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
|
||||||
|
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: SettingsDoc = { ...value }
|
||||||
|
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
|
||||||
|
if (parsedLogLevel.success) {
|
||||||
|
next.logLevel = parsedLogLevel.data
|
||||||
|
} else if (next.logLevel !== undefined) {
|
||||||
|
next.logLevel = "DEBUG"
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
|
||||||
|
if (!isPlainObject(doc)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(doc.server)) {
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
private readonly configStore: YamlDocStore
|
private readonly configStore: YamlDocStore
|
||||||
private readonly stateStore: YamlDocStore
|
private readonly stateStore: YamlDocStore
|
||||||
@@ -23,22 +72,44 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDoc(kind: DocKind): SettingsDoc {
|
getDoc(kind: DocKind): SettingsDoc {
|
||||||
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
if (kind !== "config") {
|
||||||
|
return this.stateStore.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.configStore.get()
|
||||||
|
const normalized = normalizeConfigDoc(current)
|
||||||
|
if (!isDeepEqual(current, normalized)) {
|
||||||
|
this.configStore.replace(normalized)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||||
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
const updated =
|
||||||
|
kind === "config"
|
||||||
|
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||||
|
: this.stateStore.mergePatch(patch)
|
||||||
this.publish(kind, "*")
|
this.publish(kind, "*")
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||||
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
if (kind !== "config") {
|
||||||
|
return this.stateStore.getOwner(owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
return owner === "server"
|
||||||
|
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
|
||||||
|
: this.getDoc("config")[owner] as SettingsDoc
|
||||||
}
|
}
|
||||||
|
|
||||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||||
const updated =
|
const updated =
|
||||||
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
|
kind === "config"
|
||||||
|
? owner === "server"
|
||||||
|
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||||
|
: this.configStore.mergePatchOwner(owner, patch)
|
||||||
|
: this.stateStore.mergePatchOwner(owner, patch)
|
||||||
this.publish(kind, owner, updated)
|
this.publish(kind, owner, updated)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,12 +142,15 @@ export class WorkspaceManager {
|
|||||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logLevel = (serverConfig as any)?.logLevel
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
folder: workspacePath,
|
folder: workspacePath,
|
||||||
binaryPath: resolvedBinaryPath,
|
binaryPath: resolvedBinaryPath,
|
||||||
environment,
|
environment,
|
||||||
|
logLevel,
|
||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ interface LaunchOptions {
|
|||||||
folder: string
|
folder: string
|
||||||
binaryPath: string
|
binaryPath: string
|
||||||
environment?: Record<string, string>
|
environment?: Record<string, string>
|
||||||
|
logLevel?: string
|
||||||
onExit?: (info: ProcessExitInfo) => void
|
onExit?: (info: ProcessExitInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +140,8 @@ export class WorkspaceRuntime {
|
|||||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||||
this.validateFolder(options.folder)
|
this.validateFolder(options.folder)
|
||||||
|
|
||||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
|
||||||
|
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
|
||||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||||
|
|
||||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||||
|
|||||||
@@ -2378,6 +2378,72 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:default",
|
||||||
|
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-is-registered",
|
||||||
|
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-register",
|
||||||
|
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-register-all",
|
||||||
|
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister",
|
||||||
|
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister-all",
|
||||||
|
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-is-registered",
|
||||||
|
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register",
|
||||||
|
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register-all",
|
||||||
|
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister",
|
||||||
|
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister-all",
|
||||||
|
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -534,7 +534,9 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("using cwd={}", c.display()));
|
log_line(&format!("using cwd={}", c.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let command_info = if supports_user_shell() {
|
let use_user_shell = supports_user_shell();
|
||||||
|
|
||||||
|
let command_info = if use_user_shell {
|
||||||
log_line("spawning via user shell");
|
log_line("spawning via user shell");
|
||||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||||
} else {
|
} else {
|
||||||
@@ -545,7 +547,7 @@ impl CliProcessManager {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
if !supports_user_shell() {
|
if !use_user_shell {
|
||||||
if which::which(&resolution.node_binary).is_err() {
|
if which::which(&resolution.node_binary).is_err() {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"Node binary not found. Make sure Node.js is installed."
|
"Node binary not found. Make sure Node.js is installed."
|
||||||
@@ -559,6 +561,8 @@ impl CliProcessManager {
|
|||||||
let mut c = Command::new(&cmd.shell);
|
let mut c = Command::new(&cmd.shell);
|
||||||
c.args(&cmd.args)
|
c.args(&cmd.args)
|
||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
|
.env_remove("npm_config_prefix")
|
||||||
|
.env_remove("NPM_CONFIG_PREFIX")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
configure_spawn(&mut c);
|
configure_spawn(&mut c);
|
||||||
@@ -619,26 +623,41 @@ impl CliProcessManager {
|
|||||||
.map(BufReader::new);
|
.map(BufReader::new);
|
||||||
|
|
||||||
if let Some(reader) = stdout {
|
if let Some(reader) = stdout {
|
||||||
Self::process_stream(
|
let app = app_clone.clone();
|
||||||
reader,
|
let status = status_clone.clone();
|
||||||
"stdout",
|
let ready = ready_clone.clone();
|
||||||
&app_clone,
|
let token = token_clone.clone();
|
||||||
&status_clone,
|
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||||
&ready_clone,
|
thread::spawn(move || {
|
||||||
&token_clone,
|
Self::process_stream(
|
||||||
auth_cookie_name_clone.as_str(),
|
reader,
|
||||||
);
|
"stdout",
|
||||||
|
&app,
|
||||||
|
&status,
|
||||||
|
&ready,
|
||||||
|
&token,
|
||||||
|
auth_cookie_name.as_str(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
Self::process_stream(
|
let app = app_clone.clone();
|
||||||
reader,
|
let status = status_clone.clone();
|
||||||
"stderr",
|
let ready = ready_clone.clone();
|
||||||
&app_clone,
|
let token = token_clone.clone();
|
||||||
&status_clone,
|
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||||
&ready_clone,
|
thread::spawn(move || {
|
||||||
&token_clone,
|
Self::process_stream(
|
||||||
auth_cookie_name_clone.as_str(),
|
reader,
|
||||||
);
|
"stderr",
|
||||||
|
&app,
|
||||||
|
&status,
|
||||||
|
&ready,
|
||||||
|
&token,
|
||||||
|
auth_cookie_name.as_str(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -757,8 +776,7 @@ impl CliProcessManager {
|
|||||||
auth_cookie_name: &str,
|
auth_cookie_name: &str,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
|
||||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -801,37 +819,6 @@ impl CliProcessManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.to_lowercase().contains("http server listening") {
|
|
||||||
if let Some(port) = http_regex
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
|
||||||
{
|
|
||||||
Self::mark_ready(
|
|
||||||
app,
|
|
||||||
status,
|
|
||||||
ready,
|
|
||||||
bootstrap_token,
|
|
||||||
auth_cookie_name,
|
|
||||||
format!("http://localhost:{port}"),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
|
||||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
|
||||||
Self::mark_ready(
|
|
||||||
app,
|
|
||||||
status,
|
|
||||||
ready,
|
|
||||||
bootstrap_token,
|
|
||||||
auth_cookie_name,
|
|
||||||
format!("http://localhost:{}", port),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
@@ -1031,27 +1018,50 @@ impl CliEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||||
let candidates = vec![
|
let cwd = std::env::current_dir().ok();
|
||||||
std::env::current_dir()
|
let workspace = workspace_root();
|
||||||
.ok()
|
let mut candidates = vec![
|
||||||
|
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||||
|
workspace
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||||
|
workspace
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||||
|
workspace
|
||||||
|
.as_ref()
|
||||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
std::env::current_exe().ok().and_then(|ex| {
|
|
||||||
ex.parent()
|
|
||||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
|
||||||
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
|
||||||
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||||
|
let cwd = std::env::current_dir().ok();
|
||||||
|
let workspace = workspace_root();
|
||||||
let candidates = vec![
|
let candidates = vec![
|
||||||
std::env::current_dir()
|
workspace
|
||||||
.ok()
|
.as_ref()
|
||||||
.map(|p| p.join("packages/server/src/index.ts")),
|
.map(|p| p.join("packages/server/src/index.ts")),
|
||||||
std::env::current_dir()
|
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
|
||||||
.ok()
|
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
|
||||||
.map(|p| p.join("../server/src/index.ts")),
|
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
|
||||||
];
|
];
|
||||||
|
|
||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
@@ -1153,11 +1163,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
if shell_name.contains("zsh") {
|
let _ = shell_name;
|
||||||
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
vec!["-l".into(), "-c".into(), command.into()]
|
||||||
} else {
|
|
||||||
vec!["-l".into(), "-c".into(), command.into()]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ use cli_manager::{CliProcessManager, CliStatus};
|
|||||||
use keepawake::KeepAwake;
|
use keepawake::KeepAwake;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
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::{
|
use tauri_plugin_global_shortcut::{
|
||||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||||
};
|
};
|
||||||
@@ -41,6 +42,16 @@ pub struct AppState {
|
|||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||||
pub zoom_level: Mutex<f64>,
|
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)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
@@ -118,11 +129,28 @@ fn should_allow_internal(url: &Url) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
fn should_allow_window_origin<R: Runtime>(app_handle: &AppHandle<R>, window_label: &str, url: &Url) -> bool {
|
||||||
if should_allow_internal(url) {
|
if should_allow_internal(url) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let state = app_handle.state::<AppState>();
|
||||||
|
let Ok(allowed) = state.remote_origins.lock() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
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
|
if let Err(err) = webview
|
||||||
.app_handle()
|
.app_handle()
|
||||||
.opener()
|
.opener()
|
||||||
@@ -133,6 +161,53 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
false
|
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, or use the CodeNomad Electron app."
|
||||||
|
.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()));
|
||||||
|
|
||||||
|
if let Some(existing) = app.get_webview_window(&label) {
|
||||||
|
let _ = existing.navigate(parsed.clone());
|
||||||
|
let _ = existing.set_title(&title);
|
||||||
|
let _ = existing.show();
|
||||||
|
let _ = existing.unminimize();
|
||||||
|
let _ = existing.set_focus();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.state::<AppState>()
|
||||||
|
.remote_origins
|
||||||
|
.lock()
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
.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 {
|
||||||
|
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||||
|
origins.remove(&label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||||
paths
|
paths
|
||||||
.iter()
|
.iter()
|
||||||
@@ -286,6 +361,7 @@ fn main() {
|
|||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
wake_lock: Mutex::new(None),
|
wake_lock: Mutex::new(None),
|
||||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||||
|
remote_origins: Mutex::new(HashMap::new()),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
set_windows_app_user_model_id();
|
||||||
@@ -323,7 +399,8 @@ fn main() {
|
|||||||
cli_get_status,
|
cli_get_status,
|
||||||
cli_restart,
|
cli_restart,
|
||||||
wake_lock_start,
|
wake_lock_start,
|
||||||
wake_lock_stop
|
wake_lock_stop,
|
||||||
|
open_remote_window
|
||||||
])
|
])
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
match event.id().0.as_str() {
|
match event.id().0.as_str() {
|
||||||
@@ -455,11 +532,24 @@ fn main() {
|
|||||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// Ensure we have time to stop the CLI process before the app exits.
|
// Let windows close normally. App shutdown is handled only after the
|
||||||
|
// last window is actually gone so remote windows can outlive `main`.
|
||||||
|
let _ = api;
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
event: tauri::WindowEvent::Destroyed,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if !app_handle.webview_windows().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the CLI only when the final window is gone and the app is
|
||||||
|
// truly exiting.
|
||||||
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api.prevent_close();
|
|
||||||
let app = app_handle.clone();
|
let app = app_handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Some(state) = app.try_state::<AppState>() {
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Select } from "@kobalte/core/select"
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
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 { useConfig } from "../stores/preferences"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
@@ -14,11 +15,15 @@ import { useI18n, type Locale } from "../lib/i18n"
|
|||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
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 codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||||
|
|
||||||
|
type HomeTab = "local" | "servers"
|
||||||
|
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
@@ -27,12 +32,30 @@ interface FolderSelectionViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
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 { t, locale } = useI18n()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
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()
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
@@ -51,8 +74,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||||
|
|
||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
|
const serverList = () => remoteServers()
|
||||||
const isLoading = () => Boolean(props.isLoading)
|
const isLoading = () => Boolean(props.isLoading)
|
||||||
|
|
||||||
|
function getActiveListLength() {
|
||||||
|
return activeTab() === "local" ? folders().length : serverList().length
|
||||||
|
}
|
||||||
|
|
||||||
// Update selected binary when preferences change
|
// Update selected binary when preferences change
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lastUsed = serverSettings().opencodeBinary
|
const lastUsed = serverSettings().opencodeBinary
|
||||||
@@ -64,7 +92,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
function scrollToIndex(index: number) {
|
function scrollToIndex(index: number) {
|
||||||
const container = recentListRef
|
const container = recentListRef
|
||||||
if (!container) return
|
if (!container) return
|
||||||
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
|
const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
|
||||||
if (!element) return
|
if (!element) return
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
const containerRect = container.getBoundingClientRect()
|
||||||
@@ -113,19 +141,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderList = folders()
|
|
||||||
|
|
||||||
if (isBrowseShortcut) {
|
if (isBrowseShortcut) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void handleBrowse()
|
void handleBrowse()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folderList.length === 0) return
|
const listLength = getActiveListLength()
|
||||||
|
if (listLength === 0) return
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
|
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -138,7 +165,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
} else if (e.key === "PageDown") {
|
} else if (e.key === "PageDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const pageSize = 5
|
const pageSize = 5
|
||||||
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
|
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -156,7 +183,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
scrollToIndex(0)
|
scrollToIndex(0)
|
||||||
} else if (e.key === "End") {
|
} else if (e.key === "End") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = folderList.length - 1
|
const newIndex = listLength - 1
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -165,10 +192,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
handleEnterKey()
|
handleEnterKey()
|
||||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (folderList.length > 0 && focusMode() === "recent") {
|
if (listLength > 0 && focusMode() === "recent") {
|
||||||
const folder = folderList[selectedIndex()]
|
if (activeTab() === "local") {
|
||||||
if (folder) {
|
const folder = folders()[selectedIndex()]
|
||||||
handleRemove(folder.path)
|
if (folder) {
|
||||||
|
handleRemove(folder.path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const server = serverList()[selectedIndex()]
|
||||||
|
if (server) {
|
||||||
|
removeRemoteServerProfile(server.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,15 +211,40 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
function handleEnterKey() {
|
function handleEnterKey() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
const folderList = folders()
|
|
||||||
const index = selectedIndex()
|
const index = selectedIndex()
|
||||||
|
|
||||||
const folder = folderList[index]
|
if (activeTab() === "local") {
|
||||||
if (folder) {
|
const folder = folders()[index]
|
||||||
handleFolderSelect(folder.path)
|
if (folder) {
|
||||||
|
handleFolderSelect(folder.path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = serverList()[index]
|
||||||
|
if (server) {
|
||||||
|
void handleConnectSavedServer(server.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
activeTab()
|
||||||
|
setSelectedIndex(0)
|
||||||
|
setFocusMode("recent")
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const length = getActiveListLength()
|
||||||
|
if (length === 0) {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIndex() >= length) {
|
||||||
|
setSelectedIndex(length - 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
@@ -236,6 +295,87 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
props.onSelectFolder(path, selectedBinary())
|
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() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
@@ -476,90 +616,223 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||||
{/* Right column: recent folders */}
|
{/* Right column: recent folders */}
|
||||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
<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 flex flex-col flex-1 min-h-0">
|
||||||
<div class="panel-header">
|
<div class="panel-header !gap-0 !p-0">
|
||||||
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
|
||||||
<p class="panel-subtitle">
|
<button
|
||||||
{t(
|
type="button"
|
||||||
folders().length === 1
|
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||||
? "folderSelection.recent.subtitle.one"
|
classList={{
|
||||||
: "folderSelection.recent.subtitle.other",
|
"text-primary": activeTab() === "local",
|
||||||
{ count: folders().length },
|
"text-muted hover:text-secondary": activeTab() !== "local",
|
||||||
)}
|
}}
|
||||||
</p>
|
style={{
|
||||||
</div>
|
"background-color": "var(--surface-secondary)",
|
||||||
<div
|
}}
|
||||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
onClick={() => setActiveTab("local")}
|
||||||
ref={(el) => (recentListRef = el)}
|
>
|
||||||
>
|
|
||||||
<For each={folders()}>
|
|
||||||
{(folder, index) => (
|
|
||||||
<div
|
<div
|
||||||
class="panel-list-item"
|
class="panel-title text-base"
|
||||||
classList={{
|
style={{
|
||||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
"panel-list-item-disabled": isLoading(),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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
|
<button
|
||||||
data-folder-index={index()}
|
type="button"
|
||||||
class="panel-list-item-content flex-1"
|
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||||
disabled={isLoading()}
|
onClick={openServerDialog}
|
||||||
onClick={() => handleFolderSelect(folder.path)}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (isLoading()) return
|
|
||||||
setFocusMode("recent")
|
|
||||||
setSelectedIndex(index())
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-3 w-full">
|
<Globe class="w-4 h-4" />
|
||||||
<div class="flex-1 min-w-0">
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||||
|
ref={(el) => (recentListRef = el)}
|
||||||
|
>
|
||||||
|
<For each={remoteServers()}>
|
||||||
|
{(server, index) => (
|
||||||
|
<div
|
||||||
|
class="panel-list-item"
|
||||||
|
classList={{
|
||||||
|
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 w-full px-1">
|
||||||
|
<button
|
||||||
|
data-list-index={index()}
|
||||||
|
class="panel-list-item-content flex-1"
|
||||||
|
onClick={() => void handleConnectSavedServer(server.id)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setFocusMode("recent")
|
||||||
|
setSelectedIndex(index())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd">↵</kbd></Show>}>
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
</Show>
|
||||||
</For>
|
}
|
||||||
</div>
|
>
|
||||||
|
<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-list-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>
|
</div>
|
||||||
</Show>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -567,27 +840,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="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||||
<div class="panel shrink-0">
|
<div class="panel shrink-0">
|
||||||
<div class="panel-header hidden sm:block">
|
<div class="panel-header hidden sm:block">
|
||||||
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
|
||||||
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleBrowse()}
|
onClick={() => void handleBrowse()}
|
||||||
disabled={props.isLoading}
|
disabled={props.isLoading}
|
||||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||||
onMouseEnter={() => setFocusMode("new")}
|
onMouseEnter={() => setFocusMode("new")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<FolderPlus class="w-4 h-4" />
|
<FolderPlus class="w-4 h-4" />
|
||||||
<span>
|
<span>
|
||||||
{props.isLoading
|
{props.isLoading
|
||||||
? t("folderSelection.browse.buttonOpening")
|
? t("folderSelection.browse.buttonOpening")
|
||||||
: t("folderSelection.browse.button")}
|
: t("folderSelection.browse.button")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* OpenCode settings section */}
|
{/* OpenCode settings section */}
|
||||||
@@ -663,6 +946,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
onClose={() => setIsFolderBrowserOpen(false)}
|
onClose={() => setIsFolderBrowserOpen(false)}
|
||||||
onSelect={handleBrowserSelect}
|
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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { createEffect, createSignal, type Component } from "solid-js"
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Terminal } from "lucide-solid"
|
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||||
|
import { ChevronDown, Terminal } from "lucide-solid"
|
||||||
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
||||||
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
||||||
import { useConfig } from "../../stores/preferences"
|
import { useConfig } from "../../stores/preferences"
|
||||||
|
import type { ServerLogLevel } from "../../stores/preferences"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
type LogLevelOption = {
|
||||||
|
value: ServerLogLevel
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
export const OpenCodeSettingsSection: Component = () => {
|
export const OpenCodeSettingsSection: Component = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { serverSettings, updateLastUsedBinary } = useConfig()
|
const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig()
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
|
const logLevelOptions = createMemo<LogLevelOption[]>(() => [
|
||||||
|
{ value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") },
|
||||||
|
{ value: "INFO", label: t("settings.opencode.logLevel.option.info") },
|
||||||
|
{ value: "WARN", label: t("settings.opencode.logLevel.option.warn") },
|
||||||
|
{ value: "ERROR", label: t("settings.opencode.logLevel.option.error") },
|
||||||
|
])
|
||||||
|
const selectedLogLevel = createMemo(
|
||||||
|
() => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0],
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const binary = serverSettings().opencodeBinary || "opencode"
|
const binary = serverSettings().opencodeBinary || "opencode"
|
||||||
@@ -37,6 +53,60 @@ export const OpenCodeSettingsSection: Component = () => {
|
|||||||
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.opencode.logLevel.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.opencode.logLevel.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.opencode.logLevel.selector.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("settings.opencode.logLevel.selector.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<Select<LogLevelOption>
|
||||||
|
value={selectedLogLevel()}
|
||||||
|
onChange={(option) => {
|
||||||
|
if (!option) return
|
||||||
|
updateLogLevel(option.value)
|
||||||
|
}}
|
||||||
|
options={logLevelOptions()}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
itemComponent={(itemProps) => (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option">
|
||||||
|
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="selector-trigger" aria-label={t("settings.opencode.logLevel.title")}>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Select.Value<LogLevelOption>>
|
||||||
|
{(state) => (
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
|
{state.selectedOption()?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
|
<Select.Icon class="selector-trigger-icon">
|
||||||
|
<ChevronDown class="w-3 h-3" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content class="selector-popover">
|
||||||
|
<Select.Listbox class="selector-listbox" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<div class="settings-card-header">
|
<div class="settings-card-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
SpeechSynthesisResponse,
|
SpeechSynthesisResponse,
|
||||||
SpeechTranscriptionResponse,
|
SpeechTranscriptionResponse,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
|
RemoteServerProbeRequest,
|
||||||
|
RemoteServerProbeResponse,
|
||||||
VoiceModeStateResponse,
|
VoiceModeStateResponse,
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
@@ -194,6 +196,12 @@ export const serverApi = {
|
|||||||
fetchServerMeta(): Promise<ServerMeta> {
|
fetchServerMeta(): Promise<ServerMeta> {
|
||||||
return request<ServerMeta>("/api/meta")
|
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 }> {
|
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
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.subtitle": "Select any folder on your computer",
|
||||||
"folderSelection.browse.button": "Browse Folders",
|
"folderSelection.browse.button": "Browse Folders",
|
||||||
"folderSelection.browse.buttonOpening": "Opening...",
|
"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.advancedSettings": "Advanced Settings",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,31 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "Select Workspace",
|
"folderSelection.dialog.title": "Select Workspace",
|
||||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "OpenCode Log Level",
|
||||||
|
"settings.opencode.logLevel.subtitle": "Control the log verbosity used when launching new OpenCode instances.",
|
||||||
|
"settings.opencode.logLevel.selector.title": "Default log level",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "Choose how verbose new OpenCode instances should be.",
|
||||||
|
"settings.opencode.logLevel.option.debug": "Debug",
|
||||||
|
"settings.opencode.logLevel.option.info": "Info",
|
||||||
|
"settings.opencode.logLevel.option.warn": "Warn",
|
||||||
|
"settings.opencode.logLevel.option.error": "Error",
|
||||||
|
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "Interaction",
|
"settings.appearance.behavior.title": "Interaction",
|
||||||
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
|
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
|
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
|
||||||
"folderSelection.browse.button": "Explorar carpetas",
|
"folderSelection.browse.button": "Explorar carpetas",
|
||||||
"folderSelection.browse.buttonOpening": "Abriendo...",
|
"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.advancedSettings": "Configuración avanzada",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,31 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "Seleccionar workspace",
|
"folderSelection.dialog.title": "Seleccionar workspace",
|
||||||
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "Nivel de logs de OpenCode",
|
||||||
|
"settings.opencode.logLevel.subtitle": "Define el nivel de logs usado al iniciar nuevas instancias de OpenCode.",
|
||||||
|
"settings.opencode.logLevel.selector.title": "Verbosidad de logs",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "Elige cuanta informacion deben registrar las nuevas instancias de OpenCode.",
|
||||||
|
"settings.opencode.logLevel.option.debug": "Depuracion",
|
||||||
|
"settings.opencode.logLevel.option.info": "Informacion",
|
||||||
|
"settings.opencode.logLevel.option.warn": "Advertencia",
|
||||||
|
"settings.opencode.logLevel.option.error": "Error",
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "Interaccion",
|
"settings.appearance.behavior.title": "Interaccion",
|
||||||
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
|
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
|
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
|
||||||
"folderSelection.browse.button": "Parcourir les dossiers",
|
"folderSelection.browse.button": "Parcourir les dossiers",
|
||||||
"folderSelection.browse.buttonOpening": "Ouverture...",
|
"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.advancedSettings": "Paramètres avancés",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,31 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
||||||
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "Niveau de logs OpenCode",
|
||||||
|
"settings.opencode.logLevel.subtitle": "Definir le niveau de logs utilise au lancement des nouvelles instances OpenCode.",
|
||||||
|
"settings.opencode.logLevel.selector.title": "Verbosite des logs",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "Choisir la quantite de journaux emise par les nouvelles instances OpenCode.",
|
||||||
|
"settings.opencode.logLevel.option.debug": "Debogage",
|
||||||
|
"settings.opencode.logLevel.option.info": "Info",
|
||||||
|
"settings.opencode.logLevel.option.warn": "Avertissement",
|
||||||
|
"settings.opencode.logLevel.option.error": "Erreur",
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "Interaction",
|
"settings.appearance.behavior.title": "Interaction",
|
||||||
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
|
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
|
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
|
||||||
"folderSelection.browse.button": "עיון בתיקיות",
|
"folderSelection.browse.button": "עיון בתיקיות",
|
||||||
"folderSelection.browse.buttonOpening": "פותח...",
|
"folderSelection.browse.buttonOpening": "פותח...",
|
||||||
|
"folderSelection.actions.title": "פתח תיקייה או התחבר לשרת",
|
||||||
|
"folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad",
|
||||||
|
"folderSelection.actions.connectButton": "התחבר לשרת CodeNomad",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "הגדרות מתקדמות",
|
"folderSelection.advancedSettings": "הגדרות מתקדמות",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,31 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "בחר סביבת עבודה",
|
"folderSelection.dialog.title": "בחר סביבת עבודה",
|
||||||
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
|
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
|
||||||
"settings.opencode.runtime.title": "סביבת ריצה",
|
"settings.opencode.runtime.title": "סביבת ריצה",
|
||||||
"settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.",
|
"settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.",
|
||||||
|
"settings.opencode.logLevel.title": "רמת הלוגים של OpenCode",
|
||||||
|
"settings.opencode.logLevel.subtitle": "הגדר את רמת הלוגים שבה ייעשה שימוש בעת הפעלת מופעי OpenCode חדשים.",
|
||||||
|
"settings.opencode.logLevel.selector.title": "פירוט לוגים",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "בחר כמה לוגים מופעי OpenCode חדשים צריכים להפיק.",
|
||||||
|
"settings.opencode.logLevel.option.debug": "ניפוי שגיאות",
|
||||||
|
"settings.opencode.logLevel.option.info": "מידע",
|
||||||
|
"settings.opencode.logLevel.option.warn": "אזהרה",
|
||||||
|
"settings.opencode.logLevel.option.error": "שגיאה",
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "אינטראקציה",
|
"settings.appearance.behavior.title": "אינטראקציה",
|
||||||
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
|
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.subtitle": "コンピュータ上の任意のフォルダを選択",
|
"folderSelection.browse.subtitle": "コンピュータ上の任意のフォルダを選択",
|
||||||
"folderSelection.browse.button": "フォルダを参照",
|
"folderSelection.browse.button": "フォルダを参照",
|
||||||
"folderSelection.browse.buttonOpening": "開いています...",
|
"folderSelection.browse.buttonOpening": "開いています...",
|
||||||
|
"folderSelection.actions.title": "フォルダを開くかサーバーに接続",
|
||||||
|
"folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します",
|
||||||
|
"folderSelection.actions.connectButton": "CodeNomad サーバーに接続",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "詳細設定",
|
"folderSelection.advancedSettings": "詳細設定",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,31 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "ワークスペースを選択",
|
"folderSelection.dialog.title": "ワークスペースを選択",
|
||||||
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "OpenCode のログレベル",
|
||||||
|
"settings.opencode.logLevel.subtitle": "新しい OpenCode インスタンスの起動時に使うログレベルを設定します。",
|
||||||
|
"settings.opencode.logLevel.selector.title": "ログ出力の詳細度",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "新しい OpenCode インスタンスがどの程度ログを出力するかを選択します。",
|
||||||
|
"settings.opencode.logLevel.option.debug": "デバッグ",
|
||||||
|
"settings.opencode.logLevel.option.info": "情報",
|
||||||
|
"settings.opencode.logLevel.option.warn": "警告",
|
||||||
|
"settings.opencode.logLevel.option.error": "エラー",
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "操作",
|
"settings.appearance.behavior.title": "操作",
|
||||||
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
|
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.subtitle": "Выберите любую папку на компьютере",
|
"folderSelection.browse.subtitle": "Выберите любую папку на компьютере",
|
||||||
"folderSelection.browse.button": "Обзор папок",
|
"folderSelection.browse.button": "Обзор папок",
|
||||||
"folderSelection.browse.buttonOpening": "Открытие…",
|
"folderSelection.browse.buttonOpening": "Открытие…",
|
||||||
|
"folderSelection.actions.title": "Открыть папку или подключить сервер",
|
||||||
|
"folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad",
|
||||||
|
"folderSelection.actions.connectButton": "Подключить сервер CodeNomad",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Расширенные настройки",
|
"folderSelection.advancedSettings": "Расширенные настройки",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,31 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
||||||
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "Уровень логирования OpenCode",
|
||||||
|
"settings.opencode.logLevel.subtitle": "Задайте уровень логирования, используемый при запуске новых экземпляров OpenCode.",
|
||||||
|
"settings.opencode.logLevel.selector.title": "Подробность логов",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "Выберите, сколько логов должны выводить новые экземпляры OpenCode.",
|
||||||
|
"settings.opencode.logLevel.option.debug": "Отладка",
|
||||||
|
"settings.opencode.logLevel.option.info": "Информация",
|
||||||
|
"settings.opencode.logLevel.option.warn": "Предупреждение",
|
||||||
|
"settings.opencode.logLevel.option.error": "Ошибка",
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "Взаимодействие",
|
"settings.appearance.behavior.title": "Взаимодействие",
|
||||||
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
|
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.subtitle": "选择你电脑上的任意文件夹",
|
"folderSelection.browse.subtitle": "选择你电脑上的任意文件夹",
|
||||||
"folderSelection.browse.button": "浏览文件夹",
|
"folderSelection.browse.button": "浏览文件夹",
|
||||||
"folderSelection.browse.buttonOpening": "正在打开...",
|
"folderSelection.browse.buttonOpening": "正在打开...",
|
||||||
|
"folderSelection.actions.title": "打开文件夹或连接服务器",
|
||||||
|
"folderSelection.actions.subtitle": "打开本地文件夹或连接到 CodeNomad 服务器",
|
||||||
|
"folderSelection.actions.connectButton": "连接 CodeNomad 服务器",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "高级设置",
|
"folderSelection.advancedSettings": "高级设置",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,31 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "选择工作区",
|
"folderSelection.dialog.title": "选择工作区",
|
||||||
"folderSelection.dialog.description": "选择工作区以开始编码。",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "OpenCode 日志级别",
|
||||||
|
"settings.opencode.logLevel.subtitle": "设置启动新的 OpenCode 实例时使用的日志级别。",
|
||||||
|
"settings.opencode.logLevel.selector.title": "日志详细程度",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "选择新的 OpenCode 实例应输出多少日志信息。",
|
||||||
|
"settings.opencode.logLevel.option.debug": "调试",
|
||||||
|
"settings.opencode.logLevel.option.info": "信息",
|
||||||
|
"settings.opencode.logLevel.option.warn": "警告",
|
||||||
|
"settings.opencode.logLevel.option.error": "错误",
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "交互",
|
"settings.appearance.behavior.title": "交互",
|
||||||
"settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。",
|
"settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。",
|
||||||
|
|||||||
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 { createContext, createMemo, createSignal, onMount, useContext } from "solid-js"
|
||||||
import type { Accessor, ParentComponent } from "solid-js"
|
import type { Accessor, ParentComponent } from "solid-js"
|
||||||
import { storage, type OwnerBucket } from "../lib/storage"
|
import { storage, type OwnerBucket } from "../lib/storage"
|
||||||
|
import type { RemoteServerProfile } from "../../../server/src/api-types"
|
||||||
import {
|
import {
|
||||||
ensureInstanceConfigLoaded,
|
ensureInstanceConfigLoaded,
|
||||||
getInstanceConfig,
|
getInstanceConfig,
|
||||||
@@ -28,6 +29,7 @@ export type DiffViewMode = "split" | "unified"
|
|||||||
export type ExpansionPreference = "expanded" | "collapsed"
|
export type ExpansionPreference = "expanded" | "collapsed"
|
||||||
export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded"
|
export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded"
|
||||||
export type ListeningMode = "local" | "all"
|
export type ListeningMode = "local" | "all"
|
||||||
|
export type ServerLogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"
|
||||||
export type SpeechProviderPreference = "openai-compatible"
|
export type SpeechProviderPreference = "openai-compatible"
|
||||||
export type SpeechPlaybackMode = "streaming" | "buffered"
|
export type SpeechPlaybackMode = "streaming" | "buffered"
|
||||||
export type SpeechTtsFormat = "mp3" | "wav" | "opus" | "aac"
|
export type SpeechTtsFormat = "mp3" | "wav" | "opus" | "aac"
|
||||||
@@ -94,6 +96,7 @@ interface UiConfigBucket {
|
|||||||
|
|
||||||
interface ServerConfigBucket {
|
interface ServerConfigBucket {
|
||||||
listeningMode?: ListeningMode
|
listeningMode?: ListeningMode
|
||||||
|
logLevel?: ServerLogLevel
|
||||||
environmentVariables?: Record<string, string>
|
environmentVariables?: Record<string, string>
|
||||||
opencodeBinary?: string
|
opencodeBinary?: string
|
||||||
speech?: Partial<SpeechSettings>
|
speech?: Partial<SpeechSettings>
|
||||||
@@ -102,6 +105,7 @@ interface ServerConfigBucket {
|
|||||||
interface UiStateBucket {
|
interface UiStateBucket {
|
||||||
recentFolders?: RecentFolder[]
|
recentFolders?: RecentFolder[]
|
||||||
opencodeBinaries?: OpenCodeBinary[]
|
opencodeBinaries?: OpenCodeBinary[]
|
||||||
|
remoteServers?: RemoteServerProfile[]
|
||||||
models?: {
|
models?: {
|
||||||
recents?: ModelPreference[]
|
recents?: ModelPreference[]
|
||||||
favorites?: ModelPreference[]
|
favorites?: ModelPreference[]
|
||||||
@@ -112,6 +116,7 @@ interface UiStateBucket {
|
|||||||
interface NormalizedUiState {
|
interface NormalizedUiState {
|
||||||
recentFolders: RecentFolder[]
|
recentFolders: RecentFolder[]
|
||||||
opencodeBinaries: OpenCodeBinary[]
|
opencodeBinaries: OpenCodeBinary[]
|
||||||
|
remoteServers: RemoteServerProfile[]
|
||||||
models: {
|
models: {
|
||||||
recents: ModelPreference[]
|
recents: ModelPreference[]
|
||||||
favorites: ModelPreference[]
|
favorites: ModelPreference[]
|
||||||
@@ -250,6 +255,29 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
|
|||||||
const label = typeof (b as any).label === "string" ? (b as any).label : undefined
|
const label = typeof (b as any).label === "string" ? (b as any).label : undefined
|
||||||
return { path: p, version, label, lastUsed }
|
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: {
|
models: {
|
||||||
recents: cloneArray<ModelPreference>((source.models as any)?.recents, (m) => {
|
recents: cloneArray<ModelPreference>((source.models as any)?.recents, (m) => {
|
||||||
if (!m || typeof m !== "object") return null
|
if (!m || typeof m !== "object") return null
|
||||||
@@ -272,13 +300,17 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
|
|||||||
|
|
||||||
function normalizeServerConfig(
|
function normalizeServerConfig(
|
||||||
input?: ServerConfigBucket | null,
|
input?: ServerConfigBucket | null,
|
||||||
): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> & { speech: SpeechSettings } {
|
): Required<Pick<ServerConfigBucket, "listeningMode" | "logLevel" | "environmentVariables" | "opencodeBinary">> & { speech: SpeechSettings } {
|
||||||
const source = input ?? {}
|
const source = input ?? {}
|
||||||
const listeningMode = source.listeningMode === "all" ? "all" : "local"
|
const listeningMode = source.listeningMode === "all" ? "all" : "local"
|
||||||
|
const logLevel =
|
||||||
|
source.logLevel === "INFO" || source.logLevel === "WARN" || source.logLevel === "ERROR" || source.logLevel === "DEBUG"
|
||||||
|
? source.logLevel
|
||||||
|
: "DEBUG"
|
||||||
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
|
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
|
||||||
const environmentVariables = normalizeRecord(source.environmentVariables)
|
const environmentVariables = normalizeRecord(source.environmentVariables)
|
||||||
const speech = normalizeSpeechSettings(source.speech)
|
const speech = normalizeSpeechSettings(source.speech)
|
||||||
return { listeningMode, opencodeBinary, environmentVariables, speech }
|
return { listeningMode, logLevel, opencodeBinary, environmentVariables, speech }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelKey(model: { providerId: string; modelId: string }): string {
|
function getModelKey(model: { providerId: string; modelId: string }): string {
|
||||||
@@ -305,6 +337,43 @@ function buildBinaryList(binaryPath: string, version: string | undefined, source
|
|||||||
return [nextEntry, ...source].slice(0, 10)
|
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 [uiConfigBucket, setUiConfigBucket] = createSignal<UiConfigBucket>({})
|
||||||
const [serverConfigBucket, setServerConfigBucket] = createSignal<ServerConfigBucket>({})
|
const [serverConfigBucket, setServerConfigBucket] = createSignal<ServerConfigBucket>({})
|
||||||
const [uiStateBucket, setUiStateBucket] = createSignal<UiStateBucket>({})
|
const [uiStateBucket, setUiStateBucket] = createSignal<UiStateBucket>({})
|
||||||
@@ -318,6 +387,7 @@ const uiState = createMemo(() => normalizeUiState(uiStateBucket()))
|
|||||||
const preferences = uiSettings
|
const preferences = uiSettings
|
||||||
const recentFolders = createMemo<RecentFolder[]>(() => uiState().recentFolders)
|
const recentFolders = createMemo<RecentFolder[]>(() => uiState().recentFolders)
|
||||||
const opencodeBinaries = createMemo<OpenCodeBinary[]>(() => uiState().opencodeBinaries)
|
const opencodeBinaries = createMemo<OpenCodeBinary[]>(() => uiState().opencodeBinaries)
|
||||||
|
const remoteServers = createMemo<RemoteServerProfile[]>(() => uiState().remoteServers)
|
||||||
|
|
||||||
let loadPromise: Promise<void> | null = null
|
let loadPromise: Promise<void> | null = null
|
||||||
|
|
||||||
@@ -409,6 +479,11 @@ function updateLastUsedBinary(path: string): void {
|
|||||||
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error))
|
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateLogLevel(level: ServerLogLevel): void {
|
||||||
|
const target = level ?? "DEBUG"
|
||||||
|
void patchConfigOwner("server", { logLevel: target }).catch((error) => log.error("Failed to set log level", error))
|
||||||
|
}
|
||||||
|
|
||||||
async function updateSpeechSettings(updates: SpeechSettingsUpdate): Promise<void> {
|
async function updateSpeechSettings(updates: SpeechSettingsUpdate): Promise<void> {
|
||||||
const apiKeyPatch = updates.apiKey
|
const apiKeyPatch = updates.apiKey
|
||||||
const { apiKey: _apiKey, ...restUpdates } = updates
|
const { apiKey: _apiKey, ...restUpdates } = updates
|
||||||
@@ -456,6 +531,29 @@ function removeRecentFolder(folderPath: string): void {
|
|||||||
void patchStateOwner("ui", { recentFolders: next }).catch((error) => log.error("Failed to remove recent folder", error))
|
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 {
|
function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void {
|
||||||
const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : serverSettings().opencodeBinary
|
const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : serverSettings().opencodeBinary
|
||||||
const nextFolders = buildRecentFolderList(folderPath, recentFolders())
|
const nextFolders = buildRecentFolderList(folderPath, recentFolders())
|
||||||
@@ -612,17 +710,22 @@ interface ConfigContextValue {
|
|||||||
updateEnvironmentVariables: typeof updateEnvironmentVariables
|
updateEnvironmentVariables: typeof updateEnvironmentVariables
|
||||||
addEnvironmentVariable: typeof addEnvironmentVariable
|
addEnvironmentVariable: typeof addEnvironmentVariable
|
||||||
removeEnvironmentVariable: typeof removeEnvironmentVariable
|
removeEnvironmentVariable: typeof removeEnvironmentVariable
|
||||||
updateLastUsedBinary: typeof updateLastUsedBinary
|
updateLastUsedBinary: typeof updateLastUsedBinary
|
||||||
updateSpeechSettings: typeof updateSpeechSettings
|
updateLogLevel: typeof updateLogLevel
|
||||||
|
updateSpeechSettings: typeof updateSpeechSettings
|
||||||
|
|
||||||
// ui-owned state
|
// ui-owned state
|
||||||
recentFolders: typeof recentFolders
|
recentFolders: typeof recentFolders
|
||||||
opencodeBinaries: typeof opencodeBinaries
|
opencodeBinaries: typeof opencodeBinaries
|
||||||
|
remoteServers: typeof remoteServers
|
||||||
uiState: typeof uiState
|
uiState: typeof uiState
|
||||||
addRecentFolder: typeof addRecentFolder
|
addRecentFolder: typeof addRecentFolder
|
||||||
removeRecentFolder: typeof removeRecentFolder
|
removeRecentFolder: typeof removeRecentFolder
|
||||||
addOpenCodeBinary: typeof addOpenCodeBinary
|
addOpenCodeBinary: typeof addOpenCodeBinary
|
||||||
removeOpenCodeBinary: typeof removeOpenCodeBinary
|
removeOpenCodeBinary: typeof removeOpenCodeBinary
|
||||||
|
saveRemoteServerProfile: typeof saveRemoteServerProfile
|
||||||
|
markRemoteServerConnected: typeof markRemoteServerConnected
|
||||||
|
removeRemoteServerProfile: typeof removeRemoteServerProfile
|
||||||
recordWorkspaceLaunch: typeof recordWorkspaceLaunch
|
recordWorkspaceLaunch: typeof recordWorkspaceLaunch
|
||||||
addRecentModelPreference: typeof addRecentModelPreference
|
addRecentModelPreference: typeof addRecentModelPreference
|
||||||
isFavoriteModelPreference: typeof isFavoriteModelPreference
|
isFavoriteModelPreference: typeof isFavoriteModelPreference
|
||||||
@@ -663,14 +766,19 @@ const configContextValue: ConfigContextValue = {
|
|||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
removeEnvironmentVariable,
|
removeEnvironmentVariable,
|
||||||
updateLastUsedBinary,
|
updateLastUsedBinary,
|
||||||
|
updateLogLevel,
|
||||||
updateSpeechSettings,
|
updateSpeechSettings,
|
||||||
recentFolders,
|
recentFolders,
|
||||||
opencodeBinaries,
|
opencodeBinaries,
|
||||||
|
remoteServers,
|
||||||
uiState,
|
uiState,
|
||||||
addRecentFolder,
|
addRecentFolder,
|
||||||
removeRecentFolder,
|
removeRecentFolder,
|
||||||
addOpenCodeBinary,
|
addOpenCodeBinary,
|
||||||
removeOpenCodeBinary,
|
removeOpenCodeBinary,
|
||||||
|
saveRemoteServerProfile,
|
||||||
|
markRemoteServerConnected,
|
||||||
|
removeRemoteServerProfile,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
addRecentModelPreference,
|
addRecentModelPreference,
|
||||||
isFavoriteModelPreference,
|
isFavoriteModelPreference,
|
||||||
@@ -746,6 +854,7 @@ export {
|
|||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
removeEnvironmentVariable,
|
removeEnvironmentVariable,
|
||||||
updateLastUsedBinary,
|
updateLastUsedBinary,
|
||||||
|
updateLogLevel,
|
||||||
updateSpeechSettings,
|
updateSpeechSettings,
|
||||||
addRecentFolder,
|
addRecentFolder,
|
||||||
removeRecentFolder,
|
removeRecentFolder,
|
||||||
|
|||||||
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 }>
|
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||||
|
|
||||||
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
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 {
|
interface File {
|
||||||
|
|||||||
Reference in New Issue
Block a user