Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/main/services/SettingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
SessionRestoreMode,
SessionRowColorMode,
ShortcutBindings,
TerminalLinkOpenMode,
StickyNote,
TerminalBorderSkinId,
ZoomSensitivity
Expand Down Expand Up @@ -101,6 +102,7 @@ const RADIAL_LAUNCHER_ITEM_SET = new Set<RadialLauncherItemId>(RADIAL_LAUNCHER_I
const EDGE_PAN_SPEEDS = new Set<EdgePanSpeed>(["slow", "normal", "fast"]);
const ZOOM_SENSITIVITIES = new Set<ZoomSensitivity>(["slow", "normal", "fast"]);
const FOCUS_ACTIVATIONS = new Set<FocusActivation>(["off", "single", "double"]);
const TERMINAL_LINK_OPEN_MODES = new Set<TerminalLinkOpenMode>(["canvas", "external", "ask"]);
const CANVAS_WHEEL_CAPTURE_MODES = new Set<CanvasWheelCaptureMode>(["off", "always", "key"]);
const CANVAS_OVERLAY_PLACEMENTS = new Set<CanvasOverlayPlacement>([
"top-left",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 17 additions & 7 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ const FALLBACK_SETTINGS: AppSettings = {
appSkin: "classic",
snapToGrid: true,
copyOnSelect: false,
terminalLinkOpenMode: "ask",
invertTerminalWheel: true,
invertCanvasWheel: false,
edgePan: false,
Expand Down Expand Up @@ -889,6 +890,21 @@ export function App(): React.JSX.Element {
});
}, [openBrowser, settings.locale, showToast]);

const openTerminalUrl = useCallback(async (url: string): Promise<void> => {
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<void> => {
try {
const browserApi = window.canvasTTY.browser;
Expand Down Expand Up @@ -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}
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/src/features/settings/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type {
SessionStatus,
ShortcutAction,
TerminalBorderSkinListItem,
TerminalLinkOpenMode,
TerminalBorderSkinId,
UpdaterState,
ZoomSensitivity
Expand Down Expand Up @@ -1143,6 +1144,17 @@ export function SettingsPanel({
onChange={(value) => void onChange({ copyOnSelect: value === "on" })}
/>
</SettingGroup>
<SettingGroup label={t(locale, "terminalLinkOpenMode")} description={t(locale, "terminalLinkOpenModeDescription")}>
<Segmented
value={settings.terminalLinkOpenMode}
options={[
["canvas", "CanvasTTY"],
["external", t(locale, "terminalLinkOpenExternal")],
["ask", t(locale, "terminalLinkOpenAsk")]
]}
onChange={(value) => void onChange({ terminalLinkOpenMode: value as TerminalLinkOpenMode })}
/>
</SettingGroup>
<SettingGroup label={t(locale, "terminalWheelDirection")}>
<Segmented
value={settings.invertTerminalWheel ? "inverted" : "normal"}
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,10 @@ const ru = {
terminalWheelDirection: "Направление колеса терминала",
copyOnSelect: "Копировать при выделении",
copyOnSelectDescription: "Копирует текст в буфер обмена после выделения мышью во всех агентах и терминале. Если приложение перехватывает мышь, удерживайте Shift при выделении.",
terminalLinkOpenMode: "Открывать ссылки",
terminalLinkOpenModeDescription: "Выбирает браузер для ссылок из терминала и вывода агентов.",
terminalLinkOpenExternal: "Внешний браузер",
terminalLinkOpenAsk: "Спрашивать",
canvasWheelDirection: "Направление колеса канваса",
wheelNormal: "Обычное",
wheelInverted: "Обратное",
Expand Down Expand Up @@ -1300,6 +1304,10 @@ const en: Record<keyof typeof ru, string> = {
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",
Expand Down
2 changes: 2 additions & 0 deletions src/shared/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -290,6 +291,7 @@ export interface AppSettings {
appSkin: AppSkinId;
snapToGrid: boolean;
copyOnSelect: boolean;
terminalLinkOpenMode: TerminalLinkOpenMode;
invertTerminalWheel: boolean;
invertCanvasWheel: boolean;
edgePan: boolean;
Expand Down
96 changes: 96 additions & 0 deletions tests/terminal-link-settings.test.mjs
Original file line number Diff line number Diff line change
@@ -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`
);
}
});
11 changes: 9 additions & 2 deletions tests/terminal-links.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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, /<TerminalLinkDialog/);
Expand Down
Loading