feat(terminal): Align the terminal with IDEA's tabs, menu and settings

- ⌘T (Ctrl+Shift+T on Windows) opens a terminal tab while the terminal
  has focus; elsewhere the key keeps its IDE action.
- The tab bar's ▾ menu lists installed shells under "New Predefined
  Session" (login shell, shell integration) and links to Settings.
- Settings › Tools › Terminal: pick the shell path from the installed
  shells or browse for one, "Move focus to the Editor with Escape",
  fallback font, column width and a link to the Terminal keymap.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
This commit is contained in:
l0ng-ai
2026-10-24 13:02:52 +08:00
parent 1f5fcb477d
commit be49490153
17 changed files with 320 additions and 23 deletions
+9
View File
@@ -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,
}
}
}
+51 -1
View File
@@ -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<String> {
let mut found: Vec<PathBuf> = 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<String>) {
@@ -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<PathBuf> {
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 {
+1
View File
@@ -184,6 +184,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
term::term_resize,
term::term_kill,
term::term_default_shell,
term::term_available_shells,
run::run_configs,
run::run_save_configs,
run::run_start,
+7
View File
@@ -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<String> {
ide_terminal::available_shells()
}
+8
View File
@@ -342,6 +342,8 @@ export const commands = {
termKill: (id: TerminalId) => typedError<null, IdeError>(__TAURI_INVOKE("term_kill", { id })),
/** Shell used when Settings › Terminal › Shell path is empty. */
termDefaultShell: () => __TAURI_INVOKE<string>("term_default_shell"),
/** Installed shells for the Terminal's "New Predefined Session" menu. */
termAvailableShells: () => __TAURI_INVOKE<string[]>("term_available_shells"),
/** Detected configs followed by user-defined ones (user ids win on conflict). */
runConfigs: () => typedError<RunConfig[], IdeError>(__TAURI_INVOKE("run_configs")),
runSaveConfigs: (configs: RunConfig[]) => typedError<null, IdeError>(__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 = {
+101 -7
View File
@@ -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<string>("terminal.shellPath");
const [detected, setDetected] = useState<string | null>(null);
const [shells, setShells] = useState<string[]>([]);
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLButtonElement>(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 (
<TextSetting
k="terminal.shellPath"
<Row
label="Shell path"
mono
placeholder={detected ?? "Login shell"}
htmlFor={id}
hint="A command line, e.g. /bin/zsh --login. Leave empty to use the login shell."
/>
>
<Input
id={id}
mono
className={styles.wide}
value={value}
placeholder={detected ?? "Login shell"}
onChange={(e) => set(e.target.value)}
/>
<IconButton
ref={menuRef}
icon="chevron-down"
size="small"
title="Installed Shells"
aria-haspopup="menu"
aria-expanded={open}
disabled={shells.length === 0}
onClick={() => setOpen((o) => !o)}
/>
<Menu
open={open}
onClose={() => 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) }))}
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
aria-label="Browse"
disabled={!isTauri()}
onClick={() => void browse()}
/>
</Row>
);
}
@@ -127,6 +173,22 @@ function CursorRow() {
);
}
/** Keymap filtered to the Terminal actions. */
function TerminalKeybindingsLink() {
return (
<button
type="button"
className={styles.link}
onClick={() => {
selectPage("keymap");
setQuery("Terminal");
}}
>
<T>Configure terminal keybindings</T>
</button>
);
}
function TerminalOptions() {
return (
<Grid
@@ -137,11 +199,20 @@ function TerminalOptions() {
<BoundToggle k="terminal.mouseReporting" label="Mouse reporting" />
<BoundToggle k="terminal.copyOnSelection" label="Copy to clipboard on selection" />
<BoundToggle k="terminal.pasteOnMiddleClick" label="Paste on middle mouse button click" />
<BoundToggle
k="terminal.escapeToEditor"
label="Move focus to the Editor with Escape"
hint="Otherwise Escape goes to the shell."
/>
</>
}
right={
<>
<BoundToggle k="terminal.overrideIdeShortcuts" label="Override IDE shortcuts" />
<BoundToggle
k="terminal.overrideIdeShortcuts"
label="Override IDE shortcuts"
hint={<TerminalKeybindingsLink />}
/>
<BoundToggle k="terminal.shellIntegration" label="Shell integration" />
<BoundToggle k="terminal.highlightHyperlinks" label="Highlight hyperlinks" />
{isMac && <BoundToggle k="terminal.optionAsMeta" label="Use Option as Meta key" />}
@@ -156,8 +227,11 @@ function TerminalFont() {
const [family, setFamily] = useSetting<string>("terminal.fontFamily");
const [size, setSize] = useSetting<number>("terminal.fontSize");
const [lineHeight, setLineHeight] = useSetting<number>("terminal.lineHeight");
const [fallback, setFallback] = useSetting<string>("terminal.fallbackFontFamily");
const [columnWidth, setColumnWidth] = useSetting<number>("terminal.columnWidth");
const matched = useLabelMatch("Use editor font");
const familyId = useId();
const fallbackId = useId();
return (
<>
<div className={clsx(styles.inlineRow, matched && styles.matched)}>
@@ -199,6 +273,24 @@ function TerminalFont() {
/>
</Row>
</Indented>
<Row label="Fallback font" htmlFor={fallbackId} hint="Used for characters the main font lacks.">
<Input
id={fallbackId}
className={styles.medium}
value={fallback}
placeholder="None"
onChange={(e) => setFallback(e.target.value)}
/>
</Row>
<Row label="Column width">
<NumberInput
value={columnWidth}
onChange={setColumnWidth}
{...L.terminalColumnWidth}
step={0.1}
aria-label="Terminal column width"
/>
</Row>
</>
);
}
@@ -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,
},
],
+35 -1
View File
@@ -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<string[]> | null = null;
/** Installed shells, looked up once. */
function availableShells(): Promise<string[]> {
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<string[]>([]);
const menuRef = useRef<HTMLButtonElement>(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);
}}
/>
<Menu open={menuOpen} anchor={menuRef} items={moreItems} onClose={() => setMenuOpen(false)} />
</div>
+23
View File
@@ -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",
+6 -6
View File
@@ -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;
+10 -6
View File
@@ -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 <id> sh`). */
command?: { program: string; args: string[] };
/**
* Run this program instead of the configured shell (e.g. `docker exec -it <id> 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<TerminalSettingsValid["cursorShape"], "block" | "underline" | "bar"> = {
@@ -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,
),
);
+23 -1
View File
@@ -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);
});
});
+21
View File
@@ -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 };
}
+2
View File
@@ -252,6 +252,8 @@ export const intellijMacos: Record<string, string[]> = {
"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"],
};
+2
View File
@@ -249,6 +249,8 @@ export const intellijWindows: Record<string, string[]> = {
"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"],
};
+1
View File
@@ -144,6 +144,7 @@ export const vscodeMacos: Record<string, string[]> = {
"toolwindow.git": ["Ctrl+Shift+G"],
"toolwindow.commit": [],
"toolwindow.terminal": ["Ctrl+`"],
"terminal.new": [],
"toolwindow.hideAll": ["Meta+B"],
"toolwindow.maximize": [],
"toolwindow.jumpToLast": [],
+10 -1
View File
@@ -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": "新建预定义会话"
}
+10
View File
@@ -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),
};
}