mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": "新建预定义会话"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user