diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index 5ba49188..90622cab 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -35,6 +35,7 @@ import type { SessionRestoreMode, SessionRowColorMode, ShortcutBindings, + TerminalLinkOpenMode, StickyNote, TerminalBorderSkinId, ZoomSensitivity @@ -101,6 +102,7 @@ const RADIAL_LAUNCHER_ITEM_SET = new Set(RADIAL_LAUNCHER_I const EDGE_PAN_SPEEDS = new Set(["slow", "normal", "fast"]); const ZOOM_SENSITIVITIES = new Set(["slow", "normal", "fast"]); const FOCUS_ACTIVATIONS = new Set(["off", "single", "double"]); +const TERMINAL_LINK_OPEN_MODES = new Set(["canvas", "external", "ask"]); const CANVAS_WHEEL_CAPTURE_MODES = new Set(["off", "always", "key"]); const CANVAS_OVERLAY_PLACEMENTS = new Set([ "top-left", @@ -187,6 +189,7 @@ export class SettingsStore { || !("agentChatHistorySearchSessions" in source) || !("agentControlEnabled" in source) || !("sessionRestoreMode" in source) + || !("terminalLinkOpenMode" in source) || !("persistCanvasRegions" in source) || !("persistStickyNotes" in source) || !("canvasRegions" in source) @@ -374,6 +377,7 @@ function createDefaults(systemLocale: string, platform: string): AppSettings { appSkin: "classic", snapToGrid: true, copyOnSelect: false, + terminalLinkOpenMode: "ask", invertTerminalWheel: true, invertCanvasWheel: false, edgePan: false, @@ -607,6 +611,9 @@ export function normalizeSettings( : fallback.appSkin, snapToGrid: typeof source.snapToGrid === "boolean" ? source.snapToGrid : fallback.snapToGrid, copyOnSelect: typeof source.copyOnSelect === "boolean" ? source.copyOnSelect : fallback.copyOnSelect, + terminalLinkOpenMode: TERMINAL_LINK_OPEN_MODES.has(source.terminalLinkOpenMode as TerminalLinkOpenMode) + ? source.terminalLinkOpenMode as TerminalLinkOpenMode + : fallback.terminalLinkOpenMode, invertTerminalWheel: typeof source.invertTerminalWheel === "boolean" ? source.invertTerminalWheel : fallback.invertTerminalWheel, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6d36491a..377a7cc4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -121,6 +121,7 @@ const FALLBACK_SETTINGS: AppSettings = { appSkin: "classic", snapToGrid: true, copyOnSelect: false, + terminalLinkOpenMode: "ask", invertTerminalWheel: true, invertCanvasWheel: false, edgePan: false, @@ -889,6 +890,21 @@ export function App(): React.JSX.Element { }); }, [openBrowser, settings.locale, showToast]); + const openTerminalUrl = useCallback(async (url: string): Promise => { + try { + const safeUrl = normalizeExternalUrl(url); + if (settings.terminalLinkOpenMode === "canvas") { + await openBrowser(safeUrl); + } else if (settings.terminalLinkOpenMode === "external") { + await window.canvasTTY.external.openUrl(safeUrl); + } else { + setPendingTerminalUrl(safeUrl); + } + } catch (error) { + showToast(error instanceof Error ? error.message : t(settings.locale, "browserActionFailed")); + } + }, [openBrowser, settings.locale, settings.terminalLinkOpenMode, showToast]); + const closeBrowser = useCallback(async (): Promise => { try { const browserApi = window.canvasTTY.browser; @@ -1353,13 +1369,7 @@ export function App(): React.JSX.Element { onOpenAgent={openAgent} onOpenTerminal={(position) => void openTerminal(position)} onOpenBrowser={openBrowserFromUi} - onOpenTerminalUrl={(url) => { - try { - setPendingTerminalUrl(normalizeExternalUrl(url)); - } catch (error) { - showToast(error instanceof Error ? error.message : t(settings.locale, "browserActionFailed")); - } - }} + onOpenTerminalUrl={(url) => void openTerminalUrl(url)} onRequestMedia={requestMedia} onRemoveMedia={removeMedia} onHomeLayoutChange={changeHomeLayout} diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index f03f630a..381af83b 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -43,6 +43,7 @@ import type { SessionStatus, ShortcutAction, TerminalBorderSkinListItem, + TerminalLinkOpenMode, TerminalBorderSkinId, UpdaterState, ZoomSensitivity @@ -1143,6 +1144,17 @@ export function SettingsPanel({ onChange={(value) => void onChange({ copyOnSelect: value === "on" })} /> + + void onChange({ terminalLinkOpenMode: value as TerminalLinkOpenMode })} + /> + = { terminalWheelDirection: "Terminal wheel direction", copyOnSelect: "Copy on selection", copyOnSelectDescription: "Copies text to the clipboard after mouse selection in every agent and terminal. Hold Shift while selecting if the application captures the mouse.", + terminalLinkOpenMode: "Open links", + terminalLinkOpenModeDescription: "Chooses the browser for links from the terminal and agent output.", + terminalLinkOpenExternal: "External browser", + terminalLinkOpenAsk: "Ask", canvasWheelDirection: "Canvas wheel direction", wheelNormal: "Normal", renameWindow: "Rename window", diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 63367199..587ab549 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -70,6 +70,7 @@ export type CanvasOverlayPlacement = "top-left" | "top-right" | "bottom-left" | export type MinimapInteractionMode = "click" | "drag"; export type BrowserViewportSurface = "native" | "placeholder" | "hidden"; export type FocusActivation = "off" | "single" | "double"; +export type TerminalLinkOpenMode = "canvas" | "external" | "ask"; export type ShortcutAction = keyof ShortcutBindings; export type KeyboardPreset = "macos" | "windows" | "linux" | "custom"; export type RadialLauncherActionId = "note" | "browser" | "settings"; @@ -290,6 +291,7 @@ export interface AppSettings { appSkin: AppSkinId; snapToGrid: boolean; copyOnSelect: boolean; + terminalLinkOpenMode: TerminalLinkOpenMode; invertTerminalWheel: boolean; invertCanvasWheel: boolean; edgePan: boolean; diff --git a/tests/terminal-link-settings.test.mjs b/tests/terminal-link-settings.test.mjs new file mode 100644 index 00000000..ac3148d0 --- /dev/null +++ b/tests/terminal-link-settings.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { normalizeSettings, SettingsStore } from "../src/main/services/SettingsStore.ts"; + +const modes = ["canvas", "external", "ask"]; +const invalidModes = [undefined, null, false, true, 0, 1, "", "system", "Canvas", "external ", [], {}, ["canvas"]]; + +async function createStore(t) { + const dir = await mkdtemp(join(tmpdir(), "canvastty-terminal-links-")); + t.after(() => rm(dir, { recursive: true, force: true })); + return { dir, path: join(dir, "settings.json"), store: new SettingsStore(dir, "en") }; +} + +test("new settings default terminal links to ask and persist that default", async (t) => { + const { path, store } = await createStore(t); + assert.equal(store.get().terminalLinkOpenMode, "ask"); + assert.equal((await store.load()).terminalLinkOpenMode, "ask"); + assert.equal(JSON.parse(await readFile(path, "utf8")).terminalLinkOpenMode, "ask"); +}); + +test("terminal link normalization accepts only supported modes and preserves the fallback", () => { + const defaults = new SettingsStore("unused-settings-test-path", "en").get(); + for (const fallbackMode of modes) { + const fallback = { ...defaults, terminalLinkOpenMode: fallbackMode }; + for (const mode of modes) { + assert.equal(normalizeSettings({ terminalLinkOpenMode: mode }, fallback).terminalLinkOpenMode, mode); + } + assert.equal(normalizeSettings({}, fallback).terminalLinkOpenMode, fallbackMode); + for (const mode of invalidModes) { + assert.equal( + normalizeSettings({ terminalLinkOpenMode: mode }, fallback).terminalLinkOpenMode, + fallbackMode, + `invalid value ${JSON.stringify(mode)} must preserve ${fallbackMode}` + ); + } + } +}); + +for (const mode of modes) { + test(`terminal link mode ${mode} survives persistence, reload, and unrelated updates`, async (t) => { + const { dir, path, store } = await createStore(t); + await store.load(); + assert.equal((await store.update({ terminalLinkOpenMode: mode })).terminalLinkOpenMode, mode); + assert.equal(JSON.parse(await readFile(path, "utf8")).terminalLinkOpenMode, mode); + + const reloaded = new SettingsStore(dir, "en"); + assert.equal((await reloaded.load()).terminalLinkOpenMode, mode); + assert.equal((await reloaded.update({ copyOnSelect: true })).terminalLinkOpenMode, mode); + const restored = await new SettingsStore(dir, "en").load(); + assert.equal(restored.terminalLinkOpenMode, mode); + assert.equal(restored.copyOnSelect, true); + }); +} + +test("invalid terminal link updates preserve the previous setting in memory and on disk", async (t) => { + const { dir, path, store } = await createStore(t); + await store.update({ terminalLinkOpenMode: "external" }); + for (const mode of invalidModes) { + assert.equal((await store.update({ terminalLinkOpenMode: mode })).terminalLinkOpenMode, "external"); + assert.equal(store.get().terminalLinkOpenMode, "external"); + assert.equal(JSON.parse(await readFile(path, "utf8")).terminalLinkOpenMode, "external"); + } + assert.equal((await new SettingsStore(dir, "en").load()).terminalLinkOpenMode, "external"); +}); + +test("current-version settings missing terminalLinkOpenMode migrate and persist ask", async (t) => { + const { dir, path, store } = await createStore(t); + await store.update({ locale: "ru", copyOnSelect: true, terminalLinkOpenMode: "canvas" }); + const oldSettings = JSON.parse(await readFile(path, "utf8")); + delete oldSettings.terminalLinkOpenMode; + await writeFile(path, JSON.stringify(oldSettings), "utf8"); + + const migrated = await new SettingsStore(dir, "en").load(); + assert.equal(migrated.terminalLinkOpenMode, "ask"); + assert.equal(migrated.locale, "ru"); + assert.equal(migrated.copyOnSelect, true); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { ...oldSettings, terminalLinkOpenMode: "ask" }); + assert.equal((await new SettingsStore(dir, "en").load()).terminalLinkOpenMode, "ask"); +}); + +test("invalid terminal link values in persisted settings load as ask", async (t) => { + const { dir, path, store } = await createStore(t); + await store.load(); + const saved = JSON.parse(await readFile(path, "utf8")); + for (const mode of invalidModes.filter((value) => value !== undefined)) { + await writeFile(path, JSON.stringify({ ...saved, terminalLinkOpenMode: mode }), "utf8"); + assert.equal( + (await new SettingsStore(dir, "en").load()).terminalLinkOpenMode, + "ask", + `persisted invalid value ${JSON.stringify(mode)} must fall back to ask` + ); + } +}); diff --git a/tests/terminal-links.test.mjs b/tests/terminal-links.test.mjs index f352ed44..89b177cc 100644 --- a/tests/terminal-links.test.mjs +++ b/tests/terminal-links.test.mjs @@ -11,7 +11,7 @@ const contractsPath = new URL("../src/shared/contracts.ts", import.meta.url); const preloadPath = new URL("../src/preload/index.ts", import.meta.url); const ipcPath = new URL("../src/main/ipc/registerIpc.ts", import.meta.url); -test("terminal HTTP(S) links open a Canvas or system-browser chooser", async () => { +test("terminal HTTP(S) links use the preferred browser or the chooser", async () => { const [terminal, dialog, workspace, app] = await Promise.all([ readFile(terminalCardPath, "utf8"), readFile(dialogPath, "utf8"), @@ -26,7 +26,14 @@ test("terminal HTTP(S) links open a Canvas or system-browser chooser", async () // The card gets a stable callback that calls the workspace's latest onOpenTerminalUrl. assert.match(workspace, /openUrl: onOpenTerminalUrl/); assert.match(workspace, /onOpenUrl: \(url: string\) => latest\.current!\.openUrl\(url\)/); - assert.match(app, /onOpenTerminalUrl=\{\(url\) => \{[\s\S]*?normalizeExternalUrl\(url\)[\s\S]*?showToast/); + assert.match(app, /onOpenTerminalUrl=\{\(url\) => void openTerminalUrl\(url\)\}/); + const handler = app.match(/const openTerminalUrl = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/)?.[0]; + assert.ok(handler); + assert.match(handler, /const safeUrl = normalizeExternalUrl\(url\)/); + assert.match(handler, /terminalLinkOpenMode === "canvas"\)\s*\{\s*await openBrowser\(safeUrl\)/); + assert.match(handler, /terminalLinkOpenMode === "external"\)\s*\{\s*await window\.canvasTTY\.external\.openUrl\(safeUrl\)/); + assert.match(handler, /else\s*\{\s*setPendingTerminalUrl\(safeUrl\)/); + assert.match(handler, /catch \(error\)\s*\{\s*showToast/); assert.match(dialog, /onOpenCanvas\(url\)/); assert.match(dialog, /onOpenExternal\(url\)/); assert.match(app, /