diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index f1996e0c..027712de 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -241,6 +241,8 @@ pub struct TerminalSettings { pub shell_integration: bool, pub highlight_hyperlinks: bool, pub option_as_meta: bool, + /// Escape moves focus to the editor instead of reaching the shell. + pub escape_to_editor: bool, /// `block` | `underline` | `bar`. pub cursor_shape: String, pub cursor_blink: bool, @@ -249,6 +251,10 @@ pub struct TerminalSettings { pub font_size: f32, /// Multiple of the cell height. pub line_height: f32, + /// Font tried for glyphs the main font lacks; empty = none. + pub fallback_font_family: String, + /// Multiple of the cell width. + pub column_width: f32, } impl Default for TerminalSettings { @@ -267,12 +273,15 @@ impl Default for TerminalSettings { shell_integration: true, highlight_hyperlinks: true, option_as_meta: false, + escape_to_editor: false, cursor_shape: "block".into(), cursor_blink: true, use_editor_font: true, font_family: "JetBrains Mono".into(), font_size: 13.0, line_height: 1.0, + fallback_font_family: String::new(), + column_width: 1.0, } } } diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 3d37f291..5592a2bc 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -359,6 +359,48 @@ pub fn default_shell_path() -> String { default_shell() } +/// Installed shells for "New Predefined Session", the login shell first. On +/// Unix: `/etc/shells` plus `$PATH`, so Homebrew shells show up too. +pub fn available_shells() -> Vec { + let mut found: Vec = Vec::new(); + let mut add = |p: PathBuf| { + let key = p.canonicalize().unwrap_or_else(|_| p.clone()); + if p.is_file() && !found.iter().any(|f| f.canonicalize().unwrap_or_else(|_| f.clone()) == key) { + found.push(p); + } + }; + add(PathBuf::from(default_shell())); + #[cfg(unix)] + { + const NAMES: [&str; 5] = ["zsh", "bash", "fish", "nu", "pwsh"]; + let listed = std::fs::read_to_string("/etc/shells").unwrap_or_default(); + for name in NAMES { + for line in listed.lines().map(str::trim).filter(|l| l.starts_with('/')) { + if shell_title(line) == name { + add(PathBuf::from(line)); + } + } + if let Some(p) = find_in_path(name) { + add(p); + } + } + } + #[cfg(windows)] + { + for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", "wsl.exe"] { + if let Some(p) = find_in_path(exe) { + add(p); + } + } + for var in ["ProgramFiles", "ProgramW6432"] { + if let Some(dir) = std::env::var_os(var) { + add(PathBuf::from(dir).join("Git").join("bin").join("bash.exe")); + } + } + } + found.into_iter().map(|p| p.to_string_lossy().into_owned()).collect() +} + /// Picks the shell binary and its arguments. A default (or explicitly chosen but /// argument-less) POSIX shell is started as a login shell, like Terminal.app/IDEA. fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec) { @@ -410,7 +452,6 @@ fn default_shell() -> String { std::env::var("COMSPEC").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "cmd.exe".to_owned()) } -#[cfg(windows)] fn find_in_path(exe: &str) -> Option { let path = std::env::var_os("PATH")?; std::env::split_paths(&path).map(|dir| dir.join(exe)).find(|p| p.is_file()) @@ -448,6 +489,15 @@ mod tests { let shell = login_shell().expect("passwd entry"); assert!(shell.starts_with('/'), "{shell}"); } + + #[cfg(unix)] + #[test] + fn available_shells_start_with_the_default_and_are_distinct() { + let shells = available_shells(); + assert_eq!(shells.first(), Some(&default_shell_path())); + let unique: std::collections::HashSet<_> = shells.iter().collect(); + assert_eq!(unique.len(), shells.len()); + } use std::sync::mpsc::Receiver; struct Harness { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index dc0d79f6..b697c767 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -184,6 +184,7 @@ pub fn builder() -> tauri_specta::Builder { term::term_resize, term::term_kill, term::term_default_shell, + term::term_available_shells, run::run_configs, run::run_save_configs, run::run_start, diff --git a/src-tauri/src/commands/term.rs b/src-tauri/src/commands/term.rs index 7dd415eb..a510080d 100644 --- a/src-tauri/src/commands/term.rs +++ b/src-tauri/src/commands/term.rs @@ -54,3 +54,10 @@ pub fn term_kill(state: State<'_, AppState>, id: TerminalId) -> IdeResult<()> { pub fn term_default_shell() -> String { ide_terminal::default_shell_path() } + +/// Installed shells for the Terminal's "New Predefined Session" menu. +#[tauri::command] +#[specta::specta] +pub fn term_available_shells() -> Vec { + ide_terminal::available_shells() +} diff --git a/src/bindings.ts b/src/bindings.ts index f579cb06..e0bed49d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -342,6 +342,8 @@ export const commands = { termKill: (id: TerminalId) => typedError(__TAURI_INVOKE("term_kill", { id })), /** Shell used when Settings › Terminal › Shell path is empty. */ termDefaultShell: () => __TAURI_INVOKE("term_default_shell"), + /** Installed shells for the Terminal's "New Predefined Session" menu. */ + termAvailableShells: () => __TAURI_INVOKE("term_available_shells"), /** Detected configs followed by user-defined ones (user ids win on conflict). */ runConfigs: () => typedError(__TAURI_INVOKE("run_configs")), runSaveConfigs: (configs: RunConfig[]) => typedError(__TAURI_INVOKE("run_save_configs", { configs })), @@ -3767,6 +3769,8 @@ export type TerminalSettings = { shellIntegration?: boolean, highlightHyperlinks?: boolean, optionAsMeta?: boolean, + /** Escape moves focus to the editor instead of reaching the shell. */ + escapeToEditor?: boolean, /** `block` | `underline` | `bar`. */ cursorShape?: string, cursorBlink?: boolean, @@ -3775,6 +3779,10 @@ export type TerminalSettings = { fontSize?: number | null, /** Multiple of the cell height. */ lineHeight?: number | null, + /** Font tried for glyphs the main font lacks; empty = none. */ + fallbackFontFamily?: string, + /** Multiple of the cell width. */ + columnWidth?: number | null, }; export type TestCaseReport = { diff --git a/src/features/settings/pages/tools.tsx b/src/features/settings/pages/tools.tsx index 4d83c95d..0750d581 100644 --- a/src/features/settings/pages/tools.tsx +++ b/src/features/settings/pages/tools.tsx @@ -15,6 +15,7 @@ import { import { Button, Checkbox, IconButton, Input, Menu, type SelectOption, T } from "@/ui"; import { NumberInput, Row, SearchLabel, Section, Select } from "../components/Form"; import { useLabelMatch, useSetting } from "../components/context"; +import { selectPage, setQuery } from "../model/staging"; import type { PageSpec } from "./spec"; import { BoundToggle, Grid, Indented, PageLink, TableEditor } from "./workflowControls"; import styles from "./Workflow.module.css"; @@ -91,19 +92,64 @@ function EnvRow() { } function ShellPathRow() { + const [value, set] = useSetting("terminal.shellPath"); const [detected, setDetected] = useState(null); + const [shells, setShells] = useState([]); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const id = useId(); useEffect(() => { if (!isTauri()) return; void commands.termDefaultShell().then(setDetected, () => undefined); + void commands.termAvailableShells().then(setShells, () => undefined); }, []); + const browse = async () => { + const { open: pick } = await import("@tauri-apps/plugin-dialog"); + const picked = await pick({ multiple: false, title: "Shell Path" }); + const path = Array.isArray(picked) ? picked[0] : picked; + if (path) set(path); + }; return ( - + > + set(e.target.value)} + /> + setOpen((o) => !o)} + /> + setOpen(false)} + anchor={menuRef} + placement="bottom-end" + aria-label="Installed shells" + items={shells.map((p) => ({ id: p, label: p, checked: p === value, run: () => set(p) }))} + /> + void browse()} + /> + ); } @@ -127,6 +173,22 @@ function CursorRow() { ); } +/** Keymap filtered to the Terminal actions. */ +function TerminalKeybindingsLink() { + return ( + + ); +} + function TerminalOptions() { return ( + } right={ <> - + } + /> {isMac && } @@ -156,8 +227,11 @@ function TerminalFont() { const [family, setFamily] = useSetting("terminal.fontFamily"); const [size, setSize] = useSetting("terminal.fontSize"); const [lineHeight, setLineHeight] = useSetting("terminal.lineHeight"); + const [fallback, setFallback] = useSetting("terminal.fallbackFontFamily"); + const [columnWidth, setColumnWidth] = useSetting("terminal.columnWidth"); const matched = useLabelMatch("Use editor font"); const familyId = useId(); + const fallbackId = useId(); return ( <>
@@ -199,6 +273,24 @@ function TerminalFont() { /> + + setFallback(e.target.value)} + /> + + + + ); } @@ -476,6 +568,8 @@ export const TOOLS_PAGES: PageSpec[] = [ "Shell integration", "Highlight hyperlinks", "Use Option as Meta key", + "Move focus to the Editor with Escape", + "Configure terminal keybindings", ], component: TerminalOptions, }, @@ -493,7 +587,7 @@ export const TOOLS_PAGES: PageSpec[] = [ { kind: "custom", label: "Use editor font", - keywords: ["Font", "Size", "Line height"], + keywords: ["Font", "Size", "Line height", "Fallback font", "Column width"], component: TerminalFont, }, ], diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index a08dbf3a..35934d22 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -3,6 +3,7 @@ import clsx from "clsx"; import { useProject } from "@/platform/project"; import { toolWindowService } from "@/workbench/contributions"; import { usePlatform } from "@/platform/platformInfo"; +import { commands, isTauri } from "@/platform/ipc"; import { IconButton, Input, @@ -192,6 +193,30 @@ export function TerminalView() { ); } +let shellList: Promise | null = null; + +/** Installed shells, looked up once. */ +function availableShells(): Promise { + if (!isTauri()) return Promise.resolve([]); + shellList ??= commands.termAvailableShells().catch(() => { + shellList = null; + return []; + }); + return shellList; +} + +/** "zsh", or "bash (/opt/homebrew/bin/bash)" when several shells share a name. */ +export function shellItems(paths: string[]): MenuItem[] { + const name = (p: string) => p.split(/[\\/]/).pop()!.replace(/\.exe$/i, ""); + return paths.map((p) => ({ + kind: "action", + action: "terminal.newPredefinedSession", + arg: p, + label: paths.filter((q) => name(q) === name(p)).length > 1 ? `${name(p)} (${p})` : name(p), + icon: "", + })); +} + /** Session tabs shown in the tool window header: "Local × + ▾". */ export function TerminalTabs() { const tabs = useTerminals((s) => s.tabs); @@ -199,6 +224,7 @@ export function TerminalTabs() { const sessions = useTerminals((s) => s.sessions); const [renaming, setRenaming] = useState<{ id: string; anchor: Element } | null>(null); const [menuOpen, setMenuOpen] = useState(false); + const [shells, setShells] = useState([]); const menuRef = useRef(null); const focusRaf = useRef(0); @@ -240,8 +266,13 @@ export function TerminalTabs() { const moreItems: MenuItem[] = [ { kind: "action", action: "terminal.new" }, { kind: "separator" }, + { kind: "header", label: "New Predefined Session" }, + ...shellItems(shells), + { kind: "separator" }, { kind: "action", action: "terminal.splitRight" }, { kind: "action", action: "terminal.splitDown" }, + { kind: "separator" }, + { kind: "action", action: "file.settings", arg: "tools.terminal", label: "Settings", icon: "settings" }, ]; return ( @@ -283,7 +314,10 @@ export function TerminalTabs() { title="More Terminal Options" aria-haspopup="menu" aria-expanded={menuOpen} - onClick={() => setMenuOpen((v) => !v)} + onClick={() => { + if (!menuOpen) void availableShells().then(setShells); + setMenuOpen((v) => !v); + }} /> setMenuOpen(false)} />
diff --git a/src/features/terminal/index.ts b/src/features/terminal/index.ts index 253bca6d..ae1c30df 100644 --- a/src/features/terminal/index.ts +++ b/src/features/terminal/index.ts @@ -22,6 +22,10 @@ function focusActiveSoon() { requestAnimationFrame(() => requestAnimationFrame(() => terminalRuntime()?.activeSession()?.focus())); } +function shellName(path: string): string { + return path.split(/[\\/]/).pop()!.replace(/\.exe$/i, "") || path; +} + async function openNewTab(dir?: string) { const root = projectRoot(); const cwd = dir || root; @@ -53,8 +57,27 @@ export function activate(): () => void { icon: "plus", when: "projectOpen", keywords: ["shell", "console"], + // ⌘T is VCS Update elsewhere; in the terminal it opens a tab, as in IDEA. + keyWhen: "terminalFocus", run: () => openNewTab(), }, + { + id: "terminal.newPredefinedSession", + title: "New Predefined Session", + description: "Opens a terminal tab running the shell at `arg` (a path from `termAvailableShells`).", + category: "Terminal", + icon: "terminal", + when: "projectOpen", + hidden: true, + run: async ({ arg }) => { + const root = projectRoot(); + if (typeof arg !== "string" || !arg || !root) return; + const rt = await loadTerminalRuntime(); + rt.newTab(rt.startDirectory(root), shellName(arg), { program: arg, args: [], shell: true }); + toolWindowService().show(WINDOW, true); + focusActiveSoon(); + }, + }, { id: "terminal.openAt", title: "Open in Terminal", diff --git a/src/features/terminal/keys.ts b/src/features/terminal/keys.ts index 9ec2a1de..8245bb45 100644 --- a/src/features/terminal/keys.ts +++ b/src/features/terminal/keys.ts @@ -49,6 +49,8 @@ export function terminalKeyAction(e: KeyLike, hasSelection: boolean): TerminalKe if (ctrl && shift && !meta && !alt) { if (e.code === "KeyC") return hasSelection ? { kind: "copy" } : { kind: "pass" }; if (e.code === "KeyV") return { kind: "paste" }; + // New Tab on Windows / Linux (IDEA keymap); plain Ctrl+T still reaches the shell. + if (e.code === "KeyT") return { kind: "pass" }; } if (ctrl && !meta && !alt) { // Use the physical key so layouts and Shift don't change the control code. @@ -83,12 +85,10 @@ export function installTerminalKeys(target: Window = window): () => void { const action = terminalKeyAction(e, session.hasSelection); // "Override IDE shortcuts" off: Ctrl+key and Escape reach the IDE keymap first; // keys it doesn't bind still get to xterm through its own key handling. - if ( - action.kind === "send" && - (e.ctrlKey || e.key === "Escape") && - !workflowSettings().terminal.overrideIdeShortcuts - ) - return; + const t = workflowSettings().terminal; + if (action.kind === "send" && (e.ctrlKey || e.key === "Escape") && !t.overrideIdeShortcuts) return; + // "Move focus to the Editor with Escape": the IDE keymap's Escape (Focus Editor) runs instead. + if (action.kind === "send" && e.key === "Escape" && t.escapeToEditor) return; switch (action.kind) { case "pass": return; diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 6e57dc56..a098d950 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -14,7 +14,7 @@ import { findTerminalLinks, resolvePath, type TerminalLink } from "./links"; import { splitCommandLine } from "@/platform/commandLine"; import { useProject } from "@/platform/project"; import { workflowSettings, type TerminalSettingsValid } from "@/platform/workflowSettings"; -import { readSearchDecorations, readTerminalTheme, terminalFont } from "./theme"; +import { readSearchDecorations, readTerminalTheme, terminalFont, withFontExtras } from "./theme"; export interface TerminalSearchOptions { caseSensitive: boolean; @@ -46,8 +46,11 @@ export interface SessionOptions { onFocus: (key: string) => void; /** The shell exited (after the exit message was printed). */ onExit?: (key: string) => void; - /** Run this program instead of the configured shell (e.g. `docker exec -it sh`). */ - command?: { program: string; args: string[] }; + /** + * Run this program instead of the configured shell (e.g. `docker exec -it sh`). + * `shell`: it is a shell (a predefined session), so shell integration still applies. + */ + command?: { program: string; args: string[]; shell?: boolean }; } /** Mouse-tracking DEC private modes (X10, normal, button, any-event, encodings). */ @@ -74,7 +77,7 @@ function beep() { function fontFor(t: TerminalSettingsValid) { const e = useProject.getState().settings?.editor as { fontFamily?: string; fontSize?: number; lineHeight?: number } | undefined; - return terminalFont(t, e); + return withFontExtras(terminalFont(t, e), t); } const CURSOR: Record = { @@ -163,7 +166,7 @@ export class TerminalSession { fontFamily: font.family, fontSize: font.size, lineHeight: font.lineHeight, - letterSpacing: 0, + letterSpacing: font.letterSpacing, cursorBlink: settings.cursorBlink, cursorStyle: CURSOR[settings.cursorShape], cursorWidth: 2, @@ -238,6 +241,7 @@ export class TerminalSession { if (o.fontFamily !== font.family) o.fontFamily = font.family; if (o.fontSize !== font.size) o.fontSize = font.size; if (o.lineHeight !== font.lineHeight) o.lineHeight = font.lineHeight; + if (o.letterSpacing !== font.letterSpacing) o.letterSpacing = font.letterSpacing; o.cursorStyle = CURSOR[t.cursorShape]; o.cursorBlink = t.cursorBlink; o.macOptionIsMeta = t.optionAsMeta; @@ -364,7 +368,7 @@ export class TerminalSession { const args = custom ? custom.args : shellArgs; const info = await call( commands.termSpawn( - { cwd: this.opts.cwd, shell: shell ?? null, args, env: { ...t.env }, cols, rows, shellIntegration: !custom && t.shellIntegration }, + { cwd: this.opts.cwd, shell: shell ?? null, args, env: { ...t.env }, cols, rows, shellIntegration: (!custom || !!custom.shell) && t.shellIntegration }, channel, ), ); diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 9b240bd6..b3a4d1cf 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -4,7 +4,7 @@ import { matchCounter } from "./FindBar"; import { buildSearchDecorations } from "./theme"; import { findTerminalLinks, resolvePath, trimUrl } from "./links"; import { layoutPanes, nextTabName, removeFromLayout, splitLayout } from "./store"; -import { buildTheme, mix, parseColor, toHex } from "./theme"; +import { buildTheme, mix, parseColor, toHex, withFontExtras } from "./theme"; describe("theme", () => { it("parses colour formats", () => { @@ -109,12 +109,18 @@ describe("terminal keys", () => { expect(terminalKeyAction(key({ key: "V", code: "KeyV", ctrlKey: true, shiftKey: true }), false)).toEqual({ kind: "paste", }); + // Ctrl+Shift+T is New Tab in the IDE keymap; Ctrl+T still goes to the shell. + expect(terminalKeyAction(key({ key: "T", code: "KeyT", ctrlKey: true, shiftKey: true }), false)).toEqual({ + kind: "pass", + }); + expect(terminalKeyAction(key({ key: "t", code: "KeyT", ctrlKey: true }), false)).toEqual({ kind: "send", data: "\x14" }); }); it("implements macOS conveniences", () => { expect(terminalKeyAction(key({ key: "c", metaKey: true }), true)).toEqual({ kind: "copy" }); expect(terminalKeyAction(key({ key: "c", metaKey: true }), false)).toEqual({ kind: "pass" }); expect(terminalKeyAction(key({ key: "k", metaKey: true }), false)).toEqual({ kind: "clear" }); + expect(terminalKeyAction(key({ key: "t", metaKey: true }), false)).toEqual({ kind: "pass" }); expect(terminalKeyAction(key({ key: "v", metaKey: true }), false)).toEqual({ kind: "paste" }); expect(terminalKeyAction(key({ key: "f", metaKey: true }), false)).toEqual({ kind: "find" }); expect(terminalKeyAction(key({ key: "ArrowLeft", altKey: true }), false)).toEqual({ kind: "send", data: "\x1bb" }); @@ -174,3 +180,19 @@ describe("split layout", () => { expect(removeFromLayout("a", "a")).toBeNull(); }); }); + +describe("withFontExtras", () => { + const font = { family: '"JetBrains Mono", "Symbols Nerd Font", monospace', size: 10, lineHeight: 1 }; + + it("puts the fallback family right after the main one", () => { + expect(withFontExtras(font, { fallbackFontFamily: "PingFang SC", columnWidth: 1 }).family).toBe( + '"JetBrains Mono", "PingFang SC", "Symbols Nerd Font", monospace', + ); + expect(withFontExtras(font, { fallbackFontFamily: "", columnWidth: 1 }).family).toBe(font.family); + }); + + it("turns the column width into letter spacing", () => { + expect(withFontExtras(font, { fallbackFontFamily: "", columnWidth: 1 }).letterSpacing).toBe(0); + expect(withFontExtras(font, { fallbackFontFamily: "", columnWidth: 1.5 }).letterSpacing).toBe(3); + }); +}); diff --git a/src/features/terminal/theme.ts b/src/features/terminal/theme.ts index 631e7628..8d296015 100644 --- a/src/features/terminal/theme.ts +++ b/src/features/terminal/theme.ts @@ -209,3 +209,24 @@ export function terminalFont( lineHeight: Math.max(1, Math.round((lh / (size * 1.2)) * 100) / 100), }; } + +/** + * Settings › Tools › Terminal extras over `terminalFont`: the fallback family + * goes right after the main one, and the column width (a multiple of the cell + * width) becomes xterm's letter spacing, from the ≈ 0.6 em advance of + * monospace fonts. + */ +export function withFontExtras( + font: { family: string; size: number; lineHeight: number }, + t: { fallbackFontFamily: string; columnWidth: number }, +): { family: string; size: number; lineHeight: number; letterSpacing: number } { + const fallback = t.fallbackFontFamily.replace(/"/g, "").trim(); + const comma = font.family.indexOf(","); + const family = !fallback + ? font.family + : comma < 0 + ? `${font.family}, "${fallback}"` + : `${font.family.slice(0, comma)}, "${fallback}"${font.family.slice(comma)}`; + const letterSpacing = Math.round((t.columnWidth - 1) * font.size * 0.6 * 10) / 10; + return { ...font, family, letterSpacing }; +} diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 4d2d82e1..0b0976eb 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -252,6 +252,8 @@ export const intellijMacos: Record = { "db.sqlScript": [], "db.goToReferencedTable": [], "db.collapseAll": [], + // Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there. + "terminal.new": ["Meta+T"], // Last: later bindings win, so ⌘N is Generate in the editor and New in tool windows. "code.generate": ["Meta+N"], }; diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index 6a05f925..78fa756d 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -249,6 +249,8 @@ export const intellijWindows: Record = { "db.sqlScript": [], "db.goToReferencedTable": [], "db.collapseAll": [], + // Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there. + "terminal.new": ["Ctrl+Shift+T"], // Last: later bindings win, so Alt+Insert is Generate in the editor and New in tool windows. "code.generate": ["Alt+Insert"], }; diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index 0192c421..6883ae58 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -144,6 +144,7 @@ export const vscodeMacos: Record = { "toolwindow.git": ["Ctrl+Shift+G"], "toolwindow.commit": [], "toolwindow.terminal": ["Ctrl+`"], + "terminal.new": [], "toolwindow.hideAll": ["Meta+B"], "toolwindow.maximize": [], "toolwindow.jumpToLast": [], diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d12057b4..c3b59f6a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2324,5 +2324,14 @@ "Custom": "自定义", "On PATH": "PATH 中", "Reinstall": "重新安装", - "Auto-detect": "自动检测" + "Auto-detect": "自动检测", + "Move focus to the Editor with Escape": "按 Escape 将焦点移到编辑器", + "Otherwise Escape goes to the shell.": "否则 Escape 发送给 shell。", + "Configure terminal keybindings": "配置终端快捷键", + "Used for characters the main font lacks.": "用于主字体缺少的字符。", + "Column width": "列宽", + "Installed Shells": "已安装的 Shell", + "Installed shells": "已安装的 Shell", + "Shell Path": "Shell 路径", + "New Predefined Session": "新建预定义会话" } diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 544130b7..9e2740c7 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -91,12 +91,15 @@ export interface TerminalSettingsValid { shellIntegration: boolean; highlightHyperlinks: boolean; optionAsMeta: boolean; + escapeToEditor: boolean; cursorShape: CursorShape; cursorBlink: boolean; useEditorFont: boolean; fontFamily: string; fontSize: number; lineHeight: number; + fallbackFontFamily: string; + columnWidth: number; } export interface ActionsOnSaveValid { @@ -135,6 +138,7 @@ export const WORKFLOW_LIMITS = { terminationTimeoutMs: { min: 0, max: 600_000 }, terminalFontSize: { min: 6, max: 72 }, terminalLineHeight: { min: 0.5, max: 3 }, + terminalColumnWidth: { min: 0.5, max: 2 }, } as const; export const DEFAULT_EXTERNAL_TOOL: ExternalToolValid = { @@ -221,12 +225,15 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { shellIntegration: true, highlightHyperlinks: true, optionAsMeta: false, + escapeToEditor: false, cursorShape: "block", cursorBlink: true, useEditorFont: true, fontFamily: "JetBrains Mono", fontSize: 13, lineHeight: 1, + fallbackFontFamily: "", + columnWidth: 1, }, actionsOnSave: { optimizeImports: false, cleanup: false }, externalTools: [], @@ -357,12 +364,15 @@ export function validateTerminal(raw: unknown): TerminalSettingsValid { shellIntegration: bool(v.shellIntegration, d.shellIntegration), highlightHyperlinks: bool(v.highlightHyperlinks, d.highlightHyperlinks), optionAsMeta: bool(v.optionAsMeta, d.optionAsMeta), + escapeToEditor: bool(v.escapeToEditor, d.escapeToEditor), cursorShape: oneOf(v.cursorShape, ["block", "underline", "bar"] as const, d.cursorShape), cursorBlink: bool(v.cursorBlink, d.cursorBlink), useEditorFont: bool(v.useEditorFont, d.useEditorFont), fontFamily: nonEmpty(v.fontFamily, d.fontFamily), fontSize: num(v.fontSize, d.fontSize, WORKFLOW_LIMITS.terminalFontSize), lineHeight: num(v.lineHeight, d.lineHeight, WORKFLOW_LIMITS.terminalLineHeight), + fallbackFontFamily: text(v.fallbackFontFamily, d.fallbackFontFamily).trim(), + columnWidth: num(v.columnWidth, d.columnWidth, WORKFLOW_LIMITS.terminalColumnWidth), }; }