From ffa872ebd3add840858f047c928ee376d08a2c64 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 28 Sep 2026 21:59:37 -0500 Subject: [PATCH] fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport --- plugins/proxmox/src/backend/guest-sync.ts | 78 +++++++++++++ plugins/proxmox/src/backend/routes.ts | 110 +++++------------- .../proxmox/tests/backend/guest-sync.test.ts | 61 ++++++++++ src/backend/hosts/cloudflare-websocket.ts | 64 ---------- src/backend/hosts/connect/transport.ts | 48 +------- src/backend/sync/entities.ts | 1 + .../tests/hosts/cloudflare-websocket.test.ts | 74 ------------ .../hosts/host-session-status.test.ts | 2 +- .../{ => tests}/hosts/jump-host-proxy.test.ts | 2 +- .../{ => tests}/hosts/ssh-keepalive.test.ts | 2 +- .../tests/plugins/sync-registry.test.ts | 10 ++ src/backend/tests/sync/wire.test.ts | 24 ++++ src/ui/lib/default-keybindings.ts | 18 +-- src/ui/lib/terminal-themes.ts | 24 ++-- src/ui/locales/en.json | 26 ++++- src/ui/sidebar/ConnectionDefaultsSettings.tsx | 2 +- src/ui/sidebar/HostTerminalSettings.tsx | 14 ++- src/ui/sidebar/KeybindingsDialog.tsx | 2 +- .../HostItem/host-status-tooltip.test.ts} | 0 19 files changed, 270 insertions(+), 292 deletions(-) create mode 100644 plugins/proxmox/src/backend/guest-sync.ts create mode 100644 plugins/proxmox/tests/backend/guest-sync.test.ts delete mode 100644 src/backend/hosts/cloudflare-websocket.ts delete mode 100644 src/backend/tests/hosts/cloudflare-websocket.test.ts rename src/backend/{ => tests}/hosts/host-session-status.test.ts (93%) rename src/backend/{ => tests}/hosts/jump-host-proxy.test.ts (94%) rename src/backend/{ => tests}/hosts/ssh-keepalive.test.ts (91%) rename src/ui/{sidebar/tree/HostItem/HostItem.test.ts => tests/sidebar/tree/HostItem/host-status-tooltip.test.ts} (100%) diff --git a/plugins/proxmox/src/backend/guest-sync.ts b/plugins/proxmox/src/backend/guest-sync.ts new file mode 100644 index 000000000..d39021748 --- /dev/null +++ b/plugins/proxmox/src/backend/guest-sync.ts @@ -0,0 +1,78 @@ +export type ProxmoxSource = { + source: "proxmox"; + sourceHostId: number; + node: string; + vmid: number; + type: "qemu" | "lxc"; + lastSeenAt?: string; + lastStatus?: string; + missingSince?: string | null; +}; + +export function parseJsonObject(value: unknown): Record { + if (!value) return {}; + if (typeof value === "object") return value as Record; + if (typeof value !== "string") return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +/** The guest a host was imported from, read from its proxmoxConfig setting. */ +export function getProxmoxSource(config: unknown): ProxmoxSource | null { + const source = parseJsonObject(config).source; + if (!source || typeof source !== "object") return null; + const src = source as Record; + if ( + src.source !== "proxmox" || + typeof src.sourceHostId !== "number" || + typeof src.node !== "string" || + typeof src.vmid !== "number" || + (src.type !== "qemu" && src.type !== "lxc") + ) { + return null; + } + return src as ProxmoxSource; +} + +export function proxmoxSourceKey(source: { + sourceHostId: number; + node: string; + type: string; + vmid: number; +}): string { + return `${source.sourceHostId}:${source.node}:${source.type}:${source.vmid}`; +} + +export interface ImportedGuest { + host: Record; + config: Record; + source: ProxmoxSource; +} + +/** + * The hosts already imported from one Proxmox node, keyed by guest. The + * source lives in each guest's proxmoxConfig host setting, not on the host + * row, so the caller passes both. + */ +export function indexImportedGuests( + hosts: Record[], + configs: Array<{ hostId: number; value: unknown }>, + sourceHostId: number, +): Map { + const configById = new Map(configs.map((c) => [c.hostId, c.value])); + const out = new Map(); + for (const host of hosts) { + const config = parseJsonObject(configById.get(Number(host.id))); + const source = getProxmoxSource(config); + if (source?.sourceHostId === sourceHostId) { + out.set(proxmoxSourceKey(source), { host, config, source }); + } + } + return out; +} diff --git a/plugins/proxmox/src/backend/routes.ts b/plugins/proxmox/src/backend/routes.ts index 3b5b7cfd6..39d4e44bd 100644 --- a/plugins/proxmox/src/backend/routes.ts +++ b/plugins/proxmox/src/backend/routes.ts @@ -14,6 +14,11 @@ import { serializeProxmoxJumpHosts, } from "./proxmox-jump-hosts.js"; import { isSafeNodeName } from "./proxmox-shared.js"; +import { + indexImportedGuests, + parseJsonObject, + type ProxmoxSource, +} from "./guest-sync.js"; /** A thrown value's message, or the fallback when it is not an Error. */ function getErrorMessage(error: unknown, fallback = "Unknown error"): string { @@ -187,17 +192,6 @@ type ProxmoxGuest = { /** The resolved host ctx.ssh.connect hands back, plus the sudo secret proxmox needs for pvesh. */ type PluginSshHostWithSudo = PluginHostRecord & { sudoPassword?: string }; -type ProxmoxSource = { - source: "proxmox"; - sourceHostId: number; - node: string; - vmid: number; - type: "qemu" | "lxc"; - lastSeenAt?: string; - lastStatus?: string; - missingSince?: string | null; -}; - type ProxmoxSyncResult = { created: number; updated: number; @@ -206,41 +200,6 @@ type ProxmoxSyncResult = { errors: string[]; }; -function parseJsonObject(value: unknown): Record { - if (!value) return {}; - if (typeof value === "object") return value as Record; - if (typeof value !== "string") return {}; - try { - const parsed = JSON.parse(value); - return parsed && typeof parsed === "object" - ? (parsed as Record) - : {}; - } catch { - return {}; - } -} - -function getProxmoxSource(host: Record): ProxmoxSource | null { - const config = parseJsonObject(host.proxmoxConfig); - const source = config.source; - if (!source || typeof source !== "object") return null; - const src = source as Record; - if ( - src.source !== "proxmox" || - typeof src.sourceHostId !== "number" || - typeof src.node !== "string" || - typeof src.vmid !== "number" || - (src.type !== "qemu" && src.type !== "lxc") - ) { - return null; - } - return src as ProxmoxSource; -} - -function proxmoxSourceKey(source: ProxmoxSource): string { - return `${source.sourceHostId}:${source.node}:${source.type}:${source.vmid}`; -} - function guestSourceKey(sourceHostId: number, guest: ProxmoxGuest): string { return `${sourceHostId}:${guest.node}:${guest.type}:${guest.vmid}`; } @@ -552,19 +511,18 @@ async function syncProxmoxHost( string, unknown >[]; - const existingBySource = new Map>(); - for (const host of existingHosts) { - const source = getProxmoxSource(host); - if (source?.sourceHostId === sourceHostId) { - existingBySource.set(proxmoxSourceKey(source), host); - } - } + const existingBySource = indexImportedGuests( + existingHosts, + await ctx.settings.listHostValues("proxmoxConfig"), + sourceHostId, + ); const seen = new Set(); for (const guest of discovery.guests) { const key = guestSourceKey(sourceHostId, guest); seen.add(key); - const existing = existingBySource.get(key); + const imported = existingBySource.get(key); + const existing = imported?.host; const source: ProxmoxSource = { source: "proxmox", sourceHostId, @@ -576,13 +534,7 @@ async function syncProxmoxHost( missingSince: null, }; - const baseConfig = existing - ? parseJsonObject(existing.proxmoxConfig) - : {}; - const proxmoxConfig = { - ...baseConfig, - source, - }; + const proxmoxConfig = { ...(imported?.config ?? {}), source }; const existingConnectionType = existing?.connectionType === "ssh" || existing?.connectionType === "rdp" ? existing.connectionType @@ -616,7 +568,6 @@ async function syncProxmoxHost( connectionType, folder: existing?.folder || sourceHostName, tags: mergeTags(existing?.tags, guestTags(guest), ["proxmox-missing"]), - proxmoxConfig: JSON.stringify(proxmoxConfig), updatedAt: now, }; @@ -629,6 +580,11 @@ async function syncProxmoxHost( existing.id as number, update as PluginHostUpdateInput, ); + await ctx.settings.setHost( + existing.id as number, + "proxmoxConfig", + proxmoxConfig, + ); result.updated++; continue; } @@ -639,7 +595,7 @@ async function syncProxmoxHost( enableRdp: connectionType === "rdp", }); - await ctx.hosts.create({ + const created = await ctx.hosts.create({ ...update, createdAt: now, pin: false, @@ -660,8 +616,6 @@ async function syncProxmoxHost( telnetPassword: null, jumpHosts: serializeProxmoxJumpHosts(discovery.jumpHosts), quickActions: null, - statsConfig: null, - dockerConfig: null, terminalConfig: null, forceKeyboardInteractive: "false", useSocks5: 0, @@ -672,30 +626,28 @@ async function syncProxmoxHost( socks5ProxyChain: null, portKnockSequence: null, } as unknown as PluginHostCreateInput); + await ctx.settings.setHost( + Number(created.id), + "proxmoxConfig", + proxmoxConfig, + ); result.created++; } if (discovery.config.markMissingGuests) { - for (const [key, existing] of existingBySource.entries()) { + for (const [key, { host, config, source }] of existingBySource) { if (seen.has(key)) continue; - const config = parseJsonObject(existing.proxmoxConfig); - const source = getProxmoxSource(existing); - if (!source) continue; - const missingSince = source.missingSince || now; await ctx.hosts.update( - existing.id as number, + host.id as number, { - tags: mergeTags(existing.tags, ["proxmox-missing"]), - proxmoxConfig: JSON.stringify({ - ...config, - source: { - ...source, - missingSince, - }, - }), + tags: mergeTags(host.tags, ["proxmox-missing"]), updatedAt: now, } as PluginHostUpdateInput, ); + await ctx.settings.setHost(host.id as number, "proxmoxConfig", { + ...config, + source: { ...source, missingSince: source.missingSince || now }, + }); result.markedMissing++; } } diff --git a/plugins/proxmox/tests/backend/guest-sync.test.ts b/plugins/proxmox/tests/backend/guest-sync.test.ts new file mode 100644 index 000000000..53af05465 --- /dev/null +++ b/plugins/proxmox/tests/backend/guest-sync.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + getProxmoxSource, + indexImportedGuests, + proxmoxSourceKey, +} from "../../src/backend/guest-sync.js"; + +const source = { + source: "proxmox", + sourceHostId: 1, + node: "pve", + vmid: 101, + type: "qemu", +}; + +describe("getProxmoxSource", () => { + it("reads the source from an object or a JSON string", () => { + expect(getProxmoxSource({ source })).toEqual(source); + expect(getProxmoxSource(JSON.stringify({ source }))).toEqual(source); + }); + + it("refuses a malformed source", () => { + expect(getProxmoxSource(null)).toBeNull(); + expect(getProxmoxSource({ source: { ...source, vmid: "101" } })).toBeNull(); + expect(getProxmoxSource({ source: { ...source, type: "vm" } })).toBeNull(); + }); +}); + +describe("indexImportedGuests", () => { + // The host row carries no proxmoxConfig since 2.9.0; the source only + // exists in the plugin's host settings. + const hosts = [ + { id: 10, name: "guest-a" }, + { id: 11, name: "guest-b" }, + { id: 12, name: "manual" }, + ]; + + it("matches hosts to guests through their settings", () => { + const index = indexImportedGuests( + hosts, + [ + { hostId: 10, value: { source, lastSyncAt: "x" } }, + { hostId: 11, value: { source: { ...source, sourceHostId: 2 } } }, + ], + 1, + ); + expect([...index.keys()]).toEqual([proxmoxSourceKey(source as never)]); + const entry = index.get("1:pve:qemu:101")!; + expect(entry.host.id).toBe(10); + expect(entry.config.lastSyncAt).toBe("x"); + }); + + it("ignores settings for hosts the user does not own", () => { + const index = indexImportedGuests( + hosts, + [{ hostId: 99, value: { source } }], + 1, + ); + expect(index.size).toBe(0); + }); +}); diff --git a/src/backend/hosts/cloudflare-websocket.ts b/src/backend/hosts/cloudflare-websocket.ts deleted file mode 100644 index 877792408..000000000 --- a/src/backend/hosts/cloudflare-websocket.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Duplex } from "node:stream"; -import type { RawData, WebSocket } from "ws"; - -export function waitForWebSocketOpen( - socket: WebSocket, - timeoutMs: number, -): Promise { - return new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timer); - socket.off("open", onOpen); - socket.off("error", onError); - }; - const onOpen = () => { - cleanup(); - resolve(); - }; - const onError = (error: Error) => { - cleanup(); - socket.terminate(); - reject(error); - }; - const timer = setTimeout(() => { - cleanup(); - socket.terminate(); - reject(new Error("Cloudflare tunnel timeout")); - }, timeoutMs); - - socket.once("open", onOpen); - socket.once("error", onError); - }); -} - -export function createWebSocketDuplex(socket: WebSocket): Duplex { - const duplex = new Duplex({ - read() {}, - write(chunk, _encoding, callback) { - try { - socket.send(chunk, (error) => callback(error || undefined)); - } catch (error) { - callback(error instanceof Error ? error : new Error(String(error))); - } - }, - destroy(error, callback) { - cleanup(); - socket.terminate(); - callback(error); - }, - }); - - const onMessage = (data: RawData) => duplex.push(data); - const onClose = () => duplex.destroy(); - const onError = () => duplex.destroy(); - const cleanup = () => { - socket.off("message", onMessage); - socket.off("close", onClose); - socket.off("error", onError); - }; - - socket.on("message", onMessage); - socket.on("close", onClose); - socket.on("error", onError); - return duplex; -} diff --git a/src/backend/hosts/connect/transport.ts b/src/backend/hosts/connect/transport.ts index e94cd88d8..31d9400c4 100644 --- a/src/backend/hosts/connect/transport.ts +++ b/src/backend/hosts/connect/transport.ts @@ -1,6 +1,6 @@ /** - * How the bytes get to a host: port knocking, a Cloudflare Access tunnel, a - * jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and + * How the bytes get to a host: port knocking, a jump host chain or a SOCKS5 + * proxy, in that order. Sets config.sock and * returns the jump client so the caller can close it with the connection. */ @@ -39,7 +39,7 @@ function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null { class SshTransportError extends Error { constructor( message: string, - readonly stage: "cloudflare" | "jump-host" | "jump-forward" | "proxy", + readonly stage: "jump-host" | "jump-forward" | "proxy", options?: { cause?: unknown }, ) { super(message, options); @@ -50,8 +50,6 @@ class SshTransportError extends Error { export interface OpenTransportOptions { /** Knock before connecting when the host has a sequence. Default true. */ portKnock?: boolean; - /** Use terminalConfig.cfAccessClient* when present. Default true. */ - cloudflare?: boolean; /** Resolve DNS up front for a direct connection. Default true. */ resolveDns?: boolean; log?: SshAuthLog; @@ -59,7 +57,7 @@ export interface OpenTransportOptions { export interface OpenedTransport { jumpClient: Client | null; - via: "direct" | "proxy" | "jump" | "cloudflare"; + via: "direct" | "proxy" | "jump"; } function forwardThrough( @@ -94,8 +92,6 @@ export async function openSshTransport( config: MutableConnectConfig, options: OpenTransportOptions = {}, ): Promise { - const log = options.log ?? (() => {}); - if ( options.portKnock !== false && Array.isArray(host.portKnockSequence) && @@ -113,42 +109,6 @@ export async function openSshTransport( let via: OpenedTransport["via"] = "direct"; - const terminalConfig = (host.terminalConfig ?? {}) as Record; - if ( - options.cloudflare !== false && - terminalConfig.cfAccessClientId && - terminalConfig.cfAccessClientSecret - ) { - try { - const WebSocket = (await import("ws")).default; - const { createWebSocketDuplex, waitForWebSocketOpen } = - await import("../cloudflare-websocket.js"); - const cfHostname = (terminalConfig.cfTunnelHostname as string) || host.ip; - const cfWs = new WebSocket( - `wss://${cfHostname}/cdn-cgi/access/ssh-connect`, - { - headers: { - "CF-Access-Client-Id": terminalConfig.cfAccessClientId as string, - "CF-Access-Client-Secret": - terminalConfig.cfAccessClientSecret as string, - }, - }, - ); - await waitForWebSocketOpen(cfWs, 30000); - config.sock = createWebSocketDuplex( - cfWs, - ) as unknown as typeof config.sock; - via = "cloudflare"; - log("info", "Connected via Cloudflare Tunnel"); - } catch (error) { - throw new SshTransportError( - "Cloudflare tunnel connection failed: " + getErrorMessage(error), - "cloudflare", - { cause: error }, - ); - } - } - const jumpUserId = host.userId || ""; if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) { const jumpClient = await createJumpHostChain(host.jumpHosts, jumpUserId); diff --git a/src/backend/sync/entities.ts b/src/backend/sync/entities.ts index f979b186f..a70b2ba4e 100644 --- a/src/backend/sync/entities.ts +++ b/src/backend/sync/entities.ts @@ -58,6 +58,7 @@ const HOST_REFERENCES = [ }, { field: "jumpHosts[].hostId", entityType: "hosts" }, { field: "quickActions[].snippetId", entityType: "commandSnippet" }, + { field: "terminalConfig.startupSnippetId", entityType: "commandSnippet" }, ] as const; /** Columns that only mean something on the device that holds them. */ diff --git a/src/backend/tests/hosts/cloudflare-websocket.test.ts b/src/backend/tests/hosts/cloudflare-websocket.test.ts deleted file mode 100644 index 48875adb6..000000000 --- a/src/backend/tests/hosts/cloudflare-websocket.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { EventEmitter } from "node:events"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { WebSocket } from "ws"; -import { - createWebSocketDuplex, - waitForWebSocketOpen, -} from "../../hosts/cloudflare-websocket.js"; - -function setupSocket() { - const socket = new EventEmitter() as EventEmitter & { - terminate: ReturnType; - send: ReturnType; - }; - socket.terminate = vi.fn(); - socket.send = vi.fn(); - return socket as unknown as WebSocket; -} - -describe("waitForWebSocketOpen", () => { - afterEach(() => vi.useRealTimers()); - - it("clears listeners and leaves an opened socket alive", async () => { - vi.useFakeTimers(); - const socket = setupSocket(); - const result = waitForWebSocketOpen(socket, 30_000); - - socket.emit("open"); - - await expect(result).resolves.toBeUndefined(); - expect(socket.terminate).not.toHaveBeenCalled(); - expect(socket.listenerCount("open")).toBe(0); - expect(socket.listenerCount("error")).toBe(0); - expect(vi.getTimerCount()).toBe(0); - }); - - it("terminates a socket that does not open before the deadline", async () => { - vi.useFakeTimers(); - const socket = setupSocket(); - const result = waitForWebSocketOpen(socket, 30_000); - const rejection = expect(result).rejects.toThrow( - "Cloudflare tunnel timeout", - ); - - await vi.advanceTimersByTimeAsync(30_000); - - await rejection; - expect(socket.terminate).toHaveBeenCalledOnce(); - expect(socket.listenerCount("open")).toBe(0); - expect(socket.listenerCount("error")).toBe(0); - }); - - it("terminates the socket when the SSH duplex is destroyed", () => { - const socket = setupSocket(); - const duplex = createWebSocketDuplex(socket); - - duplex.destroy(); - - expect(socket.terminate).toHaveBeenCalledOnce(); - expect(socket.listenerCount("message")).toBe(0); - expect(socket.listenerCount("close")).toBe(0); - expect(socket.listenerCount("error")).toBe(0); - }); - - it("handles established socket errors without an unhandled event", () => { - const socket = setupSocket(); - const duplex = createWebSocketDuplex(socket); - - socket.emit("error", new Error("connection lost")); - - expect(duplex.destroyed).toBe(true); - expect(socket.terminate).toHaveBeenCalledOnce(); - expect(socket.listenerCount("error")).toBe(0); - }); -}); diff --git a/src/backend/hosts/host-session-status.test.ts b/src/backend/tests/hosts/host-session-status.test.ts similarity index 93% rename from src/backend/hosts/host-session-status.test.ts rename to src/backend/tests/hosts/host-session-status.test.ts index a7fa559d2..d541d7a76 100644 --- a/src/backend/hosts/host-session-status.test.ts +++ b/src/backend/tests/hosts/host-session-status.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { HostSessionStatus } from "./host-session-status.js"; +import { HostSessionStatus } from "../../hosts/host-session-status.js"; describe("HostSessionStatus", () => { it("reports only the first connection and last disconnection per host", () => { diff --git a/src/backend/hosts/jump-host-proxy.test.ts b/src/backend/tests/hosts/jump-host-proxy.test.ts similarity index 94% rename from src/backend/hosts/jump-host-proxy.test.ts rename to src/backend/tests/hosts/jump-host-proxy.test.ts index 877998e69..90f83dfb7 100644 --- a/src/backend/hosts/jump-host-proxy.test.ts +++ b/src/backend/tests/hosts/jump-host-proxy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; +import { getJumpHostSocks5Config } from "../../hosts/jump-host-proxy.js"; describe("getJumpHostSocks5Config", () => { it("uses the first jump host proxy settings", () => { diff --git a/src/backend/hosts/ssh-keepalive.test.ts b/src/backend/tests/hosts/ssh-keepalive.test.ts similarity index 91% rename from src/backend/hosts/ssh-keepalive.test.ts rename to src/backend/tests/hosts/ssh-keepalive.test.ts index cd7733a78..97eee9e38 100644 --- a/src/backend/hosts/ssh-keepalive.test.ts +++ b/src/backend/tests/hosts/ssh-keepalive.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveSshKeepalive } from "./ssh-keepalive.js"; +import { resolveSshKeepalive } from "../../hosts/ssh-keepalive.js"; describe("resolveSshKeepalive", () => { it("preserves zero to disable SSH keepalives", () => { diff --git a/src/backend/tests/plugins/sync-registry.test.ts b/src/backend/tests/plugins/sync-registry.test.ts index c339fd6b4..f8e722397 100644 --- a/src/backend/tests/plugins/sync-registry.test.ts +++ b/src/backend/tests/plugins/sync-registry.test.ts @@ -197,6 +197,16 @@ describe("core sync entities", () => { ); }); + it("translates every snippet a host points at", () => { + const fields = (getEntity("hosts")?.references ?? []) + .filter((reference) => reference.entityType === "commandSnippet") + .map((reference) => reference.field); + expect(fields).toEqual([ + "quickActions[].snippetId", + "terminalConfig.startupSnippetId", + ]); + }); + it("encrypts host and credential secrets", () => { expect(getEntity("hosts")?.encryptedFields).toContain("password"); expect(getEntity("sshCredentials")?.encryptedFields).toContain( diff --git a/src/backend/tests/sync/wire.test.ts b/src/backend/tests/sync/wire.test.ts index fcf735748..d42b87a4b 100644 --- a/src/backend/tests/sync/wire.test.ts +++ b/src/backend/tests/sync/wire.test.ts @@ -35,6 +35,7 @@ beforeEach(() => { references: [ { field: "parentId", syncField: "parentSyncId", entityType: "things" }, { field: "hops[].thingId", entityType: "things" }, + { field: "config.startId", entityType: "things" }, { field: "bindings[].action.snippetId", entityType: "things", @@ -111,6 +112,29 @@ describe("serializing references", () => { expect(back.bindings).toEqual([{ action: { snippetId: "12" } }]); }); + it("rewrites an id nested in a JSON object column", async () => { + const wire = await serializeReferences( + entity("things"), + { config: JSON.stringify({ theme: "dark", startId: 5 }) }, + toSync, + ); + expect(JSON.parse(wire.config as string)).toEqual({ + theme: "dark", + startId: "sync-5", + }); + + const back = await deserializeReferences( + entity("things"), + wire, + toId, + null, + ); + expect(JSON.parse(back.config as string)).toEqual({ + theme: "dark", + startId: 5, + }); + }); + it("keeps the current value when a referenced row is not here", async () => { const back = await deserializeReferences( entity("things"), diff --git a/src/ui/lib/default-keybindings.ts b/src/ui/lib/default-keybindings.ts index e4c7ec6aa..d40e80dae 100644 --- a/src/ui/lib/default-keybindings.ts +++ b/src/ui/lib/default-keybindings.ts @@ -3,7 +3,7 @@ import type { KeyCombo } from "@/types/keybindings"; export interface DefaultKeybindingInfo { id: string; combo: KeyCombo; - description: string; + descriptionKey: string; } export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [ @@ -17,7 +17,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [ shift: false, meta: false, }, - description: "Copy selection (Ctrl+C, only when text is selected)", + descriptionKey: "newUi.sidebar.keybindings.builtIn.copyCtrlC", }, { id: "default-copy-ctrlshiftc", @@ -29,7 +29,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [ shift: true, meta: false, }, - description: "Copy selection (Ctrl+Shift+C)", + descriptionKey: "newUi.sidebar.keybindings.builtIn.copyCtrlShiftC", }, { id: "default-copy-cmdc", @@ -41,7 +41,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [ shift: false, meta: true, }, - description: "Copy selection (Cmd+C)", + descriptionKey: "newUi.sidebar.keybindings.builtIn.copyCmdC", }, { id: "default-paste-ctrlshiftv", @@ -53,7 +53,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [ shift: true, meta: false, }, - description: "Paste from clipboard (Ctrl+Shift+V)", + descriptionKey: "newUi.sidebar.keybindings.builtIn.pasteCtrlShiftV", }, { id: "default-ctrlaltw", @@ -65,7 +65,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [ shift: false, meta: false, }, - description: "Send Ctrl+W to shell (blocked from closing browser tab)", + descriptionKey: "newUi.sidebar.keybindings.builtIn.ctrlW", }, { id: "default-ctrlaltt", @@ -77,7 +77,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [ shift: false, meta: false, }, - description: "Send Ctrl+T to shell (blocked from opening browser tab)", + descriptionKey: "newUi.sidebar.keybindings.builtIn.ctrlT", }, { id: "default-ctrlaltn", @@ -89,7 +89,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [ shift: false, meta: false, }, - description: "Send Ctrl+N to shell (blocked from opening browser window)", + descriptionKey: "newUi.sidebar.keybindings.builtIn.ctrlN", }, { id: "default-ctrlaltq", @@ -101,6 +101,6 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [ shift: false, meta: false, }, - description: "Send Ctrl+Q to shell (blocked from quitting browser)", + descriptionKey: "newUi.sidebar.keybindings.builtIn.ctrlQ", }, ]; diff --git a/src/ui/lib/terminal-themes.ts b/src/ui/lib/terminal-themes.ts index 6a243c2b9..678af4ff5 100644 --- a/src/ui/lib/terminal-themes.ts +++ b/src/ui/lib/terminal-themes.ts @@ -815,22 +815,26 @@ export function resolveTerminalFontFamily(fontFamily?: string): string { } export const CURSOR_STYLES = [ - { value: "block", label: "Block" }, - { value: "underline", label: "Underline" }, - { value: "bar", label: "Bar" }, + { value: "block", label: "Block", labelKey: "hosts.cursorStyleBlock" }, + { + value: "underline", + label: "Underline", + labelKey: "hosts.cursorStyleUnderline", + }, + { value: "bar", label: "Bar", labelKey: "hosts.cursorStyleBar" }, ] as const; export const BELL_STYLES = [ - { value: "none", label: "None" }, - { value: "sound", label: "Sound" }, - { value: "visual", label: "Visual" }, - { value: "both", label: "Both" }, + { value: "none", label: "None", labelKey: "hosts.bellStyleNone" }, + { value: "sound", label: "Sound", labelKey: "hosts.bellStyleSound" }, + { value: "visual", label: "Visual", labelKey: "hosts.bellStyleVisual" }, + { value: "both", label: "Both", labelKey: "hosts.bellStyleBoth" }, ] as const; export const FAST_SCROLL_MODIFIERS = [ - { value: "alt", label: "Alt" }, - { value: "ctrl", label: "Ctrl" }, - { value: "shift", label: "Shift" }, + { value: "alt", label: "Alt", labelKey: "hosts.fastScrollModifierAlt" }, + { value: "ctrl", label: "Ctrl", labelKey: "hosts.fastScrollModifierCtrl" }, + { value: "shift", label: "Shift", labelKey: "hosts.fastScrollModifierShift" }, ] as const; export const DEFAULT_TERMINAL_CONFIG = { diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index bac343575..f7b1a52ab 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1008,7 +1008,19 @@ "localOnly": "Keep on this device only", "localOnlyDesc": "This host is not sent to the server and does not appear on your other devices.", "folderLocalOnly": "Keep on this device only", - "folderLocalOnlyDesc": "This folder and every host in it stay on this device and are not sent to the server." + "folderLocalOnlyDesc": "This folder and every host in it stay on this device and are not sent to the server.", + "cursorStyleBlock": "Block", + "cursorStyleUnderline": "Underline", + "cursorStyleBar": "Bar", + "bellStyleNone": "None", + "bellStyleSound": "Sound", + "bellStyleVisual": "Visual", + "bellStyleBoth": "Both", + "fastScrollModifierAlt": "Alt", + "fastScrollModifierCtrl": "Ctrl", + "fastScrollModifierShift": "Shift", + "backspaceModeNormal": "Normal (DEL)", + "backspaceModeControlH": "Control-H (BS)" }, "commandPalette": { "searchPlaceholder": "Search hosts, commands, or settings...", @@ -2008,7 +2020,17 @@ "snippetLabel": "Snippet", "selectSnippetPlaceholder": "Select a snippet", "orphanedSnippetWarning": "snippet not found", - "clipboardPermissionNote": "May prompt for clipboard permission on first use in some browsers." + "clipboardPermissionNote": "May prompt for clipboard permission on first use in some browsers.", + "builtIn": { + "copyCtrlC": "Copy selection (Ctrl+C, only when text is selected)", + "copyCtrlShiftC": "Copy selection (Ctrl+Shift+C)", + "copyCmdC": "Copy selection (Cmd+C)", + "pasteCtrlShiftV": "Paste from clipboard (Ctrl+Shift+V)", + "ctrlW": "Send Ctrl+W to shell (blocked from closing browser tab)", + "ctrlT": "Send Ctrl+T to shell (blocked from opening browser tab)", + "ctrlN": "Send Ctrl+N to shell (blocked from opening browser window)", + "ctrlQ": "Send Ctrl+Q to shell (blocked from quitting browser)" + } }, "connectionDefaults": { "title": "Connection Defaults", diff --git a/src/ui/sidebar/ConnectionDefaultsSettings.tsx b/src/ui/sidebar/ConnectionDefaultsSettings.tsx index b1ceaa627..b7d92c80e 100644 --- a/src/ui/sidebar/ConnectionDefaultsSettings.tsx +++ b/src/ui/sidebar/ConnectionDefaultsSettings.tsx @@ -278,7 +278,7 @@ export function ConnectionDefaultsSettings() { {CURSOR_STYLES.map((style) => ( ))} diff --git a/src/ui/sidebar/HostTerminalSettings.tsx b/src/ui/sidebar/HostTerminalSettings.tsx index 3c6716b21..6b9b09253 100644 --- a/src/ui/sidebar/HostTerminalSettings.tsx +++ b/src/ui/sidebar/HostTerminalSettings.tsx @@ -270,7 +270,7 @@ export function HostTerminalSettings({ > {CURSOR_STYLES.map((s) => ( ))} @@ -322,7 +322,7 @@ export function HostTerminalSettings({ > {BELL_STYLES.map((b) => ( ))} @@ -341,8 +341,12 @@ export function HostTerminalSettings({ } className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" > - - + + @@ -850,7 +854,7 @@ export function HostTerminalSettings({ > {FAST_SCROLL_MODIFIERS.map((m) => ( ))} diff --git a/src/ui/sidebar/KeybindingsDialog.tsx b/src/ui/sidebar/KeybindingsDialog.tsx index 4fd394aea..466a0a3b9 100644 --- a/src/ui/sidebar/KeybindingsDialog.tsx +++ b/src/ui/sidebar/KeybindingsDialog.tsx @@ -299,7 +299,7 @@ export function KeybindingsDialog({ : formatCombo(def.combo)} - {def.description} + {t(def.descriptionKey)}
diff --git a/src/ui/sidebar/tree/HostItem/HostItem.test.ts b/src/ui/tests/sidebar/tree/HostItem/host-status-tooltip.test.ts similarity index 100% rename from src/ui/sidebar/tree/HostItem/HostItem.test.ts rename to src/ui/tests/sidebar/tree/HostItem/host-status-tooltip.test.ts