Порівняти коміти

...
15 Коміти
Автор SHA1 Повідомлення Дата
l0ng-ai dcd4f150bd fix(kotlin): stop notifying about every Kotlin compiler error in Maven projects (#3521)
The Kotlin-to-Java bridge popped an error notification for each new failure message, so mid-edit Kotlin with autosave produced a balloon per save for errors its editor already shows. Compiler errors in project sources now only update the Problems entry on pom.xml; a build that fails without one (Maven, a goal that didn't run, the bridge bundle or JDT snapshot) still notifies once per message.
2026-11-15 19:26:00 +08:00
l0ng-ai 8caeb1305f fix(editor): indent Kotlin, Scala, Dart and Groovy brackets opened after a string (#3520)
The string-interpolation wrapper let the stock clike tokenizer consume an empty string on a new stream at column 0 whenever a literal opened; clike read that as a new line and reset the line's indentation to 0, so a bracket opened later on the same line indented its contents from column 0. Enter and paste with Reformat on Paste put the following lines at the wrong column. The empty string is now tokenized at the literal's own column, which fixes all dialects served by the wrapper (Kotlin, Scala, Dart, Groovy; C# shares the code path).
2026-11-15 19:21:48 +08:00
l0ng-ai 7ec0a4fed2 feat(diff): Esc closes a diff tab; switch panes with ⌃⇧⇥; move a diff window back to a tab (#3517)
Close Diff Editor: Esc in a diff editor tab closes it once the find bar, a selection, completion or a popup don't take it; panels, dialogs and diff windows keep their own Esc. Select Opposite Diff Pane (⌃⇧⇥ in the IntelliJ keymaps) moves focus to the other side of a side-by-side diff and wins over the Switcher while a diff is focused. A diff window's Show Diff in Editor Tab saves its edits, opens the diff as a tab in its project window and closes the window.
2026-11-15 18:42:16 +08:00
l0ng-ai 733b6cff1d feat(run): pin Run and Debug tabs; a new run replaces one finished, unpinned tab (#3514)
Run and Debug tabs can be pinned (context menu, Pin Active Tab, or the pin in place of the close button). A new run or debug session takes the place of one finished, unpinned tab: the same configuration's, else the selected one, else the first; Run used to add a tab per configuration, so finished tabs piled up. Rerun on a pinned tab restarts it in place and keeps it pinned. Settings > Run/Debug gets "Make configurations pinned by default".

Siblings: Run, Debug, Find and Usages tabs share one context menu (Close Tab, Close All Tabs, Close Other Tabs, Pin/Unpin Tab); Find and Usages show a pinned tab with the pin in place of its close button.
2026-11-15 18:29:15 +08:00
l0ng-ai b0860ed1e2 fix(debugger): a new debug session replaces one finished tab, not all of them (#3512)
Starting a session dropped every finished Debug tab, so ended sessions of other configurations disappeared with it. The new session now takes the place of a single finished tab: the same-named one, else the selected one, else the first; other finished tabs stay. A prepared start replaces it when its build begins and doesn't replace another when the adapter starts.
2026-11-15 18:01:31 +08:00
l0ng-ai 48a13cb8fa fix(terminal): paste paths of files dragged from the Project view (#3511)
The terminal took file paths only from OS drops (Finder / Explorer). A
file or folder dragged from the Project view onto the terminal did nothing.
The terminal is now a drop target for Project view drags too and pastes the
paths at the prompt, shell-quoted, without executing; it also works in a
remote window, where the paths are the remote project's.

Fixes #3503
2026-11-15 17:28:05 +08:00
l0ng-ai c9488df786 fix(appearance): red-green vision setting also adjusts editor colours (#3510)
Adjust colors for red-green vision deficiency only recoloured VCS file
status, change markers and success, so in a file without changes it had no
visible effect. With the bundled dark and light schemes it now also uses the
schemes' red-green variants for syntax (keywords, strings, numbers, fields,
functions, annotations, TODO), errors (orange instead of red), warnings,
identifier-under-caret and search highlights, breakpoint lines and coverage.
Other colour themes and plugin themes keep their own editor colours.

Fixes #3500
2026-11-15 17:28:01 +08:00
l0ng-ai 4427ec2c07 fix(ui): restore hover backgrounds on the main menu and Review Mode buttons (#3509)
The main menu button (Windows and Linux title bar) and the Review Mode comment navigation buttons are plain buttons, so #2990's IconButton hover did not reach them. They paint --bg-toolbar-hover again; the main menu button keeps it while its menu is open. Gutter run markers, diff arrows and the database banner close button keep their icon-only hover.
2026-11-15 17:26:02 +08:00
Mr0Bread cf71a61cdf fix(ui): restore toolbar icon hover feedback (#2990)
Toolbar and tool window icon buttons (IconButton) paint --bg-toolbar-hover across their hit target again, so hovering shows the clickable area. Disabled buttons stay transparent; pressed, selected, focus ring and icon colours are unchanged. The hover colours match the toolbar tokens already used for the pressed state in Islands Light and Dark.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 17:23:13 +08:00
l0ng-ai f9af0e7bff fix(editor): offer to make a read-only file writable when editing it (#3508)
A file with the read-only attribute set (often kept when copying files in
Explorer) opened read-only, and typing into it did nothing beyond the
banner. Typing, pasting or dropping into such a file now asks to make it
writable (the same question as Toggle Read-only Attribute) and clears the
attribute on confirm. Binary, too-large and library documents still refuse
edits without asking.

Fixes #3479
2026-11-15 17:15:50 +08:00
Akbar Husain 6449b68bb0 feat(debug): debug Zig under CodeLLDB (#3392)
Zig programs now debug under CodeLLDB with sourceLanguages: [] so expressions aren't evaluated as Rust; the context menu of a closed .zig file reads its content; rerunning failed tests of a zig build test step derives a standalone zig test from the module root detect/zig.rs records per step (zig build forwards no args to the test binary). Adds the zig-app fixture's failing test step and run/debug smoke cases. The Language smoke CI job for zig is left for a separate change.

Part of #3260

Co-authored-by: akbar1214 <[email protected]>
2026-11-15 17:13:12 +08:00
Mr0Bread 5d2d814610 fix(trust): list Restricted Mode commands by name; snapshot every command's trust class (#3394)
Restricted Mode allowed commands by 19 name prefixes, so any new command matching a prefix was allowed without review. It now lists each allowed command by exact name; a new command is denied until it is listed. src-tauri/tests/commands.snap records every command's trust class, remote placement, bindings presence and sync/async mode, regenerated by the export_bindings test and checked for drift in CI next to src/bindings.ts. The unused bytes_ prefix is gone; doc_open_untitled, doc_save_as and doc_python_facts, added on main since, stay allowed by name.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 17:11:59 +08:00
l0ng-ai 3f18a8a274 fix(project-view): paste a clipboard image as img.png (#3507)
Pasting into the Project view only handled files, so a screenshot on the
clipboard did nothing. With no files to paste, Paste now saves the clipboard
image in the target folder as img.png (img_1.png, img_2.png ... when taken)
and selects it; Paste is enabled while the clipboard holds an image.

The image is read natively: public.png, or public.tiff converted, on macOS;
the registered PNG format, or CF_DIB (24/32-bit) encoded as PNG, on Windows;
GTK on Linux as before.

Fixes #3486
2026-11-15 17:10:11 +08:00
l0ng-ai f8a8ecb662 fix(tools): name a tool relative to its Windows wrapper so a non-ASCII user directory works (#3376)
Windows tool wrappers held the tool's absolute path as UTF-8, which cmd.exe reads in the console code page, so a non-ASCII user directory broke every language server and debugger. Wrappers now name paths inside the tool directory via %~dp0 (plain ASCII), and installed wrappers are rewritten at startup with the existing relocate_shims; GEM_HOME lines are covered too.

Fixes #3338
2026-11-15 17:06:56 +08:00
Deng Liuтаdengliu e2b1a2d732 fix(markdown): scale Mermaid diagrams, SVGs and images with preview font zoom (#3481)
Editor font zoom scaled the Markdown preview's text but left Mermaid diagrams, embedded SVGs and raster images at 1x: Mermaid writes a pixel max-width on its svg and images were only capped at 100%. The preview now publishes --markdown-zoom-scale and records each diagram's and image's intrinsic width (SVG viewBox/width/height, data-URL SVG headers, naturalWidth), sizing them as min(base, 100%) * scale so they follow zoom in/out/reset while staying clamped to the pane at 1x. Agent chat diagrams keep scale 1; Mermaid full-screen and PNG export share the same size helper.

Co-authored-by: dengliu <[email protected]>
2026-11-15 16:57:09 +08:00
92 змінених файлів з 3093 додано та 207 видалено

No files matched your search

+3 -3
Переглянути файл
@@ -98,10 +98,10 @@ jobs:
# Test fixtures write LF files; keep Git from converting them on checkout.
- run: git config --global core.autocrlf false
- run: cargo nextest run --workspace --no-fail-fast
# nextest ran export_bindings, which rewrites src/bindings.ts.
- name: Bindings are up to date
# nextest ran export_bindings, which rewrites src/bindings.ts and the command snapshot.
- name: Bindings and command snapshot are up to date
if: runner.os == 'Linux'
run: git diff --exit-code src/bindings.ts
run: git diff --exit-code src/bindings.ts src-tauri/tests/commands.snap
# The remote development server ships as a static Linux binary (release-build.yml):
# keep that build working.
Створено
+1
Переглянути файл
@@ -4232,6 +4232,7 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"png 0.18.1",
]
[[package]]
+9 -3
Переглянути файл
@@ -1,6 +1,6 @@
[package]
name = "ide-clipboard"
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
description = "The OS clipboard's file list (Explorer <-> project view) and pasted images: Windows CF_HDROP / PNG / CF_DIB or macOS NSPasteboard, no window or message loop"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -9,8 +9,14 @@ license.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSBitmapImageRep", "NSImageRep", "NSPasteboard"] }
[target.'cfg(windows)'.dependencies]
png = "0.18"
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
[dev-dependencies]
png = "0.18"
+222 -3
Переглянути файл
@@ -18,6 +18,10 @@
//!
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
//! empty and [`set_files`] fails there.
//!
//! [`png`] reads an image (a screenshot) for the project view's Paste, which
//! saves it as a file: `public.png` or `public.tiff` on macOS, the registered
//! `PNG` format or `CF_DIB` on Windows. Linux reads it through GTK in the app.
use std::path::PathBuf;
@@ -34,6 +38,19 @@ pub fn text() -> Option<String> {
imp::text()
}
/// The clipboard's image as PNG bytes, None when it holds none. Call on the main
/// thread on macOS.
#[cfg(any(windows, target_os = "macos"))]
pub fn png() -> Option<Vec<u8>> {
imp::png()
}
/// Whether the clipboard holds an image, without reading it. Call on the main
/// thread on macOS and Linux.
pub fn has_image() -> bool {
imp::has_image()
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -134,9 +151,62 @@ pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
out
}
/// `BI_RGB` and `BI_BITFIELDS`: uncompressed pixels, the forms screenshots take.
#[cfg(any(windows, test))]
const BI_RGB: u32 = 0;
#[cfg(any(windows, test))]
const BI_BITFIELDS: u32 = 3;
/// A packed DIB (`BITMAPINFOHEADER` or a later header, then the pixels) as PNG.
/// Only 24- and 32-bit uncompressed bitmaps, which is what screenshots and
/// copied images are; None for anything else. A 32-bit bitmap whose alpha is
/// all zero (most writers leave it unset) is opaque.
#[cfg(any(windows, test))]
fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
let u32_at = |at: usize| dib.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
let header = u32_at(0)? as usize;
let width = u32_at(4)? as i32;
let height = u32_at(8)? as i32;
let bits = u16::from_le_bytes([*dib.get(14)?, *dib.get(15)?]);
let compression = u32_at(16)?;
if header < 40 || width <= 0 || height == 0 || !matches!(bits, 24 | 32) {
return None;
}
if compression != BI_RGB && !(compression == BI_BITFIELDS && bits == 32) {
return None;
}
// A `BITMAPINFOHEADER` keeps the three bitfield masks after it; later
// headers hold them inside. Masks other than BGRA's are not supported.
let masks = if compression == BI_BITFIELDS && header == 40 { 12 } else { 0 };
let colors = u32_at(32)? as usize;
let (width, rows) = (width as usize, height.unsigned_abs() as usize);
let stride = (width * bits as usize).div_ceil(32) * 4;
let start = header + masks + colors * 4;
let pixels = dib.get(start..start.checked_add(stride.checked_mul(rows)?)?)?;
let step = bits as usize / 8;
let mut rgba = Vec::with_capacity(width * rows * 4);
for row in 0..rows {
// A positive height stores the bottom row first.
let source = if height > 0 { rows - 1 - row } else { row };
let line = &pixels[source * stride..source * stride + width * step];
for px in line.chunks_exact(step) {
rgba.extend_from_slice(&[px[2], px[1], px[0], if step == 4 { px[3] } else { 255 }]);
}
}
if step == 4 && rgba.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|px| px[3] = 255);
}
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, width as u32, rows as u32);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header().ok()?.write_image_data(&rgba).ok()?;
Some(out)
}
#[cfg(windows)]
mod imp {
use super::{HEADER, decode, encode, names_text};
use super::{HEADER, decode, dib_to_png, encode, names_text};
use std::ffi::c_void;
use std::path::PathBuf;
use std::ptr;
@@ -164,6 +234,59 @@ mod imp {
fn GetClipboardData(format: u32) -> Handle;
fn SetClipboardData(format: u32, memory: Handle) -> Handle;
fn IsClipboardFormatAvailable(format: u32) -> i32;
fn RegisterClipboardFormatW(name: *const u16) -> u32;
}
/// `CF_DIB`: Windows synthesizes it from `CF_BITMAP` and `CF_DIBV5`, so every
/// bitmap on the clipboard reads as one.
const CF_DIB: u32 = 8;
/// The `PNG` format browsers and the Snipping Tool add beside the bitmap; it
/// keeps transparency, so it is read first.
fn png_format() -> u32 {
let name: Vec<u16> = "PNG\0".encode_utf16().collect();
unsafe { RegisterClipboardFormatW(name.as_ptr()) }
}
pub fn has_image() -> bool {
let png = png_format();
(png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0)
|| unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0
}
/// A copy of the clipboard's `format` block. Call with the clipboard open.
fn read_block(format: u32) -> Option<Vec<u8>> {
// SAFETY: the clipboard keeps the block alive while it is open, and
// `GlobalSize` bounds the bytes read from it.
unsafe {
let memory = GetClipboardData(format);
if memory.is_null() {
return None;
}
let data = GlobalLock(memory);
if data.is_null() {
return None;
}
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory)).to_vec();
GlobalUnlock(memory);
Some(bytes)
}
}
pub fn png() -> Option<Vec<u8>> {
if !open() {
return None;
}
let png = png_format();
let image = if png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0 {
read_block(png)
} else if unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0 {
read_block(CF_DIB).and_then(|dib| dib_to_png(&dib))
} else {
None
};
unsafe { CloseClipboard() };
image
}
#[link(name = "kernel32")]
@@ -327,8 +450,8 @@ mod imp {
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
use objc2::{ClassType, MainThreadMarker};
use objc2_app_kit::{
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
NSPasteboardWriting,
NSBitmapImageFileType, NSBitmapImageRep, NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypePNG,
NSPasteboardTypeString, NSPasteboardTypeTIFF, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use std::path::PathBuf;
@@ -380,6 +503,33 @@ mod imp {
})
}
pub fn has_image() -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
let types = NSArray::from_slice(&[unsafe { NSPasteboardTypePNG }, unsafe { NSPasteboardTypeTIFF }]);
NSPasteboard::generalPasteboard().availableTypeFromArray(&types).is_some()
})
}
/// PNG as copied, else TIFF (what macOS screenshots and most apps write) converted.
pub fn png() -> Option<Vec<u8>> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
if let Some(png) = board.dataForType(unsafe { NSPasteboardTypePNG }) {
return Some(png.to_vec());
}
let tiff = board.dataForType(unsafe { NSPasteboardTypeTIFF })?;
let image = NSBitmapImageRep::imageRepWithData(&tiff)?;
// SAFETY: an empty property dictionary is valid for every file type.
let png =
unsafe { image.representationUsingType_properties(NSBitmapImageFileType::PNG, &NSDictionary::new()) }?;
Some(png.to_vec())
})
}
pub fn text() -> Option<String> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
@@ -448,6 +598,17 @@ mod imp {
false
}
pub fn has_image() -> bool {
#[cfg(target_os = "linux")]
{
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).wait_is_image_available()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
pub fn files() -> std::io::Result<Vec<PathBuf>> {
Ok(Vec::new())
}
@@ -521,4 +682,62 @@ mod tests {
bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0");
assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"]));
}
/// A `BITMAPINFOHEADER` DIB of `width` × `height` (positive: bottom-up).
fn dib(width: i32, height: i32, bits: u16, compression: u32, rows: &[&[u8]]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&40u32.to_le_bytes());
out.extend_from_slice(&width.to_le_bytes());
out.extend_from_slice(&height.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&bits.to_le_bytes());
out.extend_from_slice(&compression.to_le_bytes());
out.extend_from_slice(&[0u8; 20]);
if compression == super::BI_BITFIELDS {
for mask in [0x00ff_0000u32, 0x0000_ff00, 0x0000_00ff] {
out.extend_from_slice(&mask.to_le_bytes());
}
}
for row in rows {
out.extend_from_slice(row);
}
out
}
fn decode_png(png: &[u8]) -> (u32, u32, Vec<u8>) {
let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
(info.width, info.height, buf)
}
#[test]
fn a_bottom_up_24_bit_bitmap_becomes_an_upright_opaque_png() {
// Rows are padded to 4 bytes; the first stored row is the bottom one.
let bottom: &[u8] = &[255, 0, 0, 0, 255, 0, 0, 0];
let top: &[u8] = &[0, 0, 255, 255, 255, 255, 0, 0];
let png = super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[bottom, top])).unwrap();
let (w, h, rgba) = decode_png(&png);
assert_eq!((w, h), (2, 2));
assert_eq!(rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]);
}
#[test]
fn a_32_bit_bitmap_keeps_its_alpha_unless_none_was_written() {
let row: &[u8] = &[10, 20, 30, 0, 40, 50, 60, 0];
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_BITFIELDS, &[row])).unwrap());
assert_eq!(rgba, [30, 20, 10, 255, 60, 50, 40, 255]);
let row: &[u8] = &[10, 20, 30, 128, 40, 50, 60, 0];
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_RGB, &[row])).unwrap());
assert_eq!(rgba, [30, 20, 10, 128, 60, 50, 40, 0]);
}
#[test]
fn unsupported_or_truncated_bitmaps_are_not_images() {
let row: &[u8] = &[1, 2, 3, 4];
assert!(super::dib_to_png(&dib(4, 1, 8, super::BI_RGB, &[row])).is_none());
assert!(super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[row])).is_none());
assert!(super::dib_to_png(&[0; 10]).is_none());
}
}
+3
Переглянути файл
@@ -327,6 +327,8 @@ pub struct RunSettings {
/// Ask before closing a Run tab whose process is still running.
pub confirm_terminate: bool,
pub show_on_start: bool,
/// New Run/Debug tabs start pinned: a later run doesn't take their place once finished.
pub pin_tabs: bool,
/// Grace period between interrupt and kill when stopping a process.
pub termination_timeout_ms: u32,
}
@@ -338,6 +340,7 @@ impl Default for RunSettings {
confirm_rerun: true,
confirm_terminate: true,
show_on_start: true,
pin_tabs: false,
termination_timeout_ms: 3000,
}
}
+30
Переглянути файл
@@ -325,6 +325,20 @@ pub fn migrate(data: &Path, local: &Path) {
if cfg!(target_os = "windows") && data != local {
migrate_entries(data, local);
}
if cfg!(target_os = "windows") {
relative_tool_shims(&local.join("tools"));
}
}
/// Wrappers written before they named their tool relative to themselves hold its absolute
/// path, which cmd.exe can't find through a non-ASCII user directory (#3338).
fn relative_tool_shims(tools: &Path) {
let Ok(entries) = fs::read_dir(tools) else { return };
for dir in entries.flatten().map(|e| e.path()).filter(|p| p.is_dir()) {
if let Err(error) = relocate_shims(&dir, &dir) {
tracing::warn!(%error, ?dir, "cannot make the tool's wrappers relative");
}
}
}
fn migrate_entries(data: &Path, local: &Path) {
@@ -651,6 +665,22 @@ mod tests {
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn installed_tool_wrappers_become_relative() {
let tmp = tempfile::tempdir().unwrap();
let tools = tmp.path().join("tools");
let tool = tools.join("taplo");
fs::create_dir_all(tool.join("bin")).unwrap();
let shim = tool.join("bin/taplo.cmd");
fs::write(&shim, format!("@echo off\r\n\"{}\\taplo.exe\" %*\r\n", tool.display())).unwrap();
// A wrapper the user re-saved in another encoding is left alone.
fs::create_dir_all(tools.join("other/bin")).unwrap();
fs::write(tools.join("other/bin/x.cmd"), b"@echo off\r\n\"C:\\\xd5\xc5\" %*\r\n").unwrap();
relative_tool_shims(&tools);
assert_eq!(fs::read_to_string(&shim).unwrap(), "@echo off\r\n\"%~dp0..\\taplo.exe\" %*\r\n");
relative_tool_shims(&tmp.path().join("missing"));
}
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
+38 -12
Переглянути файл
@@ -251,18 +251,12 @@ impl KotlinBridge {
}
}
other => {
let text = match other {
Ok(Ok(outcome)) => outcome
.messages
.iter()
.filter(|m| m.severity == Severity::Error)
.map(|m| m.text.as_str())
.collect::<Vec<_>>()
.join("\n"),
Ok(Err(error)) => error.message,
Err(error) => error.to_string(),
let (text, notify) = match other {
Ok(Ok(outcome)) => failed_build(&outcome),
Ok(Err(error)) => (error.message, true),
Err(error) => (error.to_string(), true),
};
self.failure(text, &mut diagnostics, &mut last_failure);
self.report(text, notify, &mut diagnostics, &mut last_failure);
remove_directory(directory).await;
}
}
@@ -276,6 +270,12 @@ impl KotlinBridge {
}
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
self.report(text, true, paths, previous);
}
/// Publishes a failed build in Problems (on `pom.xml`) and, when `notify`, as a
/// notification once per distinct message.
fn report(&self, text: String, notify: bool, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
self.clear_diagnostics(paths);
@@ -295,7 +295,7 @@ impl KotlinBridge {
deprecated: false,
}],
});
if previous != &text {
if notify && previous != &text {
self.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "Kotlin editor compilation failed".into(),
@@ -306,6 +306,15 @@ impl KotlinBridge {
}
}
/// The error text of a failed bridge build, and whether to notify about it. Compiler errors
/// in the user's own Kotlin already show in its editor and stay in Problems; only a build
/// that failed without one (Maven itself, a missing plugin) is worth a notification.
fn failed_build(outcome: &Outcome) -> (String, bool) {
let errors: Vec<_> = outcome.messages.iter().filter(|m| m.severity == Severity::Error).collect();
let text = errors.iter().map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
(text, !errors.iter().any(|m| m.path.is_some()))
}
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
let input = path.extension().is_some_and(|e| e == "kt")
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
@@ -383,6 +392,23 @@ mod tests {
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
}
#[test]
fn source_errors_stay_out_of_notifications() {
let message = |text: &str, path: Option<&str>| ide_build::compile::BuildMessage {
severity: Severity::Error,
text: text.into(),
path: path.map(|p| PathBuf::from(p).into()),
range: None,
code: None,
};
let outcome = |messages| Outcome { projects: Vec::new(), messages, success: false };
let (text, notify) = failed_build(&outcome(vec![message("No value passed", Some("/p/src/A.kt"))]));
assert_eq!((text.as_str(), notify), ("No value passed", false));
let (text, notify) = failed_build(&outcome(vec![message("Plugin not found", None)]));
assert_eq!((text.as_str(), notify), ("Plugin not found", true));
assert!(failed_build(&outcome(Vec::new())).1);
}
#[test]
fn extensionless_root_file_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
+142 -30
Переглянути файл
@@ -296,39 +296,142 @@ impl crate::Host {
}
}
/// Commands which only inspect/edit source or operate user-owned application state.
/// New execution commands are denied until deliberately classified here.
/// Commands allowed in Restricted Mode, by exact name: those which only inspect/edit source or
/// operate user-owned application state. A new command is denied until deliberately listed here.
pub fn restricted_command_allowed(cmd: &str) -> bool {
if [
"fs_",
"doc_",
"syntax_",
"search_",
"nav_",
"history_",
"spell_",
"analysis_",
"structural_",
"refactor_",
"bytes_",
"window_",
"window_group_",
"term_",
"settings_",
"project_trust_",
// New Project writes only where the user chose and runs no code of an open project.
"scaffold_",
"ssh_",
"git_credential",
]
.iter()
.any(|p| cmd.starts_with(p))
{
return true;
}
matches!(
cmd,
"app_info"
"fs_compare_directories"
| "fs_copy"
| "fs_copy_merge"
| "fs_create_dir"
| "fs_create_file"
| "fs_create_file_bytes"
| "fs_delete"
| "fs_entry_kind"
| "fs_open_with_system"
| "fs_probe"
| "fs_read_dir"
| "fs_read_image"
| "fs_read_range"
| "fs_read_text"
| "fs_rename"
| "fs_resolve_reference"
| "fs_set_writable"
| "doc_close"
| "doc_edit"
| "doc_folding"
| "doc_highlights"
| "doc_open"
| "doc_open_untitled"
| "doc_open_virtual"
| "doc_outline"
| "doc_python_facts"
| "doc_save"
| "doc_save_as"
| "doc_save_all"
| "doc_selection_ranges"
| "doc_set_bom"
| "doc_set_encoding"
| "doc_set_line_ending"
| "doc_snapshot"
| "doc_sync_from_disk"
| "doc_ui_mode"
| "syntax_errors"
| "syntax_expression_at"
| "syntax_identifiers"
| "syntax_identifiers_in_files"
| "syntax_locals"
| "syntax_node_at"
| "syntax_structural_ranges"
| "syntax_support"
| "search_start"
| "search_validate"
| "nav_index_definitions"
| "nav_inheritance"
| "nav_resolve_module"
| "history_content"
| "history_list"
| "history_list_dir"
| "history_put_label"
| "history_restore"
| "spell_check"
| "spell_suggest"
| "analysis_locate_duplicates"
| "structural_languages"
| "structural_search"
| "structural_validate"
| "refactor_can_undo_file_move"
| "refactor_file_move_edits"
| "refactor_file_move_text_edits"
| "refactor_move_files"
| "refactor_undo_file_move"
| "window_close_context"
| "window_complete_launch"
| "window_cycle"
| "window_flash_taskbar"
| "window_group_cancel_close"
| "window_group_close"
| "window_group_close_tabs"
| "window_group_current"
| "window_group_detach"
| "window_group_merge"
| "window_group_reorder"
| "window_group_restore"
| "window_group_select"
| "window_is_fullscreen"
| "window_new"
| "window_open_comparison"
| "window_open_deployment"
| "window_open_diff"
| "window_open_file_in_new_window"
| "window_open_files"
| "window_open_project"
| "window_open_projects"
| "window_open_remote"
| "window_open_settings"
| "window_set_titlebar_height"
| "window_start_drag"
| "window_take_launch"
| "window_toggle_fullscreen"
| "window_toggle_zoom"
| "term_available_shells"
| "term_default_shell"
| "term_is_busy"
| "term_kill"
| "term_resize"
| "term_spawn"
| "term_store_image"
| "term_write"
| "settings_export"
| "settings_get"
| "settings_get_global"
| "settings_import"
| "settings_list_jetbrains_ides"
| "settings_read_jetbrains"
| "settings_set"
| "project_trust_add_location"
| "project_trust_list"
| "project_trust_remove_location"
| "project_trust_set"
| "project_trust_status"
// New Project writes only where the user chose and runs no code of an open project.
| "scaffold_catalog"
| "scaffold_create"
| "scaffold_options"
| "scaffold_requirements"
| "ssh_askpass_answer"
| "ssh_connected"
| "ssh_disconnect"
| "ssh_needs_secret"
| "ssh_open_terminal"
| "ssh_password_saved"
| "ssh_set_secret"
| "ssh_test"
| "ssh_trust_host"
| "git_credential_forget"
| "git_credentials"
| "app_info"
| "app_build_info"
| "app_cli_install"
| "app_cli_uninstall"
@@ -342,6 +445,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "clear_caches"
| "clipboard_file_list"
| "clipboard_has_files"
| "clipboard_has_image"
| "clipboard_image"
| "clipboard_set_file_list"
| "clipboard_write_text"
@@ -572,6 +676,14 @@ mod tests {
assert!(super::restricted_command_allowed("clipboard_write_text"));
}
#[test]
fn restricted_mode_denies_new_commands_whatever_their_name() {
for cmd in ["fs_new_command", "term_new_command", "ssh_new_command", "settings_new_command", "git_credential_x"]
{
assert!(!super::restricted_command_allowed(cmd), "{cmd} is allowed without being listed");
}
}
#[test]
fn remote_project_decisions_are_kept_on_this_machine() {
let tmp = tempfile::tempdir().unwrap();
+1
Переглянути файл
@@ -46,6 +46,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"clear_caches",
"clipboard_file_list",
"clipboard_has_files",
"clipboard_has_image",
"clipboard_image",
"clipboard_set_file_list",
"clipboard_write_text",
+171
Переглянути файл
@@ -12,6 +12,7 @@ pub(super) fn detect(dir: &Path, out: &mut Vec<RunConfig>) {
let Some(src) = read(&dir.join("build.zig")) else { return };
let src = code_only(&src);
out.push(config(BUILD_ID, "zig build", "zig", "zig", &["build"]));
let roots = test_roots(&src);
for step in steps(&src) {
// `install` is what plain `zig build` runs.
if step == "install" || step == "uninstall" {
@@ -20,6 +21,9 @@ pub(super) fn detect(dir: &Path, out: &mut Vec<RunConfig>) {
let mut c = config(format!("zig:{step}"), format!("zig build {step}"), "zig", "zig", &["build", &step]);
if step == "test" || step.starts_with("test-") || step.starts_with("test_") {
c = c.test();
if let Some((_, root)) = roots.iter().find(|(name, _)| *name == step) {
c.extras.params.insert("testRoot".into(), root.clone());
}
}
out.push(c);
}
@@ -161,6 +165,137 @@ fn executables(src: &str) -> Vec<Executable> {
out
}
/// The call's argument text: from just after its `(` to the matching `)`.
fn call_body(src: &str, start: usize) -> &str {
let (mut depth, mut in_string, mut escaped) = (1usize, false, false);
for (i, c) in src[start..].char_indices() {
if in_string {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'(' | '{' | '[' => depth += 1,
')' | '}' | ']' => {
depth -= 1;
if depth == 0 {
return &src[start..start + i];
}
}
_ => {}
}
}
src
}
/// The first `.root_source_file = <receiver>.path("…")` in `body`.
fn root_source_file(body: &str) -> Option<String> {
for (i, _) in body.match_indices(".root_source_file") {
let Some(v) = body[i + ".root_source_file".len()..].trim_start().strip_prefix('=') else { continue };
let v = v.trim_start();
if let Some(call) = v.find(".path(")
&& v[..call].chars().all(|c| c.is_alphanumeric() || c == '_')
{
return string_at(&v[call + ".path(".len()..]);
}
}
None
}
/// The identifier assigned just before `at`: `const mod = b.addModule(…` → `mod`.
fn assigned_var(src: &str, at: usize) -> Option<&str> {
let before = src[..at].trim_end();
let before = before.strip_suffix('.')?.trim_end();
// Drop the receiver (`b`) that sits between the `=` and the call.
let receiver =
before.char_indices().rev().take_while(|(_, c)| c.is_alphanumeric() || *c == '_').last().map(|(i, _)| i)?;
let before = before[..receiver].trim_end();
let before = before.strip_suffix('=')?.trim_end();
let start =
before.char_indices().rev().take_while(|(_, c)| c.is_alphanumeric() || *c == '_').last().map(|(i, _)| i)?;
Some(&before[start..])
}
/// The test root behind each test-named step (`zig build <step>`): the step's `dependOn` runs
/// an `addTest` artifact — a variable (`const tests = b.addTest(…)`) or an inline call — whose
/// module's `root_source_file` this resolves (through `addModule`/`createModule` variables).
/// `zig build` forwards no args to the test binary, so rerunning a single failed test goes
/// through a standalone `zig test <root> --test-filter …` on the step's own test module.
fn test_roots(src: &str) -> Vec<(String, String)> {
let mut modules: Vec<(String, String)> = Vec::new();
for key in ["addModule(", "createModule("] {
for (i, _) in src.match_indices(key) {
let Some(root) = root_source_file(call_body(src, i + key.len())) else { continue };
if let Some(var) = assigned_var(src, i)
&& !modules.iter().any(|(m, _)| *m == var)
{
modules.push((var.to_owned(), root));
}
}
}
let module_root = |body: &str| -> Option<String> {
root_source_file(body).or_else(|| {
let at = body.find(".root_module")?;
let v = body[at + ".root_module".len()..].trim_start().strip_prefix('=')?.trim_start();
let var: String = v.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
modules.iter().find(|(m, _)| *m == var).map(|(_, root)| root.clone())
})
};
let mut tests: Vec<(String, String)> = Vec::new();
for (i, _) in src.match_indices("addTest(") {
let Some(root) = module_root(call_body(src, i + "addTest(".len())) else { continue };
if let Some(var) = assigned_var(src, i)
&& !tests.iter().any(|(v, _)| *v == var)
{
tests.push((var.to_owned(), root));
}
}
let mut steps: Vec<(String, String)> = Vec::new();
for (i, _) in src.match_indices(".step(\"") {
let Some(name) = string_at(&src[i + ".step(".len()..]) else { continue };
// The match includes the dot, so the assignment ends one character earlier.
if let Some(var) = assigned_var(src, i + 1) {
steps.push((var.to_owned(), name));
}
}
let mut out: Vec<(String, String)> = Vec::new();
for (i, _) in src.match_indices(".dependOn(&") {
let Some(step) = receiver_var(src, i + 1) else { continue };
let Some((_, name)) = steps.iter().find(|(v, _)| v == step) else { continue };
let after = &src[i + ".dependOn(&".len()..];
let artifact = if let Some(at) = after.find("addRunArtifact(") {
let v = &after[at + "addRunArtifact(".len()..];
let var: String = v.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
tests.iter().find(|(v, _)| *v == var).map(|(_, root)| root.clone())
} else if let Some(at) = after.find("addTest(") {
module_root(call_body(after, at + "addTest(".len()))
} else {
None
};
if let Some(root) = artifact
&& name.starts_with("test")
&& !out.iter().any(|(n, _)| n == name)
{
out.push((name.clone(), root));
}
}
out
}
/// The identifier a method is called on just before `at`: `test_step.dependOn(…` → `test_step`.
fn receiver_var(src: &str, at: usize) -> Option<&str> {
let before = src[..at].trim_end();
let before = before.strip_suffix('.')?;
let start =
before.char_indices().rev().take_while(|(_, c)| c.is_alphanumeric() || *c == '_').last().map(|(i, _)| i)?;
Some(&before[start..])
}
#[cfg(test)]
mod tests {
use super::*;
@@ -190,7 +325,9 @@ pub fn build(b: *std.Build) void {
}) });
_ = tool;
const run_step = b.step("run", "Run the app");
const tests = b.addTest(.{ .root_module = mod });
const test_step = b.step("test", "Run tests");
test_step.dependOn(&b.addRunArtifact(tests).step);
const docs = b.step("docs", "Generate docs // not a comment");
_ = .{ run_step, test_step, docs };
}
@@ -228,5 +365,39 @@ pub fn build(b: *std.Build) void {
assert_eq!(app.extras.before_launch[0].kind, "run");
assert_eq!(app.extras.before_launch[0].target, BUILD_ID);
assert_eq!(app.extras.params.get("source").map(String::as_str), Some("src/main.zig"));
assert_eq!(test.extras.params.get("testRoot").map(String::as_str), Some("src/root.zig"));
}
const TEST_ROOTS: &str = r#"const std = @import("std");
pub fn build(b: *std.Build) void {
const tally = b.addModule("tally", .{ .root_source_file = b.path("src/tally.zig") });
const unit = b.addTest(.{ .root_module = tally });
const test_step = b.step("test", "Run the unit tests");
test_step.dependOn(&b.addRunArtifact(unit).step);
const failing = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/failing.zig"),
}) });
const test_failing_step = b.step("test-failing", "Run the failing tests");
test_failing_step.dependOn(&b.addRunArtifact(failing).step);
const direct = b.addTest(.{ .root_source_file = b.path("src/direct.zig") });
const test_direct_step = b.step("test-direct", "Run the direct tests");
test_direct_step.dependOn(&b.addRunArtifact(direct).step);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&b.addRunArtifact(unit).step);
}
"#;
#[test]
fn test_roots_come_from_the_step_s_own_artifact() {
let src = code_only(TEST_ROOTS);
assert_eq!(
test_roots(&src),
[
("test".to_owned(), "src/tally.zig".to_owned()),
("test-failing".to_owned(), "src/failing.zig".to_owned()),
("test-direct".to_owned(), "src/direct.zig".to_owned()),
]
);
}
}
+28 -3
Переглянути файл
@@ -331,8 +331,8 @@ pub fn env_wrapper(bin_dir: &Path, command: &str, target: &Path, env: &[(&str, &
#[cfg(not(unix))]
{
let link = bin_dir.join(format!("{command}.cmd"));
let vars: String = env.iter().map(|(k, v)| format!("set \"{k}={}\"\r\n", v.display())).collect();
fs::write(&link, format!("@echo off\r\nsetlocal\r\n{vars}\"{}\" %*\r\n", target.display()))?;
let vars: String = env.iter().map(|(k, v)| format!("set \"{k}={}\"\r\n", cmd_path(bin_dir, v))).collect();
fs::write(&link, format!("@echo off\r\nsetlocal\r\n{vars}\"{}\" %*\r\n", cmd_path(bin_dir, target)))?;
Ok(link)
}
}
@@ -357,11 +357,25 @@ pub fn link_executable(bin_dir: &Path, command: &str, target: &Path) -> IdeResul
{
let link = bin_dir.join(format!("{command}.cmd"));
let runner = if target.extension().is_some_and(|e| e == "js") { "node " } else { "" };
fs::write(&link, format!("@echo off\r\n{runner}\"{}\" %*\r\n", target.display()))?;
fs::write(&link, format!("@echo off\r\n{runner}\"{}\" %*\r\n", cmd_path(bin_dir, target)))?;
Ok(link)
}
}
/// `path` as a `.cmd` shim in `bin_dir` names it: relative to the shim (`%~dp0..\…`) when it
/// is in the tool's own directory. cmd.exe reads a batch file in the console's code page, so
/// an absolute path through a non-ASCII user directory, written as UTF-8, isn't found (#3338).
#[cfg_attr(unix, allow(dead_code))]
fn cmd_path(bin_dir: &Path, path: &Path) -> String {
match bin_dir.parent().and_then(|tool| path.strip_prefix(tool).ok()) {
Some(rest) => {
let rest: Vec<_> = rest.components().map(|c| c.as_os_str().to_string_lossy()).collect();
format!("%~dp0..\\{}", rest.join("\\"))
}
None => path.display().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -743,6 +757,17 @@ mod tests {
assert!(found.ends_with("server/com.microsoft.java.debug.plugin-0.53.2.jar"));
}
#[test]
fn windows_shims_name_the_tool_relative_to_themselves() {
let tool = Path::new("/Users/张三/AppData/Local/ai.l0ng.openidea/tools/taplo");
let bin = tool.join("bin");
assert_eq!(cmd_path(&bin, &tool.join("taplo.exe")), "%~dp0..\\taplo.exe");
assert_eq!(cmd_path(&bin, &tool.join("gems/bin/solargraph")), "%~dp0..\\gems\\bin\\solargraph");
// Outside the tool's directory (a system SDK) stays absolute.
let sdk = Path::new("/usr/local/share/dotnet");
assert_eq!(cmd_path(&bin, sdk), sdk.display().to_string());
}
#[cfg(unix)]
#[test]
fn wrapper_runs_target() {
+2 -2
Переглянути файл
@@ -197,5 +197,5 @@ You don't write anything special for remote development. In a remote window, `sr
1. Put the logic in the owning crate; convert errors to `IdeError` at its edge; use IPC coordinates.
2. Add the shared implementation to `ide-host::ops::<domain>` if it touches a project; register it with the remote dispatcher if remote windows need it.
3. Add the `#[tauri::command] #[specta::specta]` adapter in `src-tauri/src/commands/<domain>.rs` and list it in `collect_commands!`.
4. If it executes anything, leave it out of the restricted-mode allowlist (it is denied in untrusted projects by default).
5. `pnpm gen:bindings`, then call it with `call(commands.yourCommand(...))`.
4. Decide its trust class: list it by exact name in `restricted_command_allowed` (also in Restricted Mode) or `projectless_command_allowed` (also with no project open), or leave it out to require a trusted project. There are no name prefixes; if it executes anything, leave it out.
5. `pnpm gen:bindings`, then call it with `call(commands.yourCommand(...))`. It also rewrites `src-tauri/tests/commands.snap`; check that the new line shows the trust class and placement you meant.
+2
Переглянути файл
@@ -28,6 +28,8 @@ Style components with CSS Modules: `Thing.module.css` next to `Thing.tsx`, impor
Build from the primitives in `src/ui/` before writing your own: `Button`, `BarButton`, `Input`, `Checkbox`, `Select`, `Dialog`, `Popup`, `Menu`, `Tooltip`, `List`, `Tree`, `Tabs`, `Splitter`, `Scrollbar`, `Spinner`, `Kbd`, and `T` for translated text. They already handle focus, keyboard navigation, theming and translation of their labels. Workbench-level pieces (tool windows, status items, overlays) come from [contributions](/en/contribute/architecture/frontend#contributions), not from custom layout.
Toolbar icon actions use `IconButton`: its full hit target uses `--bg-toolbar-hover` on hover, while pressed and selected states use their corresponding toolbar tokens. Keep semantic icon colours and the keyboard focus ring intact.
## Accessibility
- Use real `<button>` and `<input>` elements (or the primitives that render them), never clickable `<div>`s.
+1 -1
Переглянути файл
@@ -31,7 +31,7 @@ Opening a project folder can run code that the project controls: language server
- When a project that hasn't been decided opens, the **Trust and Open Project?** dialog offers **Trust Project**, **Preview in Restricted Mode** and **Don't Open**. Decisions and trusted locations are managed on Settings' **Project Trust** page (group **Security**), and **Revoke Trust** is an action.
- Trust is user-owned state (`crates/ide-host/src/trust.rs`). **Project settings never grant trust.**
- Enforcement is in the backend, not the UI: every IPC command passes `Host::authorize_command` in the Tauri invoke handler (`src-tauri/src/lib.rs`) before it runs. In an untrusted project only commands on an explicit allowlist (`restricted_command_allowed`) run: reading and editing files and documents, syntax, search, navigation, local history, spelling, analysis, refactoring, settings and similar.
- The allowlist is a default-deny list: **a new command that executes anything is denied in restricted mode until you deliberately classify it there.** Don't add an execution command to the allowlist to make a feature "work" in restricted mode.
- The allowlist is a default-deny list of exact names: **a new command that executes anything is denied in restricted mode until you deliberately classify it there.** Don't add an execution command to the allowlist to make a feature "work" in restricted mode. `src-tauri/tests/commands.snap` lists every command's trust class, so a change shows up in review.
- Below the command layer, language servers don't start (`LspManager` execution switch), and git runs with execution disabled and without the project's `git` executable setting until the project is trusted. Project plugins (`.openidea/plugins/`) load only for trusted projects.
- In a remote window the desktop checks the remote project's trust before forwarding a command, and `openidea-server` authorizes every `invoke` again.
+6 -6
Переглянути файл
@@ -7,12 +7,12 @@ Smoke tests check each language end to end with the real tools the IDE uses: lan
They are not part of `cargo nextest run` or `pnpm test`, because they need the tools installed. CI runs them in the **Language smoke** workflow with pinned versions.
| Suite | What it checks | Languages |
| ----------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------- |
| `crates/ide-host/tests/language_smoke.rs` | Code intelligence through the `ops` the app's commands call | typescript, python, rust, go, java, kotlin, php |
| `crates/ide-host/tests/project_smoke.rs` | Opening a fixture with its build tool and SDK | `maven_sync`, `jdk`, `gradle_sync`, `python_venv` |
| `smoke/run.test.ts` | Run and test configurations through the run store and a real `openidea-server` | typescript, python, rust, go, java, kotlin, php |
| `smoke/debug.test.ts` | Debugging the entry point with the debugger the IDE installs | typescript, python, rust, go, java, kotlin, c, cpp, php |
| Suite | What it checks | Languages |
| ----------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------ |
| `crates/ide-host/tests/language_smoke.rs` | Code intelligence through the `ops` the app's commands call | typescript, python, rust, go, java, kotlin, php |
| `crates/ide-host/tests/project_smoke.rs` | Opening a fixture with its build tool and SDK | `maven_sync`, `jdk`, `gradle_sync`, `python_venv` |
| `smoke/run.test.ts` | Run and test configurations through the run store and a real `openidea-server` | typescript, python, rust, go, java, kotlin, php, zig |
| `smoke/debug.test.ts` | Debugging the entry point with the debugger the IDE installs | typescript, python, rust, go, java, kotlin, c, cpp, zig, php |
## Language smoke
+2 -2
Переглянути файл
@@ -197,5 +197,5 @@ onIdeEvent("projectTrustChanged", (e) => {
1. 逻辑放在所属的 crate 里;在其边界把错误转成 `IdeError`;使用 IPC 坐标。
2. 如果涉及项目,把共享实现加到 `ide-host::ops::<domain>`;远程窗口需要的话,注册到远程 dispatcher。
3. 在 `src-tauri/src/commands/<domain>.rs` 添加 `#[tauri::command] #[specta::specta]` 适配函数,并加入 `collect_commands!`。
4. 如果它会执行任何东西,不要把它加进受限模式白名单(未受信任的项目里默认拒绝)。
5. 运行 `pnpm gen:bindings`,然后用 `call(commands.yourCommand(...))` 调用。
4. 决定它的信任级别:在 `restricted_command_allowed`(受限模式下也可运行)或 `projectless_command_allowed`(未打开项目时也可运行)中按完整名称列出它,或者不列出,即需要受信任的项目。没有名称前缀规则;会执行任何东西的命令不要列出。
5. 运行 `pnpm gen:bindings`,然后用 `call(commands.yourCommand(...))` 调用。它也会重写 `src-tauri/tests/commands.snap`;检查新增的那一行是否是你想要的信任级别和运行位置。
+2
Переглянути файл
@@ -28,6 +28,8 @@ description: "前端 UI 代码的规则:设计 token、CSS Modules、图标、
先用 `src/ui/` 里的基础组件,再考虑自己写:`Button`、`BarButton`、`Input`、`Checkbox`、`Select`、`Dialog`、`Popup`、`Menu`、`Tooltip`、`List`、`Tree`、`Tabs`、`Splitter`、`Scrollbar`、`Spinner`、`Kbd`,以及用于翻译文本的 `T`。它们已经处理好焦点、键盘导航、主题和标签翻译。工作台级别的部件(工具窗口、状态栏项、浮层)来自 [contribution](/zh/contribute/architecture/frontend),不要自己写布局。
工具栏图标操作使用 `IconButton`:悬停时整个点击区域使用 `--bg-toolbar-hover`,按下和选中状态使用对应的工具栏 token。保留图标的语义颜色和键盘焦点环。
## 无障碍
- 使用真正的 `<button>` 和 `<input>` 元素(或渲染它们的基础组件),不要用可点击的 `<div>`。
+1 -1
Переглянути файл
@@ -28,7 +28,7 @@ description: "如何报告漏洞,以及贡献者需要维护的安全模型:
- 打开一个尚未做过决定的项目时,会弹出 **信任并打开项目?** 对话框,提供 **信任项目**、**以受限模式预览** 和 **不打开**。已有决定和受信任的位置在设置的 **项目信任** 页面(**安全** 分组)里管理,**撤销信任** 是一个 action。
- 信任是用户自己的状态(`crates/ide-host/src/trust.rs`)。**项目设置永远不能授予信任。**
- 拦截在后端而不是 UI:每个 IPC 命令执行前,都要在 Tauri 的 invoke handler(`src-tauri/src/lib.rs`)里经过 `Host::authorize_command`。未受信任的项目里,只有显式白名单(`restricted_command_allowed`)中的命令能运行:读写文件和文档、语法、搜索、导航、本地历史、拼写、分析、重构、设置等。
- 这份白名单是默认拒绝的:**新增的任何会执行东西的命令,在你有意识地把它归类之前,受限模式下一律拒绝。** 不要为了让某个功能在受限模式下“能用”就把执行类命令加进白名单。
- 这份白名单按完整名称列出,默认拒绝:**新增的任何会执行东西的命令,在你有意识地把它归类之前,受限模式下一律拒绝。** 不要为了让某个功能在受限模式下“能用”就把执行类命令加进白名单。`src-tauri/tests/commands.snap` 列出每个命令的信任级别,改动会出现在审查的 diff 里。
- 在命令层之下,项目受信任之前语言服务器不会启动(`LspManager` 的执行开关),git 以禁止执行的方式运行,也不使用项目里配置的 `git` 可执行文件。项目插件(`.openidea/plugins/`)只在受信任的项目中加载。
- 远程窗口中,桌面端在转发命令前会先确认远程项目是否受信任,`openidea-server` 对每个 `invoke` 还会再校验一次。
+6 -6
Переглянути файл
@@ -7,12 +7,12 @@ description: "在 fixtures 示例项目上,用真实语言服务器跑语言
由于需要安装这些工具,冒烟测试不包含在 `cargo nextest run` 或 `pnpm test` 中。CI 在 **Language smoke** 工作流里用固定版本运行它们。
| 套件 | 检查内容 | 语言 |
| ----------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------- |
| `crates/ide-host/tests/language_smoke.rs` | 经由应用命令所调用的 `ops` 检查代码智能 | typescript、python、rust、go、java、kotlin、php |
| `crates/ide-host/tests/project_smoke.rs` | 用构建工具和 SDK 打开示例项目 | `maven_sync`、`jdk`、`gradle_sync`、`python_venv` |
| `smoke/run.test.ts` | 通过 run store 和真实的 `openidea-server` 执行运行与测试配置 | typescript、python、rust、go、java、kotlin、php |
| `smoke/debug.test.ts` | 用 IDE 安装的调试器调试入口点 | typescript、python、rust、go、java、kotlin、c、cpp、php |
| 套件 | 检查内容 | 语言 |
| ----------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| `crates/ide-host/tests/language_smoke.rs` | 经由应用命令所调用的 `ops` 检查代码智能 | typescript、python、rust、go、java、kotlin、php |
| `crates/ide-host/tests/project_smoke.rs` | 用构建工具和 SDK 打开示例项目 | `maven_sync`、`jdk`、`gradle_sync`、`python_venv` |
| `smoke/run.test.ts` | 通过 run store 和真实的 `openidea-server` 执行运行与测试配置 | typescript、python、rust、go、java、kotlin、php、zig |
| `smoke/debug.test.ts` | 用 IDE 安装的调试器调试入口点 | typescript、python、rust、go、java、kotlin、c、cpp、zig、php |
## 语言冒烟测试
+64 -2
Переглянути файл
@@ -174,12 +174,36 @@ test("⌘R replaces every match in the file", async ({ workbench: page }) => {
await expect(editor.locator(".cm-line", { hasText: "OrderDto" })).toHaveCount(2);
});
test("Markdown preview highlights ⌘F matches and selected word occurrences", async ({ workbench: page }) => {
test("Markdown preview highlights ⌘F matches, selected words and scales diagrams with font zoom", async ({
workbench: page,
}) => {
await page.evaluate(async () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 96" width="320" height="96"><!-- svg-source:excalidraw --><rect width="320" height="96"/></svg>';
const url = `data:image/svg+xml;base64,${btoa(svg)}`;
const routeModule = "/src/platform/tauriCore.ts";
const { setInvokeRoute } = await import(/* @vite-ignore */ routeModule);
setInvokeRoute((cmd: string) => (cmd === "fs_read_image" ? Promise.resolve(url) : null));
});
const editor = await openFromTree(page, "README.md");
const content = editor.locator(".cm-content");
await content.click();
await page.keyboard.press("Meta+a");
await page.keyboard.insertText("# Order Service\n\nManage **Order** items and `Order` status.\n");
await page.keyboard.insertText(
[
"# Order Service",
"",
"Manage **Order** items and `Order` status.",
"",
"```mermaid",
"flowchart LR",
" A --> B",
"```",
"",
"![Diagram](./arch.excalidraw.svg)",
].join("\n"),
);
const preview = page.getByRole("document", { name: "Markdown preview" });
await expect(preview).toContainText("Manage Order items");
@@ -209,6 +233,44 @@ test("Markdown preview highlights ⌘F matches and selected word occurrences", a
})),
)
.toEqual({ current: ["Order"], other: ["Order", "Order"] });
const mermaidSvg = preview.locator(".mermaid-output svg");
const excalidrawImg = preview.locator('img[alt="Diagram"]');
await expect(mermaidSvg).toBeVisible();
await expect(excalidrawImg).toHaveAttribute("src", /^data:image\/svg\+xml/);
const widths = async () => ({
mermaid: (await mermaidSvg.boundingBox())!.width,
excalidraw: (await excalidrawImg.boundingBox())!.width,
});
const base = await widths();
expect(base.mermaid).toBeGreaterThan(50);
expect(base.excalidraw).toBeCloseTo(320, 0);
const runAction = (id: string, times = 1) =>
page.evaluate(
async ({ id, times }) => {
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
for (let i = 0; i < times; i++) await executeAction(id);
},
{ id, times },
);
await runAction("editor.increaseFontSize", 4);
const zoomedIn = await widths();
expect(zoomedIn.mermaid / base.mermaid).toBeCloseTo(17 / 13, 2);
expect(zoomedIn.excalidraw / base.excalidraw).toBeCloseTo(17 / 13, 2);
await runAction("editor.decreaseFontSize", 6);
const zoomedOut = await widths();
expect(zoomedOut.mermaid / base.mermaid).toBeCloseTo(11 / 13, 2);
expect(zoomedOut.excalidraw / base.excalidraw).toBeCloseTo(11 / 13, 2);
await runAction("editor.resetFontSize");
const reset = await widths();
expect(reset.mermaid).toBeCloseTo(base.mermaid, 1);
expect(reset.excalidraw).toBeCloseTo(base.excalidraw, 1);
});
test("the bookmark gutter takes no width until the file has a bookmark", async ({ workbench: page }) => {
+91
Переглянути файл
@@ -0,0 +1,91 @@
import { expect, openFromTree, test } from "./fixtures";
for (const theme of ["light", "dark"] as const) {
test(`${theme}: toolbar icon buttons provide hover feedback without changing their layout or icons`, async ({
workbench: page,
}) => {
const gear = page.getByRole("button", { name: "IDE and Project Settings", exact: true });
await gear.click();
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
await page
.getByRole("menuitemcheckbox", { name: `Islands ${theme === "light" ? "Light" : "Dark"}`, exact: true })
.click();
await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
const toolbar = page.locator("header").first();
const disabledRun = toolbar.getByRole("button", { name: "Run", exact: true });
await expect(disabledRun).toBeDisabled();
await disabledRun.hover();
await expect(disabledRun).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await openFromTree(page, "App.tsx");
await page.locator("[data-run-chooser]").click();
await page.getByRole("option", { name: "Current File", exact: true }).click();
const project = page.getByRole("region", { name: "Project", exact: true });
const buttons = [
...["Select Opened File", "Expand All", "Collapse All", "Options", "Hide"].map((name) =>
project.getByRole("button", { name, exact: true }),
),
...[
"Run Current File",
"Debug Current File",
"More Actions",
"Search Everywhere",
"IDE and Project Settings",
].map((name) => toolbar.getByRole("button", { name, exact: true })),
];
for (const button of buttons) {
await page.mouse.move(700, 500);
const bounds = await button.boundingBox();
const iconColors = await button.locator("svg [fill], svg [stroke]").evaluateAll((nodes) =>
nodes.map((node) => {
const style = getComputedStyle(node);
return { fill: style.fill, stroke: style.stroke };
}),
);
await expect(button).toBeEnabled();
// Hover inside the hit target but outside its centred icon.
await button.hover({ position: { x: bounds!.width / 2, y: 1 } });
await expect(button).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
expect(await button.boundingBox()).toEqual(bounds);
if (/^(Run|Debug) Current File$/.test((await button.getAttribute("aria-label")) ?? "")) {
expect(
await button.locator("svg [fill], svg [stroke]").evaluateAll((nodes) =>
nodes.map((node) => {
const style = getComputedStyle(node);
return { fill: style.fill, stroke: style.stroke };
}),
),
).toEqual(iconColors);
}
await button.focus();
await page.keyboard.press("Shift+Tab");
await page.keyboard.press("Tab");
await expect(button).toBeFocused();
await expect(button).toHaveCSS("outline-style", "solid");
await expect(button).toHaveCSS("outline-width", "2px");
}
const expand = project.getByRole("button", { name: "Expand All", exact: true });
await expand.hover();
const hover = await expand.evaluate((button) => getComputedStyle(button).backgroundColor);
await page.mouse.down();
await expect(expand).not.toHaveCSS("background-color", hover);
await page.mouse.up();
await expect(project.getByRole("treeitem", { name: "src", exact: true })).toHaveAttribute("aria-expanded", "true");
await project.getByRole("button", { name: "Collapse All", exact: true }).click();
await expect(project.getByRole("treeitem", { name: "src", exact: true })).toHaveAttribute("aria-expanded", "false");
await project.getByRole("button", { name: "Options", exact: true }).click();
await expect(page.getByRole("menu", { name: "Project options", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await toolbar.getByRole("button", { name: "More Actions", exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Edit Configurations…/ })).toBeVisible();
await page.keyboard.press("Escape");
await toolbar.getByRole("button", { name: "Search Everywhere", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Search Everywhere", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await project.getByRole("button", { name: "Hide", exact: true }).click();
await expect(project).toBeHidden();
});
}
+7
Переглянути файл
@@ -28,4 +28,11 @@ pub fn build(b: *std.Build) void {
const tests = b.addTest(.{ .root_module = tally });
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&b.addRunArtifact(tests).step);
const failing_tests = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/failing.zig"),
.target = target,
}) });
const test_failing_step = b.step("test-failing", "Run the tests that fail on purpose");
test_failing_step.dependOn(&b.addRunArtifact(failing_tests).step);
}
+5
Переглянути файл
@@ -0,0 +1,5 @@
//! Fails on purpose: the `test-failing` step, for checking that a run's failure shows up
//! in the test tree and Rerun Failed Tests re-runs it alone.
test "intentionally fails" {
return error.OpenideaFail;
}
+2 -1
Переглянути файл
@@ -3,7 +3,8 @@ const tally = @import("tally");
pub fn main() !void {
const prices = [_]u32{ 4999, 2499, 5999 };
const sum = tally.total(&prices); // Set a breakpoint here
const sum = tally.total(&prices);
var buf: [16]u8 = undefined;
// Set a breakpoint here (QA: debugger) and inspect `sum`.
std.debug.print("Total: {s}\n", .{try tally.format(&buf, sum)});
}
+11
Переглянути файл
@@ -164,6 +164,17 @@ const CASES: Record<string, Case> = {
variable: "line",
evaluate: ["print line", / = /],
},
// Zig ships no debugger of its own: CodeLLDB debugs the binary `zig build` produces.
// The breakpoint lands on the last use of `sum`: zig's debuginfo gives a const local a
// location only within its live range, so `prices` (used earlier) is not in the frame.
zig: {
fixture: "zig-app",
tools: ["codelldb"],
adapter: "codelldb",
file: "src/main.zig",
variable: "sum",
evaluate: ["sum", /13497/],
},
php: {
fixture: "php-app",
setup: (root) =>
+13
Переглянути файл
@@ -135,6 +135,19 @@ const CASES: Record<string, Case> = {
file: "ThresholdCustomsTest.php",
},
},
// The failing run uses the fixture's dedicated `test-failing` step (`zig build` forwards
// no args to the test binary, so a failing test can't be switched on through the config).
zig: {
fixture: "zig-app",
run: { config: "zig:run", prints: "Total: $134.97" },
tests: {
config: "zig:test-failing",
failing: (c) => c,
passed: 0,
failed: "intentionally fails",
file: "src/failing.zig",
},
},
};
let server: Server | null = null;
+17 -9
Переглянути файл
@@ -76,15 +76,26 @@ pub async fn clipboard_set_file_list(app: AppHandle, paths: Vec<PathBuf>) -> Ide
with_clipboard(app, move || Ok(ide_clipboard::set_files(&paths))).await
}
/// The clipboard's image as a PNG data URL, for a paste whose event carried no
/// files: WebKitGTK leaves `clipboardData` empty for a raw image (a screenshot).
/// None when the clipboard holds no image, and always on other platforms.
/// Whether the clipboard holds an image (a screenshot): cheap, for the Paste item's
/// enabled state.
#[tauri::command]
#[specta::specta]
pub fn clipboard_has_image() -> bool {
// Synchronous Tauri commands run on the main thread, as macOS and GTK require.
ide_clipboard::has_image()
}
/// The clipboard's image as a PNG data URL: for a paste whose event carried no files
/// (WebKitGTK leaves `clipboardData` empty for a raw image, a screenshot), and for
/// the project view's Paste, which has no paste event. None when it holds no image.
#[tauri::command]
#[specta::specta]
pub async fn clipboard_image(app: AppHandle) -> IdeResult<Option<String>> {
use base64::Engine;
let data_url =
|bytes: Vec<u8>| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes));
#[cfg(target_os = "linux")]
{
use base64::Engine;
let (tx, rx) = tokio::sync::oneshot::channel();
app.run_on_main_thread(move || {
let sender = std::cell::Cell::new(Some(tx));
@@ -98,14 +109,11 @@ pub async fn clipboard_image(app: AppHandle) -> IdeResult<Option<String>> {
.map_err(|error| {
ide_core::IdeError::internal(format!("Cannot dispatch clipboard access to the main thread: {error}"))
})?;
let png = rx.await.ok().flatten();
Ok(png
.map(|bytes| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes))))
Ok(rx.await.ok().flatten().map(data_url))
}
#[cfg(not(target_os = "linux"))]
{
let _ = app;
Ok(None)
Ok(with_clipboard(app, || Ok(ide_clipboard::png())).await?.map(data_url))
}
}
+1
Переглянути файл
@@ -195,6 +195,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
clipboard::clipboard_text,
clipboard::clipboard_file_list,
clipboard::clipboard_has_files,
clipboard::clipboard_has_image,
clipboard::clipboard_image,
clipboard::clipboard_set_file_list,
fonts::system_font_families,
+699
Переглянути файл
@@ -0,0 +1,699 @@
# Generated by `pnpm gen:bindings`; do not edit. One line per command:
# name, trust class, placement in a remote window, in bindings.ts, how Tauri runs it.
agent_accept_changes trusted project_host bindings async
agent_archive always project_host bindings sync
agent_base_text trusted project_host bindings async
agent_cancel always project_host bindings sync
agent_changes trusted project_host bindings async
agent_commands always project_host bindings async
agent_create trusted project_host bindings async
agent_create_ephemeral trusted desktop bindings async
agent_custom_import trusted desktop bindings sync
agent_custom_remove trusted desktop bindings sync
agent_custom_save trusted desktop bindings sync
agent_delete trusted project_host bindings async
agent_entries always project_host bindings sync
agent_external_sessions trusted project_host bindings async
agent_fork trusted project_host bindings async
agent_grouped_changes trusted project_host bindings async
agent_import_session trusted project_host bindings async
agent_mcp_import always project_host bindings sync
agent_mcp_list always project_host bindings sync
agent_mcp_remove always project_host bindings sync
agent_mcp_save always project_host bindings sync
agent_mcp_set_enabled always project_host bindings sync
agent_probe trusted project_host bindings async
agent_prompt trusted project_host bindings async
agent_providers trusted project_host bindings sync
agent_refresh_models trusted project_host bindings sync
agent_registry_install always project_host bindings async
agent_registry_list always project_host bindings async
agent_registry_remove always project_host bindings async
agent_rename trusted project_host bindings sync
agent_respond trusted project_host bindings sync
agent_rollback trusted project_host bindings async
agent_rollback_changes trusted project_host bindings async
agent_send_queued_now trusted project_host bindings sync
agent_set_command trusted project_host bindings sync
agent_set_config trusted project_host bindings async
agent_set_env trusted project_host bindings sync
agent_stop_background always project_host bindings async
agent_subscribe always project_host bindings sync
agent_unarchive always project_host bindings sync
agent_unqueue always project_host bindings sync
analysis_locate_duplicates always project_host bindings async
android_devices trusted desktop bindings async
android_logcat trusted desktop bindings async
app_build_info always desktop bindings sync
app_cancel_quit always desktop bindings sync
app_check_update always desktop bindings async
app_cli_install always desktop bindings async
app_cli_uninstall always desktop bindings async
app_info always desktop bindings sync
app_install_update always desktop bindings async
app_log_location always desktop bindings sync
app_metrics always desktop bindings async
app_quit always desktop bindings sync
app_release_notes trusted desktop bindings async
app_restart always desktop bindings sync
app_update_announcement always desktop bindings sync
app_update_status always desktop bindings sync
app_update_window_ready always desktop bindings sync
apply_workspace_edit always project_host bindings async
build_compile_file trusted project_host bindings async
build_model trusted project_host bindings async
build_run trusted project_host bindings async
build_sync trusted project_host bindings async
caches_local_data_info trusted desktop bindings sync
caches_schedule_invalidation always desktop bindings sync
caches_set_local_data_dir trusted desktop bindings sync
cancel_request always project_host bindings sync
cancel_task always project_host bindings sync
clear_caches always desktop bindings async
clipboard_file_list always desktop bindings async
clipboard_has_files always desktop bindings sync
clipboard_has_image always desktop bindings sync
clipboard_image always desktop bindings async
clipboard_set_file_list always desktop bindings async
clipboard_text trusted desktop bindings async
clipboard_write_text always desktop bindings async
close_project always project_host bindings spawn
coverage_collect trusted project_host bindings async
coverage_import always project_host bindings async
coverage_prepare trusted project_host bindings async
db_apply_edits trusted project_host bindings async
db_completion trusted project_host bindings async
db_connect trusted project_host bindings async
db_connected trusted project_host bindings sync
db_console_path always project_host bindings sync
db_consoles always project_host bindings sync
db_database_catalog trusted project_host bindings async
db_databases trusted project_host bindings async
db_ddl trusted project_host bindings async
db_disconnect trusted project_host bindings async
db_execute trusted project_host bindings async
db_explain trusted project_host bindings async
db_export_file always desktop bindings async
db_finish_transaction trusted project_host bindings async
db_history trusted project_host bindings async
db_object_ddl trusted project_host bindings async
db_remove_history trusted project_host bindings async
db_retain_catalog trusted project_host bindings async
db_save_sources trusted project_host bindings async
db_saved_catalog trusted project_host bindings async
db_schema_objects trusted project_host bindings async
db_schemas trusted project_host bindings async
db_session_context trusted project_host bindings async
db_set_manual_commit trusted project_host bindings async
db_set_password trusted project_host bindings async
db_set_ssh_secret trusted project_host bindings async
db_sources trusted project_host bindings sync
db_statements trusted project_host bindings sync
db_table_details trusted project_host bindings async
db_table_page trusted project_host bindings async
db_tables trusted project_host bindings async
db_test trusted project_host bindings async
db_test_tunnel trusted project_host bindings async
db_trust_ssh_host trusted project_host bindings async
debug_attachable_processes always project_host bindings async
debug_breakpoint_states trusted project_host bindings sync
debug_capabilities trusted project_host bindings sync
debug_completions trusted project_host bindings async
debug_data_breakpoint_info trusted project_host bindings async
debug_evaluate trusted project_host bindings async
debug_evaluate_in trusted project_host bindings async
debug_exception_filters trusted project_host bindings sync
debug_force_step_over trusted project_host bindings async
debug_free_port trusted project_host bindings async
debug_heap_capture trusted project_host bindings async
debug_heap_classes trusted project_host bindings async
debug_heap_close trusted project_host bindings async
debug_heap_compare trusted project_host bindings async
debug_heap_dominators trusted project_host bindings async
debug_heap_open trusted project_host bindings async
debug_heap_path trusted project_host bindings async
debug_heap_save trusted project_host bindings async
debug_hot_reload trusted desktop bindings async
debug_hot_restart trusted desktop bindings async
debug_js_custom_breakpoints trusted project_host bindings async
debug_js_response_body trusted project_host bindings async
debug_js_response_overrides trusted project_host bindings async
debug_js_start_profile trusted project_host bindings async
debug_js_stop_profile trusted project_host bindings async
debug_jvm_memory_classes trusted project_host bindings async
debug_jvm_memory_fields trusted project_host bindings async
debug_jvm_memory_instances trusted project_host bindings async
debug_jvm_memory_referrers trusted project_host bindings async
debug_listen_for_jvm trusted project_host bindings async
debug_pause trusted project_host bindings async
debug_redefine_classes trusted project_host bindings async
debug_restart_frame trusted project_host bindings async
debug_resume trusted project_host bindings async
debug_run_to trusted project_host bindings async
debug_scopes trusted project_host bindings async
debug_send_input trusted project_host bindings async
debug_sessions always project_host bindings sync
debug_set_breakpoints trusted project_host bindings async
debug_set_data_breakpoints trusted project_host bindings async
debug_set_exception_breakpoints trusted project_host bindings async
debug_set_function_breakpoints trusted project_host bindings async
debug_set_variable trusted project_host bindings async
debug_stack_trace trusted project_host bindings async
debug_start trusted project_host bindings async
debug_step trusted project_host bindings async
debug_step_in_target trusted project_host bindings async
debug_step_in_targets trusted project_host bindings async
debug_stop always project_host bindings async
debug_threads trusted project_host bindings async
debug_variables trusted project_host bindings async
dependency_versions trusted project_host bindings async
deploy_compare trusted project_host bindings async
deploy_coverage always project_host bindings async
deploy_delete_remote trusted project_host bindings async
deploy_download trusted project_host bindings async
deploy_info trusted project_host bindings async
deploy_server_root trusted project_host bindings async
deploy_sync trusted project_host bindings async
deploy_test_connection always project_host bindings async
deploy_upload trusted project_host bindings async
doc_close always project_host bindings async
doc_edit always project_host bindings async
doc_folding always project_host bindings async
doc_highlights always project_host bindings async
doc_open always project_host bindings async
doc_open_untitled always project_host bindings async
doc_open_virtual always project_host bindings async
doc_outline always project_host bindings async
doc_python_facts always project_host bindings async
doc_save always project_host bindings async
doc_save_all always project_host bindings async
doc_save_as always project_host bindings async
doc_selection_ranges always project_host bindings async
doc_set_bom always project_host bindings async
doc_set_encoding always project_host bindings async
doc_set_line_ending always project_host bindings async
doc_snapshot always project_host bindings async
doc_sync_from_disk always project_host bindings async
doc_ui_mode always project_host bindings async
docker_compose_op trusted desktop bindings async
docker_container_op trusted desktop bindings async
docker_container_stats trusted desktop bindings async
docker_container_top trusted desktop bindings async
docker_events trusted desktop bindings async
docker_image_history trusted desktop bindings async
docker_inspect trusted desktop bindings async
docker_logs trusted desktop bindings async
docker_prune trusted desktop bindings async
docker_pull trusted desktop bindings async
docker_registry_login trusted desktop bindings async
docker_remove trusted desktop bindings async
docker_run_env always project_host bindings async
docker_save_settings always desktop bindings async
docker_set_registry_password always desktop bindings async
docker_settings always desktop bindings async
docker_snapshot trusted desktop bindings async
end_process trusted desktop bindings async
env_reload always project_host bindings async
file_dialog_needs_gtk trusted desktop bindings async
find_dirs always project_host bindings sync
find_files always project_host bindings sync
find_files_named always project_host bindings async
find_symbols always project_host bindings sync
fs_compare_directories always project_host bindings async
fs_copy always project_host bindings async
fs_copy_merge always project_host bindings async
fs_create_dir always project_host bindings sync
fs_create_file always project_host bindings sync
fs_create_file_bytes always project_host bindings async
fs_delete always project_host bindings async
fs_entry_kind always project_host bindings async
fs_open_with_system always desktop bindings async
fs_probe always project_host bindings async
fs_read_dir always project_host bindings async
fs_read_image always project_host bindings async
fs_read_range always project_host bindings async
fs_read_text always project_host bindings async
fs_rename always project_host bindings async
fs_resolve_reference always project_host bindings async
fs_set_writable always project_host bindings sync
gh_account_repos trusted desktop bindings async
gh_api trusted desktop bindings async
gh_availability trusted desktop bindings async
gh_pr_checkout trusted desktop bindings async
gh_pr_comment trusted desktop bindings async
gh_pr_create trusted desktop bindings async
gh_pr_list trusted desktop bindings async
gh_pr_prepare_diff trusted desktop bindings async
gh_pr_review trusted desktop bindings async
gh_pr_review_comments trusted desktop bindings async
gh_pr_view trusted desktop bindings async
git_credential_forget always desktop bindings sync
git_credentials always desktop bindings sync
gradle_dependencies trusted project_host bindings async
gradle_link_projects trusted project_host bindings async
gradle_published_catalog trusted project_host bindings async
gradle_unlink_projects trusted project_host bindings async
gtk_pick_paths trusted desktop bindings async
history_content always project_host bindings async
history_list always project_host bindings async
history_list_dir always project_host bindings async
history_put_label always project_host bindings sync
history_restore always project_host bindings async
http_curl trusted desktop bindings async
http_environment_file trusted desktop bindings async
http_environments trusted desktop bindings async
http_execute trusted desktop bindings async
http_from_curl trusted desktop bindings sync
http_history trusted desktop bindings sync
http_history_clear trusted desktop bindings sync
http_history_get trusted desktop bindings sync
http_requests trusted desktop bindings sync
http_variable_definitions trusted desktop bindings async
index_file_extensions always project_host bindings sync
index_status always project_host bindings sync
inspect_code trusted project_host bindings async
java_build_workspace trusted project_host bindings async
java_debug_server trusted project_host bindings async
java_resolve_launch trusted project_host bindings async
jdk_download always project_host bindings async
jdk_download_location always project_host bindings sync
jdk_download_releases always project_host bindings async
jdk_download_vendors always project_host bindings sync
jdtls_required_java always project_host bindings async
jvm_file_presentations always project_host bindings async
languages always desktop bindings sync
light_edit_open always desktop bindings async
light_edit_open_untitled always desktop bindings async
log_frontend always desktop bindings sync
lsp_apply_code_action trusted project_host bindings async
lsp_call_hierarchy trusted project_host bindings async
lsp_can_download_sources trusted project_host bindings async
lsp_code_action_query trusted project_host bindings async
lsp_code_actions trusted project_host bindings async
lsp_code_lens trusted project_host bindings async
lsp_completion trusted project_host bindings async
lsp_document_highlights trusted project_host bindings async
lsp_document_links trusted project_host bindings async
lsp_execute_code_action trusted project_host bindings async
lsp_execute_command trusted project_host bindings async
lsp_format trusted project_host bindings async
lsp_goto trusted project_host bindings async
lsp_goto_implementation trusted project_host bindings async
lsp_hierarchy_members trusted project_host bindings async
lsp_hierarchy_refactor trusted project_host bindings async
lsp_hover trusted project_host bindings async
lsp_implementations trusted project_host bindings async
lsp_inlay_hints trusted project_host bindings async
lsp_inline_completion trusted project_host bindings async
lsp_library_sources trusted project_host bindings async
lsp_prepare_rename trusted project_host bindings async
lsp_prepare_type_hierarchy trusted project_host bindings async
lsp_references trusted project_host bindings async
lsp_rename trusted project_host bindings async
lsp_resend_diagnostics always project_host bindings sync
lsp_resolve_class_file trusted project_host bindings async
lsp_resolve_code_action trusted project_host bindings async
lsp_resolve_command trusted project_host bindings async
lsp_resolve_completion trusted project_host bindings async
lsp_restart trusted project_host bindings async
lsp_restart_named trusted desktop bindings async
lsp_semantic_tokens trusted project_host bindings async
lsp_servers always project_host bindings sync
lsp_signature_help trusted project_host bindings async
lsp_type_hierarchy trusted project_host bindings async
lsp_virtual_document trusted project_host bindings async
lsp_workspace_symbols trusted project_host bindings async
mark_recent always project_host bindings sync
maven_dependency_completion trusted project_host bindings async
maven_dependency_declaration trusted project_host bindings async
maven_exclude_dependency trusted project_host bindings async
maven_goto_declaration trusted project_host bindings async
mcp_agent_run_closed trusted desktop bindings sync
mcp_regenerate_token trusted desktop bindings sync
mcp_report_problems trusted project_host bindings sync
mcp_respond trusted desktop bindings sync
mcp_server_status trusted desktop bindings sync
mcp_set_focused_editor trusted project_host bindings sync
mcp_set_open_editors trusted project_host bindings sync
mcp_ui_ready trusted desktop bindings sync
menu_configure always desktop bindings sync
menu_model always desktop bindings sync
menu_refresh always desktop bindings sync
menu_set_disabled always desktop bindings sync
nav_index_definitions always project_host bindings async
nav_inheritance always project_host bindings async
nav_resolve_module always project_host bindings async
notebook_cell_docs trusted project_host bindings sync
notebook_close always project_host bindings async
notebook_edit always project_host bindings async
notebook_execute trusted project_host bindings async
notebook_get always project_host bindings sync
notebook_interrupt always project_host bindings async
notebook_open always project_host bindings async
notebook_redo always project_host bindings async
notebook_reload always project_host bindings async
notebook_restart trusted project_host bindings async
notebook_save always project_host bindings async
notebook_undo always project_host bindings async
open_project always project_host bindings async
path_completions always project_host bindings async
plugin_activation_event trusted desktop bindings async
plugin_answer_command trusted project_host bindings sync
plugin_answer_commit trusted project_host bindings sync
plugin_answer_prompt trusted project_host bindings sync
plugin_answer_run_target trusted project_host bindings async
plugin_assets always desktop bindings async
plugin_cancel_task trusted project_host bindings sync
plugin_catalog always desktop bindings async
plugin_choose_program outside_restricted project_host bindings async
plugin_color_presentations trusted project_host bindings async
plugin_commit_checks trusted project_host bindings async
plugin_content_tab_action trusted project_host bindings sync
plugin_context_keys trusted project_host bindings sync
plugin_debug_attach_target trusted project_host bindings async
plugin_decoration_sets trusted project_host bindings sync
plugin_endpoints trusted project_host bindings async
plugin_event_kinds trusted project_host bindings async
plugin_finish_operation trusted project_host bindings sync
plugin_formatters trusted project_host bindings sync
plugin_grant outside_restricted desktop bindings async
plugin_install_dir outside_restricted desktop bindings async
plugin_installed_intellij_themes always desktop bindings async
plugin_language_visuals trusted project_host bindings async
plugin_line_markers trusted project_host bindings async
plugin_link trusted desktop bindings async
plugin_list always desktop bindings async
plugin_log_tab_spawn trusted desktop bindings async
plugin_market always desktop bindings async
plugin_market_download always desktop bindings async
plugin_market_url always desktop bindings sync
plugin_navigation_files trusted project_host bindings async
plugin_navigation_goto trusted project_host bindings async
plugin_navigation_index trusted project_host bindings async
plugin_navigation_invalidate trusted project_host bindings sync
plugin_navigation_markers trusted project_host bindings async
plugin_parse_test_results trusted project_host bindings async
plugin_post_events trusted project_host bindings sync
plugin_prepare_debug_run trusted project_host bindings async
plugin_preview_install always desktop bindings async
plugin_program outside_restricted project_host bindings async
plugin_read_intellij_theme always desktop bindings async
plugin_reload outside_restricted desktop bindings async
plugin_revoke_net_host outside_restricted desktop bindings async
plugin_roll_back outside_restricted desktop bindings async
plugin_run_command trusted project_host bindings async
plugin_run_targets trusted project_host bindings async
plugin_secrets trusted desktop bindings sync
plugin_select_tests trusted project_host bindings async
plugin_service_detail trusted project_host bindings async
plugin_service_log trusted project_host bindings async
plugin_set_active_file always project_host bindings sync
plugin_set_capability outside_restricted desktop bindings async
plugin_set_enabled outside_restricted desktop bindings async
plugin_set_market_url always desktop bindings sync
plugin_set_secret trusted desktop bindings sync
plugin_set_theme always desktop bindings sync
plugin_set_theme_frame always desktop bindings sync
plugin_set_ui_locale always project_host bindings async
plugin_stage_package always desktop bindings async
plugin_status_items trusted project_host bindings sync
plugin_subscribe always desktop bindings sync
plugin_sync_languages trusted project_host - -
plugin_sync_packages trusted project_host - -
plugin_terminal_session_close trusted project_host bindings sync
plugin_terminal_session_spawn trusted project_host bindings async
plugin_uninstall outside_restricted desktop bindings async
plugin_unlink trusted desktop bindings async
plugin_unpack_package always desktop bindings async
plugin_view_event trusted project_host bindings async
plugin_view_render trusted project_host bindings async
plugin_web_page trusted desktop bindings sync
plugin_web_pty_spawn trusted desktop bindings async
plugin_web_read_file trusted desktop bindings async
plugin_web_save_file trusted desktop bindings async
prepare_workspace_edit always project_host bindings async
profiler_collect trusted desktop bindings async
profiler_gc_before_stop trusted desktop bindings async
profiler_prepare trusted desktop bindings async
project_current always project_host bindings sync
project_rename always desktop bindings sync
project_roots always project_host bindings sync
project_trust_add_location always desktop bindings async
project_trust_list always project_host bindings sync
project_trust_remove_location always project_host bindings async
project_trust_set always project_host bindings async
project_trust_status always project_host bindings sync
proxy_check_connection always desktop bindings async
recent_files always project_host bindings sync
recent_project_groups always desktop bindings sync
recent_projects always desktop bindings async
redo_workspace_edit always project_host bindings async
refactor_can_undo_file_move always project_host bindings sync
refactor_file_move_edits always project_host bindings async
refactor_file_move_text_edits always project_host bindings async
refactor_move_files always project_host bindings async
refactor_undo_file_move always project_host bindings async
remote_bootstrap_list always desktop bindings async
remote_connect always desktop bindings async
remote_current always desktop bindings sync
remote_delete trusted project_host bindings async
remote_disconnect always desktop bindings sync
remote_download_into trusted project_host bindings async
remote_forward_port trusted desktop bindings async
remote_forwarded_ports trusted desktop bindings sync
remote_import_local trusted desktop bindings async
remote_invoke always desktop bindings async
remote_list always project_host bindings async
remote_mkdir trusted project_host bindings async
remote_of always project_host bindings sync
remote_open always project_host bindings async
remote_rename trusted project_host bindings async
remote_stop_forwarding trusted desktop bindings async
remote_trust_set always desktop bindings sync
remote_trust_status always desktop bindings sync
remove_recent always project_host bindings sync
remove_recent_project always desktop bindings sync
replace_in_files trusted project_host bindings async
replace_preview trusted project_host bindings async
run_application_build trusted project_host bindings async
run_application_publish trusted project_host bindings async
run_apply_settings trusted project_host bindings async
run_close_stdin trusted project_host bindings sync
run_configs trusted project_host bindings async
run_dotnet_resolve trusted project_host bindings async
run_env_files trusted project_host bindings async
run_gradle_init_script trusted project_host bindings async
run_gradle_jvm_args_script always project_host bindings async
run_import_configs trusted project_host bindings async
run_import_sources trusted project_host bindings async
run_list always project_host bindings sync
run_resize trusted project_host bindings sync
run_save_configs trusted project_host bindings async
run_save_templates trusted project_host bindings async
run_start trusted project_host bindings async
run_stdin trusted project_host bindings sync
run_stop always project_host bindings sync
run_system_env always project_host bindings sync
run_templates trusted project_host bindings async
run_test_reports trusted project_host bindings async
run_tomcat_artifacts trusted project_host bindings async
run_tomcat_build trusted project_host bindings async
run_tomcat_home trusted project_host bindings async
run_tomcat_installations trusted project_host bindings async
run_tomcat_sync trusted project_host bindings async
run_tomcat_update trusted project_host bindings async
run_without_derived trusted project_host bindings async
scaffold_catalog always project_host bindings sync
scaffold_create always project_host bindings async
scaffold_options always project_host bindings async
scaffold_requirements always project_host bindings async
sdk_container_python trusted project_host bindings sync
sdk_container_python_refresh trusted project_host bindings async
sdk_detect trusted project_host bindings async
sdk_locate_tools trusted project_host bindings async
sdk_php_composer_install trusted project_host bindings async
sdk_php_effective trusted project_host bindings sync
sdk_php_language_level trusted project_host bindings async
sdk_php_xdebug trusted project_host bindings async
sdk_project_libraries trusted project_host bindings async
sdk_python_create_venv trusted project_host bindings async
sdk_python_effective trusted project_host bindings sync
sdk_python_interpreter trusted project_host bindings async
sdk_python_packages trusted project_host bindings async
sdk_python_pip trusted project_host bindings async
sdk_python_project_interpreters trusted project_host bindings async
search_start always project_host bindings async
search_validate always project_host bindings sync
set_recent_project_groups always desktop bindings sync
settings_export always desktop bindings async
settings_get always project_host bindings sync
settings_get_global always desktop bindings sync
settings_import always desktop bindings async
settings_list_jetbrains_ides always desktop bindings async
settings_read_jetbrains always desktop bindings async
settings_set always project_host bindings sync
spell_check always project_host bindings async
spell_suggest always project_host bindings async
spring_route_sources trusted desktop bindings sync
ssh_askpass_answer always desktop bindings sync
ssh_connected always project_host bindings sync
ssh_disconnect always project_host bindings sync
ssh_needs_secret always project_host bindings sync
ssh_open_terminal always desktop bindings async
ssh_password_saved always project_host bindings sync
ssh_set_secret always project_host bindings sync
ssh_test always project_host bindings async
ssh_trust_host always project_host bindings async
structural_languages always project_host bindings async
structural_search always project_host bindings async
structural_validate always project_host bindings async
syntax_errors always project_host bindings async
syntax_expression_at always project_host bindings async
syntax_identifiers always project_host bindings async
syntax_identifiers_in_files always project_host bindings async
syntax_locals always project_host bindings async
syntax_node_at always project_host bindings async
syntax_structural_ranges always project_host bindings async
syntax_support always project_host bindings sync
system_font_families always desktop bindings async
system_probe_hotkeys trusted desktop bindings sync
term_available_shells always project_host bindings sync
term_default_shell always project_host bindings sync
term_is_busy always project_host bindings async
term_kill always project_host bindings sync
term_resize always project_host bindings sync
term_spawn always project_host bindings async
term_store_image always project_host bindings async
term_write always project_host bindings sync
tools_install trusted project_host bindings async
tools_list trusted project_host bindings async
tools_uninstall trusted project_host bindings async
tools_updates trusted project_host bindings async
undo_workspace_edit always project_host bindings async
user_font_data always desktop - async
vcs_add_to_ignore trusted project_host bindings async
vcs_add_worktree trusted project_host bindings async
vcs_apply_patch trusted project_host bindings async
vcs_blame always project_host bindings async
vcs_block_history always project_host bindings async
vcs_branch_info always project_host bindings async
vcs_branches always project_host bindings async
vcs_changelists_load always project_host bindings async
vcs_changelists_save trusted project_host bindings sync
vcs_checkout trusted project_host bindings async
vcs_clone always project_host bindings async
vcs_commit trusted project_host bindings async
vcs_commit_files always project_host bindings async
vcs_commits_changes always project_host bindings async
vcs_compare_paths always project_host bindings async
vcs_compare_scope always project_host bindings async
vcs_config_get always project_host bindings async
vcs_conflict_versions always project_host bindings async
vcs_create_branch trusted project_host bindings async
vcs_create_patch trusted project_host bindings async
vcs_create_tag trusted project_host bindings async
vcs_delete_branch trusted project_host bindings async
vcs_delete_tag trusted project_host bindings async
vcs_detected_git always project_host bindings sync
vcs_diff_hunks always project_host bindings async
vcs_diff_numstat always project_host bindings async
vcs_diff_revisions always project_host bindings async
vcs_diff_text always project_host bindings async
vcs_diff_with_worktree always project_host bindings async
vcs_discard trusted project_host bindings async
vcs_edit_remote trusted project_host bindings async
vcs_file_at always project_host bindings async
vcs_file_mode trusted project_host bindings async
vcs_git_version trusted project_host bindings async
vcs_has_hook always project_host bindings async
vcs_ignore_blame_revision trusted project_host bindings async
vcs_init always project_host bindings async
vcs_line_hunks always project_host bindings async
vcs_log always project_host bindings async
vcs_log_details always project_host bindings async
vcs_log_details_batch always project_host bindings async
vcs_log_refs always project_host bindings async
vcs_log_users always project_host bindings async
vcs_merge trusted project_host bindings async
vcs_merge_base always project_host bindings async
vcs_merge_with trusted project_host bindings async
vcs_move_worktree trusted project_host bindings async
vcs_open_external_log always project_host bindings async
vcs_operation trusted project_host bindings async
vcs_patch_preview trusted project_host bindings async
vcs_rebase trusted project_host bindings async
vcs_rebase_with trusted project_host bindings async
vcs_remote trusted project_host bindings async
vcs_remotes always project_host bindings async
vcs_remove_worktree trusted project_host bindings async
vcs_rename_branch trusted project_host bindings async
vcs_restore_branch trusted project_host bindings async
vcs_restore_head trusted project_host bindings async
vcs_restore_tag trusted project_host bindings async
vcs_revision_children always project_host bindings async
vcs_revision_files always project_host bindings async
vcs_reword trusted project_host bindings async
vcs_roots always project_host bindings sync
vcs_shelf_delete trusted project_host bindings async
vcs_shelf_diff trusted project_host bindings async
vcs_shelf_import trusted project_host bindings async
vcs_shelf_patch always project_host bindings async
vcs_shelf_rename trusted project_host bindings async
vcs_shelve trusted project_host bindings async
vcs_shelves always project_host bindings async
vcs_stage trusted project_host bindings async
vcs_stage_hunks trusted project_host bindings async
vcs_stage_patch trusted project_host bindings async
vcs_stash_apply trusted project_host bindings async
vcs_stash_drop trusted project_host bindings async
vcs_stash_push trusted project_host bindings async
vcs_stashes always project_host bindings async
vcs_status always project_host bindings async
vcs_status_paths always project_host bindings async
vcs_tags always project_host bindings async
vcs_undo_commit trusted project_host bindings async
vcs_unshelve trusted project_host bindings async
vcs_unstage trusted project_host bindings async
vcs_worktrees always project_host bindings async
vcs_write_patch always project_host bindings async
web_browsers always desktop bindings async
web_goto always project_host bindings async
web_open always desktop bindings async
web_serves_page trusted desktop bindings async
web_unresolved_references always project_host bindings async
web_url always project_host bindings async
window_close_context always desktop bindings sync
window_complete_launch always desktop bindings sync
window_cycle always desktop bindings sync
window_flash_taskbar always desktop bindings sync
window_group_cancel_close always desktop bindings sync
window_group_close always desktop bindings sync
window_group_close_tabs always desktop bindings sync
window_group_current always desktop bindings sync
window_group_detach always desktop bindings async
window_group_merge always desktop bindings async
window_group_reorder always desktop bindings async
window_group_restore always desktop bindings async
window_group_select always desktop bindings async
window_is_fullscreen always desktop bindings sync
window_new always desktop bindings async
window_open_comparison always desktop bindings async
window_open_deployment always desktop bindings async
window_open_diff always desktop bindings async
window_open_file_in_new_window always desktop bindings async
window_open_files always desktop bindings async
window_open_project always desktop bindings async
window_open_projects always desktop bindings sync
window_open_remote always desktop bindings async
window_open_settings always desktop bindings async
window_set_titlebar_height always desktop bindings sync
window_start_drag always desktop bindings sync
window_take_launch always desktop bindings sync
window_toggle_fullscreen always desktop bindings sync
window_toggle_zoom always desktop bindings sync
workspace_attach always desktop bindings async
workspace_create always desktop bindings async
workspace_discover_projects always desktop bindings async
workspace_update always desktop bindings async
wsl_distros always desktop bindings async
wsl_resolve always desktop bindings sync
+93 -2
Переглянути файл
@@ -1,7 +1,98 @@
//! `cargo test -p openidea --test export_bindings` regenerates `src/bindings.ts`
//! without launching the app (used by `pnpm gen:bindings` and CI drift checks).
//! `cargo test -p openidea --test export_bindings` regenerates `src/bindings.ts` and
//! `src-tauri/tests/commands.snap` without launching the app (used by `pnpm gen:bindings`
//! and CI drift checks).
use ide_host::trust::{projectless_command_allowed, restricted_command_allowed};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use tauri_specta::{BuilderConfiguration, LanguageExt};
#[test]
fn export_bindings() {
openidea_lib::export_bindings(&openidea_lib::specta_builder());
}
/// Commands registered outside tauri-specta (`src-tauri/src/lib.rs`), with how Tauri runs them.
const RAW_COMMANDS: &[(&str, &str)] = &[("user_font_data", "async")];
/// Every command, one line each: its trust class, where a remote window runs it, whether it is
/// in `bindings.ts`, and how Tauri runs it. A change to any of these shows up in this file's diff.
#[test]
fn export_command_snapshot() {
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/commands.snap");
let fresh = std::env::temp_dir().join(format!("openidea-commands-{}.snap", std::process::id()));
openidea_lib::specta_builder().export(CommandSnapshot, &fresh).expect("command snapshot");
let text = std::fs::read(&fresh).expect("exported command snapshot");
let _ = std::fs::remove_file(&fresh);
if std::fs::read(&target).ok().as_deref() != Some(text.as_slice()) {
std::fs::write(&target, text).expect("failed to write the command snapshot");
}
}
struct CommandSnapshot;
impl LanguageExt for CommandSnapshot {
type Error = std::io::Error;
fn export(self, cfg: &BuilderConfiguration, path: &Path) -> std::io::Result<()> {
let spawned = spawned_commands();
let mut modes: BTreeMap<&str, &str> = cfg
.commands
.iter()
.map(|f| {
let mode = if spawned.contains(f.name()) {
"spawn"
} else if f.asyncness() {
"async"
} else {
"sync"
};
(f.name(), mode)
})
.collect();
let in_bindings: BTreeSet<&str> = modes.keys().copied().collect();
modes.extend(RAW_COMMANDS.iter().copied());
let routed: BTreeSet<&str> = ide_host::rpc::client_commands().into_iter().collect();
let names: BTreeSet<&str> = modes.keys().chain(&routed).copied().collect();
let mut out = String::from(
"# Generated by `pnpm gen:bindings`; do not edit. One line per command:\n\
# name, trust class, placement in a remote window, in bindings.ts, how Tauri runs it.\n",
);
for name in names {
let trust = if restricted_command_allowed(name) {
"always"
} else if projectless_command_allowed(name) {
"outside_restricted"
} else {
"trusted"
};
let placement = if routed.contains(name) { "project_host" } else { "desktop" };
let bindings = if in_bindings.contains(name) { "bindings" } else { "-" };
let mode = modes.get(name).copied().unwrap_or("-");
out.push_str(&format!("{name:<44} {trust:<18} {placement:<12} {bindings:<8} {mode}\n"));
}
std::fs::write(path, out)
}
}
/// Sync commands declared `#[tauri::command(async)]`, which Tauri runs off the main thread.
fn spawned_commands() -> BTreeSet<String> {
let mut out = BTreeSet::new();
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
dirs.push(path);
continue;
}
let source = std::fs::read_to_string(&path).unwrap_or_default();
for (_, rest) in source.match_indices("#[tauri::command(async)]").map(|(i, _)| source.split_at(i)) {
let name = rest.split("fn ").nth(1).and_then(|s| s.split('(').next());
out.insert(name.expect("a function after #[tauri::command(async)]").trim().to_owned());
}
}
}
out
}
+10 -3
Переглянути файл
@@ -310,9 +310,14 @@ export const commands = {
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
clipboardHasFiles: () => __TAURI_INVOKE<boolean>("clipboard_has_files"),
/**
* The clipboard's image as a PNG data URL, for a paste whose event carried no
* files: WebKitGTK leaves `clipboardData` empty for a raw image (a screenshot).
* None when the clipboard holds no image, and always on other platforms.
* Whether the clipboard holds an image (a screenshot): cheap, for the Paste item's
* enabled state.
*/
clipboardHasImage: () => __TAURI_INVOKE<boolean>("clipboard_has_image"),
/**
* The clipboard's image as a PNG data URL: for a paste whose event carried no files
* (WebKitGTK leaves `clipboardData` empty for a raw image, a screenshot), and for
* the project view's Paste, which has no paste event. None when it holds no image.
*/
clipboardImage: () => typedError<string | null, IdeError>(__TAURI_INVOKE("clipboard_image")),
/**
@@ -9458,6 +9463,8 @@ export type RunSettings = {
/** Ask before closing a Run tab whose process is still running. */
confirmTerminate?: boolean,
showOnStart?: boolean,
/** New Run/Debug tabs start pinned: a later run doesn't take their place once finished. */
pinTabs?: boolean,
/** Grace period between interrupt and kill when stopping a process. */
terminationTimeoutMs?: number,
};
+18
Переглянути файл
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { getIndentation, indentUnit } from "@codemirror/language";
import { completedSyntaxTree } from "@/test/syntax";
import { classHighlighter, highlightTree } from "@lezer/highlight";
import { findLanguageDescription, loadSupport, loadedSupport } from "./languages";
@@ -81,6 +82,23 @@ describe("frontend string interpolation", () => {
expect(out.some((t) => t.text.includes("{brace}") && t.style === "tok-string")).toBe(true);
});
it.each(["Kotlin", "Scala", "Dart", "Groovy", "C#"])(
"%s indents brackets opened after a string from the line's indentation",
async (mode) => {
const support = await loadSupport(findLanguageDescription(mode, "")!);
const doc = 'f(\n pair("a", g(\n x\n )),\n)';
const state = EditorState.create({
doc,
extensions: [support!, indentUnit.of(" "), EditorState.tabSize.of(4)],
});
completedSyntaxTree(state);
const indent = (line: number) => getIndentation(state, state.doc.line(line).from);
expect(indent(3)).toBe(8);
// C#'s mode leaves a closing bracket's indentation to the editor.
if (mode !== "C#") expect(indent(4)).toBe(4);
},
);
it("keeps Groovy dotted interpolation in code", async () => {
const out = await tokens("Groovy", '"$person.name tail"');
expect(out).toContainEqual(expect.objectContaining({ text: "name", style: "tok-variableName" }));
+5 -1
Переглянути файл
@@ -114,7 +114,11 @@ export function interpolationParser<S>(base: StreamParser<S>, dialect: Interpola
}
if (opening && style !== "comment" && style !== "string.special") {
stream.pos = from + opening.length;
base.token(new StringStream('""', 4, state.indentUnit), frame.code);
// Let the base tokenizer see an empty string at the literal's own column: a stream
// at column 0 would read as a new line and reset the base's line indentation.
const empty = new StringStream(stream.string.slice(0, from) + '""', 4, state.indentUnit);
empty.pos = empty.start = from;
base.token(empty, frame.code);
frame.literal = {
quote,
braces,
+25 -1
Переглянути файл
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DocModel } from "./model/document";
import { readOnlyQuestion, toggleReadOnly } from "./fileAttributes";
import { offerMakeWritable, readOnlyQuestion, toggleReadOnly } from "./fileAttributes";
function model(readOnly: boolean, extra: Partial<{ virtual: boolean; banner: string | null }> = {}) {
const meta = { readOnly, banner: extra.banner ?? (readOnly ? "readOnly" : null) };
@@ -43,3 +43,27 @@ describe("Toggle Read-only Attribute", () => {
expect(readOnlyQuestion("a.txt", false).confirm).toBe("Make Read-only");
});
});
describe("editing a read-only file", () => {
it("offers to make it writable once, then lets edits through", async () => {
const m = model(true);
answer = true;
expect(offerMakeWritable(m)).toBe(true);
expect(offerMakeWritable(m)).toBe(true);
expect(confirm).toHaveBeenCalledTimes(1);
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Make File Writable" }));
await vi.waitFor(() => expect(m.setReadOnly).toHaveBeenCalledWith(false));
expect(offerMakeWritable(m)).toBe(false);
});
it("refuses edits without asking for library, binary and too-large documents", () => {
for (const m of [
model(true, { virtual: true }),
model(true, { banner: "binary" }),
model(true, { banner: "tooLarge" }),
])
expect(offerMakeWritable(m)).toBe(true);
expect(confirm).not.toHaveBeenCalled();
expect(offerMakeWritable(model(false))).toBe(false);
});
});
+21 -1
Переглянути файл
@@ -1,7 +1,8 @@
/**
* File › File Properties › Make File Read-only / Writable (IDEA's "Toggle
* Read-only Attribute", also the status bar lock): asks, then flips the
* file's writable bit and the open document's editability.
* file's writable bit and the open document's editability. Typing into a file
* whose read-only attribute is set asks the same (IDEA's Clear Read-Only Status).
*/
import { call, commands, isTauri } from "@/platform/ipc";
import { basename } from "@/platform/project";
@@ -41,3 +42,22 @@ export function toggleReadOnly(model: DocModel) {
(ok) => ok && void setFileWritable(model, meta.readOnly),
);
}
let asking: DocModel | null = null;
/**
* An edit was attempted in `model`. When only the file's read-only attribute is in the
* way (not a binary, too large or library file), offers to make it writable, once at a
* time. Returns whether the edit was refused: the caller drops it either way.
*/
export function offerMakeWritable(model: DocModel): boolean {
const meta = model.getMeta();
if (!meta.readOnly) return false;
if (model.virtual || meta.banner !== "readOnly" || asking) return true;
asking = model;
const q = readOnlyQuestion(basename(model.path), true);
void confirm({ title: q.title, message: q.message, ok: q.confirm })
.then((ok) => ok && model.getMeta().readOnly && setFileWritable(model, true))
.finally(() => (asking = null));
return true;
}
+11
Переглянути файл
@@ -34,6 +34,7 @@ import {
import { showContextMenu } from "@/ui";
import { baseExtensions, compartments, contributionExtensions } from "../cm/setup";
import { gutterMenuItems, inGutterMenuArea } from "../gutterMenu";
import { offerMakeWritable } from "../fileAttributes";
import type { DocModel } from "./document";
import type { WorkspaceHistoryTarget } from "./workspaceHistory";
import { isNavigation } from "./navHistory";
@@ -147,6 +148,12 @@ export class EditorSlot {
return this.state;
}
private refuseReadOnlyEdit(e: Event): boolean {
if (!offerMakeWritable(this.model)) return false;
e.preventDefault();
return true;
}
private slotExtensions() {
return [
this.model.manager.workspaceHistory.extension(this.historyTarget),
@@ -169,6 +176,10 @@ export class EditorSlot {
this.host.onContextMenu(this, e);
return true;
},
// Typing, pasting or dropping into a read-only file offers to make it writable.
beforeinput: (e) => this.refuseReadOnlyEdit(e),
paste: (e) => this.refuseReadOnlyEdit(e),
drop: (e) => this.refuseReadOnlyEdit(e),
}),
ViewPlugin.define((view) => {
// Gutters sit outside the content DOM. Gutter extensions with their own menu (breakpoints,
+5 -3
Переглянути файл
@@ -85,7 +85,7 @@ export function UsagesTabs() {
id: String(t.id),
label: label(t),
closable: true,
icon: t.pinned ? "pin" : undefined,
pinned: t.pinned,
}))}
activeId={String(search.id)}
onSelect={(id) => useUsages.setState({ active: Number(id) })}
@@ -97,16 +97,18 @@ export function UsagesTabs() {
onContextMenu={(id, e) => {
const t = tabs.find((x) => x.id === Number(id));
if (!t) return;
// IDEA's content tab menu.
showContextMenu(e, [
{ id: "pin", label: t.pinned ? "Unpin Tab" : "Pin Tab", run: () => pinUsageTab(t.id, !t.pinned) },
{ kind: "separator" },
{ id: "close", label: "Close Tab", run: () => closeUsageTab(t.id) },
{ id: "closeAll", label: "Close All Tabs", run: () => tabs.forEach((o) => closeUsageTab(o.id)) },
{
id: "closeOthers",
label: "Close Other Tabs",
disabled: tabs.length < 2,
run: () => tabs.forEach((o) => o.id !== t.id && closeUsageTab(o.id)),
},
{ kind: "separator" },
{ id: "pin", label: t.pinned ? "Unpin Tab" : "Pin Tab", run: () => pinUsageTab(t.id, !t.pinned) },
]);
}}
/>
+25
Переглянути файл
@@ -372,6 +372,31 @@ describe("run config → launch config", () => {
},
);
it("Zig under CodeLLDB: no source-language mode, so evaluation stays LLDB's own", () => {
const exe = launchConfigFor(
rc({ kind: "zig-application", command: "./zig-out/bin/app", debugAdapter: "codelldb" }),
"/p",
)!;
expect(exe.adapter).toBe("codelldb");
expect(extra(exe)).toEqual({ sourceLanguages: [] });
const single = launchConfigFor(
rc({
kind: "native",
command: "/tmp/openidea-run/calc-1",
debugAdapter: "codelldb",
params: { language: "zig" },
}),
"/p",
)!;
expect(extra(single)).toEqual({ sourceLanguages: [] });
// Rust-compiled binaries keep CodeLLDB's default (Rust) source mode.
const rust = launchConfigFor(
rc({ kind: "native", command: "/tmp/openidea-run/app-1", debugAdapter: "codelldb" }),
"/p",
)!;
expect(rust.extraJson).toBeNull();
});
it("Godot: the editor's adapter plays the main scene or the chosen one of the project", () => {
const project = rc({
kind: "godot",
+47 -1
Переглянути файл
@@ -6,7 +6,7 @@ import {
removeAllBreakpoints,
useBreakpoints,
} from "../breakpoints/store";
import { handleDebugEvent, startPreparedSession, startSession, useDebug } from "../state";
import { handleDebugEvent, pinSession, rerunSession, startPreparedSession, startSession, useDebug } from "../state";
import type { LaunchConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import {
@@ -237,3 +237,49 @@ it("replaces finished tabs as soon as a prepared start begins", async () => {
await expect(started).resolves.toBe(43);
expect(Object.keys(useDebug.getState().sessions)).toEqual(["43"]);
});
it("replaces only the finished tab of the same name; other finished tabs stay", async () => {
ipc.debugStart.mockResolvedValueOnce({ status: "ok", data: 1 }).mockResolvedValueOnce({ status: "ok", data: 2 });
await startSession({ ...config("launch"), name: "Server" });
await startSession({ ...config("launch"), name: "Client" });
handleDebugEvent(1, { kind: "terminated" });
handleDebugEvent(2, { kind: "terminated" });
ipc.debugStart.mockResolvedValueOnce({ status: "ok", data: 3 });
await startSession({ ...config("launch"), name: "Server" });
expect(Object.values(useDebug.getState().sessions).map((s) => [s.name, s.status])).toEqual([
["Client", "terminated"],
["Server", "running"],
]);
ipc.debugStart.mockResolvedValueOnce({ status: "ok", data: 4 });
const started = startPreparedSession({ id: "other", name: "Other" } as never, async () => ({
...config("launch"),
name: "Other",
}));
// No finished tab named "Other": the first finished one (Client) gives way.
expect(Object.values(useDebug.getState().sessions).map((s) => s.name)).toEqual(["Server", "Other"]);
await started;
expect(Object.values(useDebug.getState().sessions).map((s) => s.name)).toEqual(["Server", "Other"]);
});
it("keeps a pinned finished tab; Rerun on it takes its place and keeps it pinned", async () => {
ipc.debugStart.mockResolvedValueOnce({ status: "ok", data: 1 });
await startSession({ ...config("launch"), name: "Server" });
pinSession(1, true);
handleDebugEvent(1, { kind: "terminated" });
ipc.debugStart.mockResolvedValueOnce({ status: "ok", data: 2 });
await startSession({ ...config("launch"), name: "Server" });
const tabs = () => Object.values(useDebug.getState().sessions).map((s) => [s.id, s.status, !!s.pinned]);
expect(tabs()).toEqual([
[1, "terminated", true],
[2, "running", false],
]);
handleDebugEvent(2, { kind: "terminated" });
ipc.debugStart.mockResolvedValueOnce({ status: "ok", data: 3 });
await rerunSession(1);
expect(tabs()).toEqual([
[2, "terminated", false],
[3, "running", true],
]);
});
+2
Переглянути файл
@@ -18,6 +18,7 @@ import {
useDebugRunSessions,
} from "@/workbench/contributions";
import { registerDebugActions } from "./actions";
import { registerDebugTabActions } from "./tabActions";
import { loadBreakpoints } from "./breakpoints/store";
import { loadExceptionBreakpoints } from "./breakpoints/exceptions";
import { loadJsBreakpoints, watchPageSessions } from "./js/eventBreakpoints";
@@ -72,6 +73,7 @@ export function activate(): () => void {
const disposers = [
() => useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: null }),
registerDebugActions(),
registerDebugTabActions(),
// Remote JVM Debug, Go Remote, …: Run/Debug Configurations › Add New Configuration.
...REMOTE_TYPES.map(registerRunConfigType),
registerRunConfigType(jsBrowserType),
+4
Переглянути файл
@@ -359,6 +359,10 @@ function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig
env: Object.entries(rc.env).map(([k, v]) => `${k}=${v}`),
...(from && to && from !== to ? { sourceMap: [[from, to]] } : {}),
});
} else if (rc.kind === "zig-application" || rc.params?.language === "zig") {
// Zig under CodeLLDB: without a source language, evaluation stays LLDB's own
// (the default Rust mode would shape `Evaluate Expression` as Rust).
out.extraJson = JSON.stringify({ sourceLanguages: [] });
}
return out;
}
+55 -24
Переглянути файл
@@ -74,6 +74,8 @@ export interface DebugSession {
programInput?: boolean;
/** Process the session is attached to (a Run tab's build tool); stopped with the session. */
process: RunId | null;
/** Pinned tab (root sessions): once finished, a later session opens a new tab instead of taking its place. */
pinned?: boolean;
status: SessionStatus;
stoppedThread: number | null;
stopReason: string | null;
@@ -304,15 +306,30 @@ function settleStart(id: DebugSessionId | null, discard = false) {
for (const s of leftovers) if (isLive(useDebug.getState().sessions[s])) markTerminated(s);
}
/** Drops finished sessions; running ones stay (multiple concurrent sessions are fine). */
function dropFinished() {
useDebug.setState((s) => {
const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v)));
const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k]));
for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id);
return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null };
});
publishContext();
/** The session being rerun: its tab is the one the new session takes, pinned or not. */
let rerunOf: DebugSessionId | null = null;
/**
* As IDEA reuses run content: a new session takes the place of one finished tab (with its
* children) that isn't pinned, preferring one named like it, then the selected one; other
* finished tabs stay. Returns whether the new tab is pinned: as the tab it replaced on a
* rerun, else per the settings.
*/
function replaceFinished(name: string): boolean | undefined {
const { sessions, active } = useDebug.getState();
const own = rerunOf != null ? sessions[rerunOf] : undefined;
rerunOf = null;
if (own && !isLive(own)) {
removeSession(own.id);
return own.pinned;
}
const finished = Object.values(sessions).filter((s) => s.parent == null && !isLive(s) && !s.pinned);
const selected = active != null ? sessions[active] : undefined;
const ordered =
selected && finished.includes(selected) ? [selected, ...finished.filter((s) => s !== selected)] : finished;
const reused = ordered.find((s) => s.name === name) ?? ordered[0];
if (reused) removeSession(reused.id);
return workflowSettings().run.pinTabs || undefined;
}
/**
@@ -670,12 +687,17 @@ export async function startPreparedSession(
const id = --preparationSeq as DebugSessionId;
const controller = new AbortController();
preparations.set(id, controller);
// The new tab takes the place of finished ones while its build runs, not once the adapter starts.
dropFinished();
// The new tab takes the place of a finished one while its build runs, not once the adapter starts.
const pinned = replaceFinished(config.name);
useDebug.setState((s) => ({
sessions: {
...s.sessions,
[id]: blank(id, { name: config.name, adapter: config.debugAdapter ?? "", runConfig: config }),
[id]: blank(id, {
name: config.name,
adapter: config.debugAdapter ?? "",
runConfig: config,
pinned,
}),
},
console: { ...s.console, [id]: [] },
active: id,
@@ -728,7 +750,7 @@ export async function startSession(
// Attaching to a JVM that may be gone: the finished session stays until the attach succeeds.
const afterDetach = justDetached(options.process);
const previousActive = useDebug.getState().active;
if (!afterDetach) dropFinished();
let pinned = !afterDetach && !options.preparation ? replaceFinished(launch.name) : undefined;
try {
if (options.show !== false && workflowSettings().run.showOnStart) toolWindowService().show("debug");
} catch {
@@ -743,7 +765,7 @@ export async function startSession(
const id = await call(commands.debugStart(launch));
console.info("Debug adapter started", { name: launch.name, ms: performance.now() - adapterStarted });
owned.add(id);
if (afterDetach) dropFinished();
if (afterDetach && !options.preparation) pinned = replaceFinished(launch.name);
if (options.preparation?.signal.aborted) {
await call(commands.debugStop(id)).catch(() => undefined);
settleStart(id);
@@ -775,6 +797,7 @@ export async function startSession(
adapter: cur.adapter || launch.adapter,
launch: options.rerunnable === false ? null : launch,
process: options.process ?? null,
pinned: preparation ? preparation.pinned : pinned,
status: cur.status === "starting" ? "running" : cur.status,
},
},
@@ -891,6 +914,11 @@ export async function closeSessionTab(id: DebugSessionId): Promise<boolean> {
return true;
}
/** Pins or unpins a session's tab. */
export function pinSession(id: DebugSessionId, pinned: boolean) {
patch(id, { pinned });
}
/** Forgets a session and its children; a selected one passes the selection to the tab on its left. */
export function removeSession(id: DebugSessionId) {
const st = useDebug.getState();
@@ -918,6 +946,7 @@ export function removeSession(id: DebugSessionId) {
});
for (const goneId of gone) {
owned.delete(goneId);
ansiStreams.delete(goneId);
unfollowProcessOutput(goneId);
}
updateExecutionPoint();
@@ -928,18 +957,20 @@ export async function rerunSession(id: DebugSessionId, options: { show?: boolean
const s = useDebug.getState().sessions[id];
if (!s) return;
const root = rootOf(s);
if (root.runConfig) {
if (isLive(root)) await stopSession(root.id);
await executeAction(
"run.debugConfig",
options.show === false ? { config: root.runConfig, show: false } : root.runConfig,
);
return;
}
const launch = root.launch;
if (!launch) return;
if (!root.runConfig && !launch) return;
if (isLive(root)) await stopSession(root.id);
await startSession(launch, { show: options.show });
rerunOf = root.id;
try {
if (root.runConfig)
await executeAction(
"run.debugConfig",
options.show === false ? { config: root.runConfig, show: false } : root.runConfig,
);
else await startSession(launch!, { show: options.show });
} finally {
rerunOf = null;
}
}
async function loadCapabilities(id: DebugSessionId) {
+94
Переглянути файл
@@ -0,0 +1,94 @@
import { registerActions, type ActionContext } from "@/platform/actions";
import type { MenuItem } from "@/ui";
import type { DebugSessionId } from "@/platform/ipc";
import { closeSessionTab, pinSession, useDebug } from "./state";
import { sessionTabs } from "./ui/sessionTabs";
/** The sessions the Debug tabs close: each tab's owner, in tab order. */
function owners(): DebugSessionId[] {
return [...new Set(sessionTabs(Object.values(useDebug.getState().sessions)).map((t) => t.owner))];
}
/** The tab owner an action is for: its argument, else the selected tab's. */
function target({ arg }: ActionContext): DebugSessionId | null {
const { sessions, active } = useDebug.getState();
const id = typeof arg === "number" ? arg : active;
if (id == null || !sessions[id]) return null;
return sessionTabs(Object.values(sessions)).find((t) => t.session.id === id || t.owner === id)?.owner ?? null;
}
/** `id` and the sessions it runs under: closing one of those would close `id` too. */
function lineage(id: DebugSessionId): Set<DebugSessionId> {
const { sessions } = useDebug.getState();
const out = new Set<DebugSessionId>();
for (let cur = sessions[id]; cur && !out.has(cur.id); cur = cur.parent != null ? sessions[cur.parent] : undefined)
out.add(cur.id);
return out;
}
/** Closes the tabs one after another; a running session the user keeps stops the rest. */
async function closeTabs(ids: readonly DebugSessionId[]) {
for (const id of ids) if (useDebug.getState().sessions[id] && !(await closeSessionTab(id))) return;
}
/** A pinnable tab: a user-started session's (a child's tab goes with its parent). */
function pinnable(id: DebugSessionId | null) {
const s = id != null ? useDebug.getState().sessions[id] : undefined;
return s && s.parent == null ? s : undefined;
}
export function registerDebugTabActions(): () => void {
return registerActions([
{
id: "debug.closeTab",
title: "Close Tab",
category: "Debug",
enabled: (c) => target(c) !== null,
run: (c) => void closeSessionTab(target(c)!),
},
{
id: "debug.closeOtherTabs",
title: "Close Other Tabs",
category: "Debug",
enabled: (c) => target(c) !== null && owners().length > 1,
run: async (c) => {
const keep = lineage(target(c)!);
await closeTabs(owners().filter((id) => !keep.has(id)));
},
},
{
id: "debug.closeAllTabs",
title: "Close All Tabs",
category: "Debug",
enabled: () => owners().length > 0,
run: () => closeTabs(owners()),
},
{
id: "debug.pinTab",
title: "Pin / Unpin Tab",
category: "Debug",
icon: "pin",
enabled: (c) => !!pinnable(target(c)),
run: (c) => {
const s = pinnable(target(c));
if (s) pinSession(s.id, !s.pinned);
},
},
]);
}
/** The context menu of a Debug tab (IDEA's content tab menu); `owner` is the session the tab stands for. */
export function debugTabMenu(owner: DebugSessionId): MenuItem[] {
const s = pinnable(owner);
return [
{ kind: "action", action: "debug.closeTab", arg: owner },
{ kind: "action", action: "debug.closeAllTabs", arg: owner },
{ kind: "action", action: "debug.closeOtherTabs", arg: owner },
...(s
? ([
{ kind: "separator" },
{ kind: "action", action: "debug.pinTab", arg: owner, label: s.pinned ? "Unpin Tab" : "Pin Tab" },
] satisfies MenuItem[])
: []),
];
}
+10 -2
Переглянути файл
@@ -41,6 +41,7 @@ import {
} from "../state";
import { remoteWaitingNote } from "../remote";
import { sessionTabs } from "./sessionTabs";
import { debugTabMenu } from "../tabActions";
import { stopNote } from "../js/eventBreakpoints";
import { NetworkView } from "../js/NetworkView";
import { liveWatchesApply, useLiveWatches } from "../js/liveWatches";
@@ -138,13 +139,15 @@ export function DebugTabs() {
const list = Object.values(sessions);
if (!list.length) return null;
const tabs = sessionTabs(list);
const items: TabItem[] = tabs.map(({ session: s, child }) => ({
const ownerOf = (id: string) => tabs.find((t) => t.session.id === +id)?.owner ?? +id;
const items: TabItem[] = tabs.map(({ session: s, child, owner }) => ({
id: String(s.id),
label: child ? tx`${s.name} (child)` : s.name,
text: s.name,
icon: "debug",
italic: s.status === "terminated",
closable: true,
pinned: !child && !!sessions[owner]?.pinned,
tooltip: statusText(s),
}));
// A browser launch's own session has no tab: its page's tab stands for it.
@@ -157,7 +160,12 @@ export function DebugTabs() {
items={items}
activeId={shown ? String(shown.session.id) : null}
onSelect={(id) => selectSession(+id)}
onClose={(id) => void closeSessionTab(tabs.find((t) => t.session.id === +id)?.owner ?? +id)}
onClose={(id) => void closeSessionTab(ownerOf(id))}
onTogglePin={(id) => void executeAction("debug.pinTab", ownerOf(id))}
onContextMenu={(id, e) => {
e.preventDefault();
showContextMenu(e, debugTabMenu(ownerOf(id)), { owner: e.currentTarget });
}}
panelIdPrefix="openidea-debug"
/>
);
+1 -1
Переглянути файл
@@ -33,7 +33,7 @@
font-size: var(--fs-small, var(--fs-ui));
}
.button.text:hover,
.button:hover,
.button[aria-expanded="true"] {
background-color: var(--bg-toolbar-hover);
}
+35 -4
Переглянути файл
@@ -1,6 +1,7 @@
/**
* The OS clipboard's file list: what Explorer copies, and what the project view
* puts there so Explorer can paste it back. The format is native (Windows
* puts there so Explorer can paste it back; and its image (a screenshot), which
* Paste saves as a file. The format is native (Windows
* `CF_HDROP`), so the backend owns it; a plain browser has nothing to exchange
* and every call degrades to "no files".
*/
@@ -13,6 +14,10 @@ export interface OsClipboard {
hasFiles(): Promise<boolean>;
/** True only when the OS accepted the file list; false for unsupported hosts. */
setFiles(paths: string[]): Promise<boolean>;
/** Whether the clipboard holds an image: cheap, for the Paste item's enabled state. */
hasImage(): Promise<boolean>;
/** The clipboard's image as PNG bytes, null when it holds none. */
image(): Promise<Uint8Array | null>;
}
const native: OsClipboard = {
@@ -39,6 +44,23 @@ const native: OsClipboard = {
return false;
}
},
async hasImage() {
if (!isTauri()) return false;
try {
return await commands.clipboardHasImage();
} catch {
return false;
}
},
async image() {
if (!isTauri()) return null;
const url = await call(commands.clipboardImage());
if (!url) return null;
const binary = atob(url.slice(url.indexOf(",") + 1));
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
},
};
let current = native;
@@ -47,7 +69,16 @@ export function osClipboard(): OsClipboard {
return current;
}
/** Tests: force a clipboard implementation. */
export function setOsClipboardForTests(clipboard: OsClipboard | null) {
current = clipboard ?? native;
/** Tests: force a clipboard implementation; what it leaves out holds nothing. */
export function setOsClipboardForTests(clipboard: Partial<OsClipboard> | null) {
current = clipboard
? {
files: async () => [],
hasFiles: async () => false,
setFiles: async () => false,
hasImage: async () => false,
image: async () => null,
...clipboard,
}
: native;
}
+13
Переглянути файл
@@ -14,6 +14,8 @@ import { isInside, joinPath, nameOf, parentOf } from "./paths";
export interface ProjectFs {
readDir(path: string): Promise<FileEntry[]>;
createFile(path: string): Promise<void>;
/** Creates `path` with `data`; fails when it exists. */
createFileBytes(path: string, data: Uint8Array): Promise<void>;
createDir(path: string): Promise<void>;
rename(from: string, to: string): Promise<void>;
/** Moves to the trash when possible. */
@@ -28,6 +30,10 @@ export const tauriFs: ProjectFs = {
await call(commands.fsCreateFile(path));
notifyFileOperation({ kind: "created", paths: [path] });
},
createFileBytes: async (path, data) => {
await call(commands.fsCreateFileBytes(path, [...data]));
notifyFileOperation({ kind: "created", paths: [path] });
},
createDir: async (path) => void (await call(commands.fsCreateDir(path))),
rename: async (from, to) => void (await call(commands.fsRename(from, to))),
delete: async (path) => {
@@ -161,6 +167,13 @@ export class MemoryFs implements ProjectFs {
this.emit({ created: [path] });
}
async createFileBytes(path: string, data: Uint8Array) {
if (this.nodes.has(path)) throw err("alreadyExists", `${path} already exists`);
this.mkdirp(parentOf(path));
this.nodes.set(path, { kind: "file", size: data.length });
this.emit({ created: [path] });
}
async createDir(path: string) {
if (this.nodes.has(path)) throw err("alreadyExists", `${path} already exists`);
this.mkdirp(path);
+20
Переглянути файл
@@ -185,6 +185,26 @@ describe("OS file imports", () => {
expect(fs.nodes.has("/p/target/local.txt")).toBe(true);
});
it("saves a pasted image as img.png, then img_1.png, and selects it", async () => {
const png = new Uint8Array([137, 80, 78, 71]);
setOsClipboardForTests({ hasImage: async () => true, image: async () => png });
await refreshOsClipboard();
expect(canPaste()).toBe(true);
await paste("/p/target");
await paste("/p/target");
expect(fs.nodes.get("/p/target/img.png")).toEqual({ kind: "file", size: 4 });
expect(fs.nodes.has("/p/target/img_1.png")).toBe(true);
expect(useProjectTree.getState().revealRequest?.path).toBe("/p/target/img_1.png");
expect(notify).not.toHaveBeenCalled();
});
it("says the clipboard is empty when it holds neither files nor an image", async () => {
setOsClipboardForTests({});
await paste("/p/target");
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "No files on the clipboard" }));
expect([...fs.nodes.keys()].filter((p) => p.startsWith("/p/target/"))).toEqual([]);
});
it("reports a clipboard read failure", async () => {
setOsClipboardForTests({
files: async () => {
+42 -5
Переглянути файл
@@ -13,7 +13,7 @@ import { osClipboard } from "./clipboard";
import { lastFileRefactoring, nextFileOperation, runFileRefactoring, undoFileRefactoring } from "./fileRefactor";
import { openFileRefactorDialog, rememberedOptions } from "./FileRefactorDialogs";
import { beginLocalImport, projectFs } from "./fs";
import { call, commands, isCancelled } from "@/platform/ipc";
import { call, commands, IpcError, isCancelled } from "@/platform/ipc";
import { MEMBER_PREFIX, type ProjectNode } from "./model";
import {
isInside,
@@ -573,8 +573,9 @@ export function copySelection(cut: boolean) {
setClipboard({ paths, cut, remote, osWrite });
}
/** Files on the OS clipboard, cached: `canPaste` runs while a menu renders. */
/** Files or an image on the OS clipboard, cached: `canPaste` runs while a menu renders. */
let osHasFiles = false;
let osHasImage = false;
/** Keeps Paste enabled for what Explorer copied; called when the window gains focus. */
export async function refreshOsClipboard() {
@@ -588,10 +589,11 @@ export async function refreshOsClipboard() {
async function readOsClipboard(): Promise<string[] | null> {
const { clipboard } = get();
const handedOff = await clipboard?.osWrite;
const external = await osClipboard().files();
const [external, image] = await Promise.all([osClipboard().files(), osClipboard().hasImage()]);
// A newer Copy/Cut owns the state if this read (or its hand-off) was delayed.
if (get().clipboard !== clipboard) return null;
osHasFiles = external.length > 0;
osHasImage = image;
// Our remote text write removes file URLs, so any files were copied later.
// A failed write leaves ownership uncertain; retain the remote copy then.
if (handedOff && (clipboard?.remote ? external.length > 0 : !sameFiles(external, clipboard?.paths)))
@@ -600,12 +602,13 @@ async function readOsClipboard(): Promise<string[] | null> {
}
export function canPaste(): boolean {
return !!get().clipboard?.paths.length || osHasFiles;
return !!get().clipboard?.paths.length || osHasFiles || osHasImage;
}
/**
* Paste: cut items go through the Move dialog, copied ones through the Copy
* dialog (IDEA). Files copied in Explorer paste as copies.
* dialog (IDEA). Files copied in Explorer paste as copies; an image (a
* screenshot) is saved as `img.png`.
*/
export async function paste(target = targetFolder()) {
if (!target || isReadOnlyPath(target)) return;
@@ -626,6 +629,7 @@ export async function paste(target = targetFolder()) {
if (!clipboard?.remote && external.length && !sameFiles(external, clipboard?.paths))
return pasteFromOsClipboard(external, target);
if (!clipboard?.paths.length) {
if (await pasteImage(target)) return;
notifications().notify({ level: "info", title: "No files on the clipboard" });
return;
}
@@ -640,6 +644,39 @@ export async function paste(target = targetFolder()) {
});
}
/**
* Saves the clipboard's image in `target` as `img.png`, or `img_1.png`, `img_2.png`
* … when taken, and selects it. False when the clipboard holds no image.
*/
async function pasteImage(target: string): Promise<boolean> {
let png: Uint8Array | null;
try {
png = await osClipboard().image();
} catch (e) {
fail(tx("Cannot access the clipboard"), e);
return true;
}
if (!png) return false;
await loadDir(target);
const taken = new Set((get().listings.get(target) ?? []).map((e) => e.name));
for (let i = 0; ; i++) {
const name = i ? `img_${i}.png` : "img.png";
if (taken.has(name)) continue;
const path = joinPath(target, name);
try {
await projectFs(get().root ?? target).createFileBytes(path, png);
} catch (e) {
// Created since the listing was read: take the next name.
if (e instanceof IpcError && e.kind === "alreadyExists") continue;
fail(tx`Cannot paste the image into ${name}`, e);
return true;
}
await loadDir(target, true);
revealPath(path, true);
return true;
}
}
/** Pasting from Explorer copies, whatever the source: never a move or a refactoring. */
async function pasteFromOsClipboard(paths: string[], target: string) {
osHasFiles = true;
+26 -13
Переглянути файл
@@ -2,7 +2,19 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { RunConfig } from "@/platform/ipc";
import type { RunTargetValid } from "@/platform/remoteSettings";
import { useAgentRunSettings, useWorkflowSettings } from "@/platform/workflowSettings";
import { EmptyState, Icon, IconButton, Menu, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui";
import {
EmptyState,
Icon,
IconButton,
Menu,
SplitGroup,
SplitHandle,
SplitPanel,
Tabs,
type TabItem,
T,
showContextMenu,
} from "@/ui";
import { executeAction } from "@/platform/actions";
import { formatBinding, hostBinding, shortcutLabel } from "@/platform/keymap";
import { ConsoleModel } from "./console";
@@ -15,7 +27,6 @@ import {
baseConfigOf,
canRunSelectedTests,
clearConsole,
closeSessionTab,
closeStdin,
debugSession,
failedTests,
@@ -31,11 +42,13 @@ import {
writeTerminal,
type RunSession,
isLive,
pinSession,
} from "./store";
import { rerunFailedConfigs } from "./testing/rerun";
import { nodeOutput } from "./testing";
import { TestTreeView, formatDuration } from "./TestTreeView";
import { StackTracePanel, closeStackTrace, useStackTraces, type StackTraceTab } from "./stackTrace";
import { StackTracePanel, useStackTraces, type StackTraceTab } from "./stackTrace";
import { closeRunTab, runTabMenu } from "./runTabs";
import { showApplicationUpdate, useApplicationUpdates } from "./applicationUpdate";
import { isUpdatable } from "./updatePolicies";
import { configIcon } from "./configs";
@@ -43,7 +56,6 @@ import { agentName } from "./agentConfig";
import { archivedAgentSessions, closeAgentArchive } from "./agentRuns";
import styles from "./Run.module.css";
import { tx } from "@/platform/i18n";
import { toolWindowService } from "@/workbench/contributions";
function failed(s: RunSession): boolean {
return s.status === "failed" || (s.status === "finished" && s.exitCode !== 0 && !s.stopped);
@@ -278,6 +290,7 @@ function sessionItems(sessions: readonly RunSession[], targets: readonly RunTarg
// An agent's command that ended, however it ended.
dimmed: agentName(s.config) !== null && !isLive(s),
closable: true,
pinned: s.pinned,
tooltip:
s.status === "finished"
? tx`Finished in ${formatDuration((s.endedAt ?? s.startedAt) - s.startedAt)}, exit code ${s.exitCode ?? "?"}`
@@ -299,14 +312,6 @@ function currentTab(
return traces.length ? { trace: traces[traces.length - 1]! } : null;
}
/** Closes a session or stack trace tab; as in IDEA, the tool window goes away with its last tab. */
export async function closeRunTab(id: string, traces: readonly StackTraceTab[]) {
if (traces.some((t) => t.id === id)) closeStackTrace(id);
else if (!(await closeSessionTab(id))) return;
if (!useRun.getState().sessions.length && !useStackTraces.getState().tabs.length)
toolWindowService().hide("run", { removeFromStripe: true });
}
/** Session (and stack trace) tabs in the tool window header. */
export function RunTabs() {
const sessions = useRun((s) => s.sessions);
@@ -331,7 +336,15 @@ export function RunTabs() {
items={items}
activeId={activeId}
onSelect={(id) => useRun.setState({ activeSession: id })}
onClose={(id) => void closeRunTab(id, traces)}
onClose={(id) => void closeRunTab(id)}
onTogglePin={(id) => {
const s = sessions.find((x) => x.id === id);
if (s) pinSession(s.id, !s.pinned);
}}
onContextMenu={(id, e) => {
e.preventDefault();
showContextMenu(e, runTabMenu(id), { owner: e.currentTarget });
}}
panelIdPrefix="openidea-run"
trailing={archived.length > 0 && <AgentArchive sessions={archived} activeId={activeId} />}
/>
+4 -4
Переглянути файл
@@ -1,7 +1,7 @@
import { expect, it, vi } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { provideToolWindowService, type ToolWindowService } from "@/workbench/contributions";
import { closeRunTab } from "./RunView";
import { closeRunTab } from "./runTabs";
import { useStackTraces } from "./stackTrace";
import { useRun, type RunSession } from "./store";
@@ -20,9 +20,9 @@ it("hides the Run tool window once its last tab is closed", async () => {
provideToolWindowService({ hide } as unknown as ToolWindowService);
useRun.setState({ sessions: [finished("a"), finished("b")] });
useStackTraces.setState({ tabs: [{ id: "t", title: "Trace", text: "" } as never] });
await closeRunTab("a", useStackTraces.getState().tabs);
await closeRunTab("t", useStackTraces.getState().tabs);
await closeRunTab("a");
await closeRunTab("t");
expect(hide).not.toHaveBeenCalled();
await closeRunTab("b", useStackTraces.getState().tabs);
await closeRunTab("b");
expect(hide).toHaveBeenCalledWith("run", { removeFromStripe: true });
});
+105 -3
Переглянути файл
@@ -1,15 +1,36 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { menuGroupItems, registerMenuGroup } from "@/workbench/contributions";
import type { MenuItem } from "@/ui";
import { canDebugProduced, choiceItems, contextMenuItems } from "./contextRun";
import { OPTIMISTIC, produceContextChoices } from "./producers";
import { canDebugProduced, choiceItems, compiledDebugPlan, contextMenuItems } from "./contextRun";
import { OPTIMISTIC, produceContextChoices, type ToolEnv } from "./producers";
import { gutterTestState, markerIcon, targetMenuItems } from "./runGutter";
import { useRun } from "./store";
import type { RunTarget } from "./targets";
import { TestResults } from "./testing";
import { clearTestStates, recordTestStates } from "./testing/states";
import { setInvokeRoute } from "@/platform/tauriCore";
const mocks = vi.hoisted(() => ({
tauri: { value: false },
read: { value: null as string | null },
toolEnv: { value: null as ((...args: unknown[]) => unknown) | null },
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/platform/ipc")>();
return { ...actual, isTauri: () => mocks.tauri.value };
});
vi.mock("./runtimes", async (importOriginal) => {
const actual = await importOriginal<typeof import("./runtimes")>();
return {
...actual,
toolEnv: (...args: Parameters<typeof actual.toolEnv>) =>
mocks.toolEnv.value ? mocks.toolEnv.value(...args) : actual.toolEnv(...args),
};
});
const ROOT = "/work/app";
const SCRATCHES = "/Users/me/scratches";
@@ -203,6 +224,23 @@ describe("run gutter popup", () => {
describe("Debug entry", () => {
const config = (c: Partial<RunConfig>) =>
({ id: "c", name: "c", command: "", args: [], cwd: "", env: {}, ...c }) as RunConfig;
const MAIN_ZIG = [
'const std = @import("std");',
"pub fn main() !void {",
' std.debug.print("hi\\n", .{});',
"}",
].join("\n");
const CALC_ZIG = [
'const std = @import("std");',
'test "adds two numbers" {',
" try std.testing.expect(true);",
"}",
].join("\n");
const zigEnv: ToolEnv = {
tool: (n) => (n === "zig" ? { path: "/bin/zig", version: "0.17.1" } : null),
compileDir: "/tmp/openidea-run",
};
it("is offered for Maven and Gradle tests, which run with a JDWP agent", () => {
const mavenTest = config({ kind: "maven", command: "mvn", args: ["test", "-Dtest=A#b"], isTest: true });
expect(canDebugProduced({ name: "A.b", scope: "element", config: mavenTest })).toBe(true);
@@ -217,6 +255,70 @@ describe("Debug entry", () => {
canDebugProduced({ name: "lint", scope: "file", config: config({ kind: "npm", args: ["run", "lint"] }) }),
).toBe(false);
});
beforeEach(() => {
useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never });
useRun.setState({ configs: [], loaded: true });
mocks.tauri.value = true;
mocks.toolEnv.value = () => zigEnv;
// Bindings' `typedError` wraps the raw invoke result: `fs_read_text` resolves to its text.
setInvokeRoute((cmd) =>
cmd === "fs_read_text" ? Promise.resolve(mocks.read.value) : Promise.reject(new Error("not stubbed")),
);
});
afterEach(() => {
mocks.tauri.value = false;
mocks.read.value = null;
mocks.toolEnv.value = null;
setInvokeRoute(null);
useProject.setState({ project: null });
useRun.setState({ configs: [], loaded: false });
});
it("re-debug of a saved zig file config rebuilds the build-then-debug plan", async () => {
mocks.read.value = MAIN_ZIG;
const saved = config({
id: "context:file:src/main.zig",
kind: "zig",
command: "zig",
args: ["run", "src/main.zig"],
cwd: null,
});
const plan = await compiledDebugPlan(saved);
expect(plan?.debug).toBeTruthy();
expect(plan!.debug!.build.command).toMatch(
/zig build-exe src\/main\.zig -femit-bin=\/tmp\/openidea-run\/main-\w+$/,
);
expect(plan!.debug!.config).toMatchObject({
kind: "native",
debugAdapter: "codelldb",
params: { language: "zig" },
});
});
it("re-debug of a saved zig test config re-targets its test", async () => {
mocks.read.value = CALC_ZIG;
const saved = config({
id: "context:file:src/calc.zig#adds two numbers",
kind: "zig",
command: "zig",
args: ["test", "src/calc.zig", "--test-filter", "adds two numbers"],
cwd: null,
});
const plan = await compiledDebugPlan(saved);
expect(plan?.debug).toBeTruthy();
expect(plan!.debug!.build.command).toMatch(
/zig test src\/calc\.zig --test-filter 'adds two numbers' --test-no-exec/,
);
expect(plan!.debug!.config.args).toEqual([]);
});
it("leaves other configurations alone", async () => {
expect(await compiledDebugPlan(config({ id: "c", kind: "shell", command: "make" }))).toBeNull();
expect(
await compiledDebugPlan(config({ id: "user:x", kind: "zig", command: "zig", args: ["run", "x.zig"], cwd: null })),
).toBeNull();
});
});
describe("Run with Fuzzing", () => {
+3 -3
Переглянути файл
@@ -197,9 +197,9 @@ export async function compiledDebugPlan(config: RunConfig): Promise<Produced | n
const input: ContextInput = { ...projectContext(), root, path, content };
const env = await toolEnv(neededTools(input), path, root, false);
if (m[2]) {
// A test: find the target by name again.
// A test: find the target by name again (targets and the id carry the unescaped name).
if (config.kind === "zig") {
const test = zigTargets(content ?? "").find((t) => t.name.replace(/\\(.)/g, "$1") === m[2]);
const test = zigTargets(content ?? "").find((t) => t.name === m[2]);
return test ? produceContext({ ...input, line: test.line }, env) : null;
}
const lines = (content ?? "").split("\n");
@@ -365,7 +365,7 @@ function peekContent(path: string): string | undefined {
/** Whether the file's runnability depends on its text (`package main`, `fn main`, `#!`…). */
function needsContent(path: string): boolean {
const name = path.slice(path.lastIndexOf("/") + 1);
return /\.(go|rs|java|kt|c|cpp|cc|cxx)$/.test(name) || !name.includes(".");
return /\.(go|rs|java|kt|c|cpp|cc|cxx|zig)$/.test(name) || !name.includes(".");
}
export async function prepareMenu(_menu: ContextMenuId, ctx: MenuGroupContext): Promise<void> {
+3 -1
Переглянути файл
@@ -43,6 +43,7 @@ import { registerExternalTools } from "./externalTools";
import { openImport } from "./importConfigs";
import { registerComposerInstall } from "./composerInstall";
import { registerGoActions } from "./goActions";
import { registerRunTabActions } from "./runTabs";
import { invalidateGoModules } from "./goModules";
import { invalidatePythonPackages } from "./pythonPackages";
import { RunWidget } from "./RunWidget";
@@ -185,7 +186,7 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = [], sho
await executeAction("debug.startConfig", show === false ? { config, show: false } : config);
return;
}
// A single compiled file (rustc / clang): build with debug info, then debug the binary.
// A single compiled file (rustc / clang / zig): build with debug info, then debug the binary.
const compiled = await compiledDebugPlan(config);
if (compiled?.debug) {
await launchProduced(compiled, true, show);
@@ -403,6 +404,7 @@ export function activate(): () => void {
registerBuiltinTypes(),
registerRunConfigType(junitType),
registerGoActions(),
registerRunTabActions(),
registerComposerInstall(),
registerOverlay({ id: "run.chooser", component: ConfigChooserOverlay }),
registerOverlay({
+6 -1
Переглянути файл
@@ -424,7 +424,12 @@ describe("single-file producers (scratches)", () => {
expect(main.debug!.build.command).toMatch(
/^mkdir -p \/tmp\/openidea-run && \/bin\/zig build-exe calc\.zig -femit-bin=\/tmp\/openidea-run\/calc-\w+$/,
);
expect(main.debug!.config).toMatchObject({ kind: "native", debugAdapter: "codelldb", args: [] });
expect(main.debug!.config).toMatchObject({
kind: "native",
debugAdapter: "codelldb",
args: [],
params: { language: "zig" },
});
const test = produceContext(scratch("calc.zig", src, 3), e)!;
expect(test).toMatchObject({ name: "adds two numbers", scope: "element" });
+9 -1
Переглянути файл
@@ -660,7 +660,15 @@ function zigFile(input: ContextInput, env: ToolEnv, s: FileSite, test?: { name:
isTest: false,
debugAdapter: null,
};
const binary: RunConfig = { ...config, kind: "native", command: out, args: [], isTest: false };
const binary: RunConfig = {
...config,
kind: "native",
command: out,
args: [],
isTest: false,
// Marks the debugged program as Zig: CodeLLDB's source mode stays off (see nativeLaunch).
params: { language: "zig" },
};
return { name, scope, config, debug: { build: buildConfig, config: binary } };
}
+71
Переглянути файл
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { RunConfig } from "@/platform/ipc";
const ipc = vi.hoisted(() => ({
runStart: vi.fn(async () => ({ status: "ok", data: 1 })),
runStop: vi.fn(async () => ({ status: "ok", data: null })),
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@tauri-apps/api/core")>()),
Channel: class {
onmessage: ((m: unknown) => void) | null = null;
},
}));
import { useProject } from "@/platform/project";
import {
provideNotificationService,
provideToolWindowService,
type ToolWindowService,
} from "@/workbench/contributions";
import { resetRuns, startRun, useRun, type RunSession } from "./store";
const config = (id: string): RunConfig =>
({ id, name: id, kind: "shell", command: "true", args: [], cwd: null, env: {} }) as unknown as RunConfig;
const finished = (id: string, extra: Partial<RunSession> = {}): RunSession =>
({
id,
config: config(id),
runId: null,
status: "finished",
exitCode: 0,
startedAt: 0,
endedAt: 1,
...extra,
}) as RunSession;
const tabs = () => useRun.getState().sessions.map((s) => `${s.id}:${s.config.id}${s.pinned ? "*" : ""}`);
beforeEach(() => {
vi.clearAllMocks();
useProject.setState({ settings: null });
provideNotificationService({ notify: vi.fn() });
provideToolWindowService({ show: vi.fn(), hide: vi.fn() } as unknown as ToolWindowService);
});
afterEach(() => resetRuns());
it("takes the place of the same configuration's finished tab, then the selected one, never a pinned one", async () => {
useRun.setState({ sessions: [finished("a"), finished("b"), finished("c", { pinned: true })], activeSession: "b" });
await startRun(config("a"), undefined, undefined, { prepared: true });
expect(tabs()).toEqual(["a:a", "b:b", "c:c*"]);
useRun.setState({ activeSession: "b" });
await startRun(config("x"), undefined, undefined, { prepared: true });
expect(tabs()).toEqual(["a:a", "b:x", "c:c*"]);
});
it("opens a new tab when every finished tab is pinned, pinned when the setting asks", async () => {
useProject.setState({ settings: { run: { pinTabs: true } } as never });
useRun.setState({ sessions: [finished("a", { pinned: true })], activeSession: "a" });
await startRun(config("a"), undefined, undefined, { prepared: true });
expect(tabs()).toEqual(["a:a*", expect.stringMatching(/^run\d+:a\*$/)]);
});
it("keeps an agent's commands out of other tabs", async () => {
const agent = { ...config("agent:build"), params: { "mcp.agentRun": "build" } } as RunConfig;
useRun.setState({ sessions: [finished("a")], activeSession: "a" });
await startRun(agent, undefined, undefined, { prepared: true });
expect(useRun.getState().sessions).toHaveLength(2);
});
+95
Переглянути файл
@@ -0,0 +1,95 @@
import { registerActions, type ActionContext } from "@/platform/actions";
import { agentRunSettings } from "@/platform/workflowSettings";
import type { MenuItem } from "@/ui";
import { toolWindowService } from "@/workbench/contributions";
import { archivedAgentSessions } from "./agentRuns";
import { closeStackTrace, useStackTraces } from "./stackTrace";
import { closeSessionTab, pinSession, useRun } from "./store";
/** Ids of the tabs in the Run tool window's strip: sessions (less archived agent tabs), then stack traces. */
export function runTabIds(): string[] {
const { sessions } = useRun.getState();
const archived = new Set(archivedAgentSessions(sessions, agentRunSettings().tabLimit).map((s) => s.id));
return [
...sessions.filter((s) => !archived.has(s.id)).map((s) => s.id),
...useStackTraces.getState().tabs.map((t) => t.id),
];
}
/** Closes a session or stack trace tab; as in IDEA, the tool window goes away with its last tab. */
export async function closeRunTab(id: string): Promise<boolean> {
if (useStackTraces.getState().tabs.some((t) => t.id === id)) closeStackTrace(id);
else if (!(await closeSessionTab(id))) return false;
if (!useRun.getState().sessions.length && !useStackTraces.getState().tabs.length)
toolWindowService().hide("run", { removeFromStripe: true });
return true;
}
/** Closes the tabs one after another; a running process the user keeps stops the rest. */
async function closeTabs(ids: readonly string[]) {
for (const id of ids) if (!(await closeRunTab(id))) return;
}
function target({ arg }: ActionContext): string | null {
const id = typeof arg === "string" ? arg : useRun.getState().activeSession;
return id != null && runTabIds().includes(id) ? id : null;
}
const session = (id: string | null) => useRun.getState().sessions.find((s) => s.id === id);
export function registerRunTabActions(): () => void {
return registerActions([
{
id: "run.closeTab",
title: "Close Tab",
category: "Run",
enabled: (c) => target(c) !== null,
run: (c) => void closeRunTab(target(c)!),
},
{
id: "run.closeOtherTabs",
title: "Close Other Tabs",
category: "Run",
enabled: (c) => target(c) !== null && runTabIds().length > 1,
run: async (c) => {
const id = target(c)!;
await closeTabs(runTabIds().filter((t) => t !== id));
if (session(id)) useRun.setState({ activeSession: id });
},
},
{
id: "run.closeAllTabs",
title: "Close All Tabs",
category: "Run",
enabled: () => runTabIds().length > 0,
run: () => closeTabs(runTabIds()),
},
{
id: "run.pinTab",
title: "Pin / Unpin Tab",
category: "Run",
icon: "pin",
enabled: (c) => !!session(target(c)),
run: (c) => {
const s = session(target(c));
if (s) pinSession(s.id, !s.pinned);
},
},
]);
}
/** The context menu of a Run tab (IDEA's content tab menu). */
export function runTabMenu(id: string): MenuItem[] {
const s = session(id);
return [
{ kind: "action", action: "run.closeTab", arg: id },
{ kind: "action", action: "run.closeAllTabs", arg: id },
{ kind: "action", action: "run.closeOtherTabs", arg: id },
...(s
? ([
{ kind: "separator" },
{ kind: "action", action: "run.pinTab", arg: id, label: s.pinned ? "Unpin Tab" : "Pin Tab" },
] satisfies MenuItem[])
: []),
];
}
+24 -2
Переглянути файл
@@ -93,6 +93,8 @@ export interface RunSession {
terminal?: boolean;
/** The HTTP port the application's embedded web server reported it started on. */
httpPort?: number;
/** Pinned tab: once finished, a later run opens a new tab instead of taking its place. */
pinned?: boolean;
}
interface SessionRuntime {
@@ -752,7 +754,25 @@ export function sessionsOf(config: RunConfig, sessions = useRun.getState().sessi
return sessions.filter((s) => ids.has(s.config.id));
}
/** Starts `config`, reusing the tab of a finished (or the given) session for the same config. */
/**
* The finished tab a run of `config` takes the place of, as IDEA reuses run content: not a
* pinned one; that of the same configuration first, then the selected one, then the first.
* Agents' commands keep to their own tabs.
*/
function reusableTab(sessions: readonly RunSession[], active: string | null, config: RunConfig) {
const free = sessions.filter((s) => !isLive(s) && !s.pinned);
const same = free.find((s) => s.config.id === config.id);
if (same || agentName(config) !== null) return same;
const others = free.filter((s) => agentName(s.config) === null);
return others.find((s) => s.id === active) ?? others[0];
}
/** Pins or unpins a session's tab. */
export function pinSession(id: string, pinned: boolean) {
patchSession(id, { pinned });
}
/** Starts `config`, reusing the tab of a finished (or the given) session (see `reusableTab`). */
export async function startRun(
config: RunConfig,
reuse?: string,
@@ -820,7 +840,8 @@ export async function startRun(
// The new process starts once the old one is gone (it may still hold a port or a lock). As in
// IDEA there is no time limit: a first Stop waits for the shutdown, a second one kills it.
await waitForExit(stopping, null);
session ??= state.sessions.find((s) => s.config.id === config.id && !busy(s));
const now = useRun.getState();
session ??= reusableTab(now.sessions, now.activeSession, config);
try {
await maybeEditorService()?.saveAll();
@@ -864,6 +885,7 @@ export async function startRun(
endedAt: null,
stdinClosed: false,
baseConfig,
pinned: workflowSettings().run.pinTabs || undefined,
};
runtimes.set(session.id, newRuntime(config));
const created = session;
+24
Переглянути файл
@@ -167,6 +167,30 @@ describe("rerunFailedConfig", () => {
expect(new Set(configs.map((config) => config.id)).size).toBe(2);
});
it("zig: --test-filter per failed test, straight for `zig test` and via the module root for `zig build`", async () => {
expect(
(await rerunFailedConfig(cfg("zig", "zig", ["test", "src/calc.zig"]), [
["calc", "adds"],
["calc", "subs"],
]))!.args,
).toEqual(["test", "src/calc.zig", "--test-filter", "adds", "--test-filter", "subs"]);
// Existing filters are replaced, not stacked.
expect(
(await rerunFailedConfig(cfg("zig", "zig", ["test", "src/calc.zig", "--test-filter", "adds"]), [
["calc", "subs"],
]))!.args,
).toEqual(["test", "src/calc.zig", "--test-filter", "subs"]);
// `zig build` forwards no args to the test binary: rerun goes through the module root
// the detection recorded.
expect(
(await rerunFailedConfig({ ...cfg("zig", "zig", ["build", "test"]), params: { testRoot: "src/tally.zig" } }, [
["tally", "subs"],
]))!.args,
).toEqual(["test", "src/tally.zig", "--test-filter", "subs"]);
// Without a recorded root a build-based run can't narrow.
expect(await rerunFailedConfig(cfg("zig", "zig", ["build", "test"]), [["tally", "subs"]])).toBeNull();
});
it("gradle and maven filters", async () => {
expect((await rerunFailedConfig(cfg("gradle", "./gradlew", ["test"]), [["com.a.FooTest", "bar()"]]))!.args).toEqual(
["test", "--tests", "com.a.FooTest.bar"],
+10 -11
Переглянути файл
@@ -250,18 +250,17 @@ function builtinTestsConfig(config: RunConfig, paths: string[][], name: string):
args = goArgs(config.args, paths, config.params?.scope === "file" ? config.params.target : undefined);
break;
case "zig": {
// Only a standalone `zig test <file>` can filter: `zig build` forwards no args to the test binary.
const file = config.args[0] === "test" ? config.args[1] : undefined;
// `zig test <file>` filters directly. A build-based run (`zig build test`) forwards no
// args to the test binary, so it re-runs through the module root the detection recorded
// — exact for projects whose tests live in one module's import graph.
const file = config.args[0] === "test" ? config.args[1] : config.params?.testRoot;
const names = unique(paths.map((p) => p[p.length - 1]!).filter(Boolean));
args =
file && names.length
? [
config.args[0]!,
file,
...dropFlag(config.args.slice(2), ["--test-filter"]),
...names.flatMap((n) => ["--test-filter", n]),
]
: null;
if (!file || !names.length) {
args = null;
break;
}
const rest = dropFlag(config.args[0] === "test" ? config.args.slice(2) : [], ["--test-filter"]);
args = ["test", file, ...rest, ...names.flatMap((n) => ["--test-filter", n])];
break;
}
case "jvm":
+5 -3
Переглянути файл
@@ -175,7 +175,7 @@ export function FindTabs() {
id: String(t.id),
label: t.title,
closable: true,
icon: t.pinned ? "pin" : undefined,
pinned: t.pinned,
}))}
activeId={String(tab.id)}
onSelect={(id) => useFindWindow.setState({ active: Number(id) })}
@@ -187,16 +187,18 @@ export function FindTabs() {
onContextMenu={(id, e) => {
const t = tabs.find((x) => x.id === Number(id));
if (!t) return;
// IDEA's content tab menu.
showContextMenu(e, [
{ id: "pin", label: t.pinned ? "Unpin Tab" : "Pin Tab", run: () => pinFindTab(t.id, !t.pinned) },
{ kind: "separator" },
{ id: "close", label: "Close Tab", run: () => closeFindTab(t.id) },
{ id: "closeAll", label: "Close All Tabs", run: () => tabs.forEach((o) => closeFindTab(o.id)) },
{
id: "closeOthers",
label: "Close Other Tabs",
disabled: tabs.length < 2,
run: () => tabs.forEach((o) => o.id !== t.id && closeFindTab(o.id)),
},
{ kind: "separator" },
{ id: "pin", label: t.pinned ? "Unpin Tab" : "Pin Tab", run: () => pinFindTab(t.id, !t.pinned) },
]);
}}
/>
+6
Переглянути файл
@@ -384,6 +384,12 @@ export const EXECUTION_PAGES: PageSpec[] = [
key: "run.showOnStart",
label: "Show Run/Debug window when a process is started",
},
{
kind: "toggle",
key: "run.pinTabs",
label: "Make configurations pinned by default",
hint: "A finished pinned tab stays open: a later run opens a new tab instead of taking its place.",
},
{
kind: "number",
key: "run.terminationTimeoutMs",
+20
Переглянути файл
@@ -3,6 +3,7 @@ import { commands } from "@/platform/ipc";
import { usePlatform } from "@/platform/platformInfo";
import { isRemoteWindow } from "@/platform/remote";
import { handleFileDrop } from "@/workbench/fileDrop";
import { dragSession, FILES_DRAG } from "@/ui";
import { droppedPathsText, installTerminalDrop } from "./drop";
vi.mock("@/platform/remote", () => ({ isRemoteWindow: vi.fn(() => false) }));
@@ -109,4 +110,23 @@ describe("terminal drop", () => {
remove();
}
});
it("pastes paths dragged from the Project view, also in a remote window", () => {
vi.mocked(isRemoteWindow).mockReturnValue(true);
const host = document.createElement("div");
const child = document.createElement("span");
host.append(child);
document.body.replaceChildren(host);
document.elementFromPoint = (() => child) as typeof document.elementFromPoint;
const paste = vi.fn();
const remove = installTerminalDrop(host, paste);
try {
const drag = dragSession({ type: FILES_DRAG, payload: "/p/src/a b.ts\n/p/docs/" });
expect(drag.move(1, 1)).toBe(true);
expect(drag.drop(1, 1)).toBe(true);
expect(paste.mock.calls).toEqual([["'/p/src/a b.ts' /p/docs"]]);
} finally {
remove();
}
});
});
+26 -7
Переглянути файл
@@ -1,13 +1,14 @@
/**
* Files dropped from the OS onto a terminal: the path is pasted at the prompt
* (shell-quoted when needed), separated by spaces, instead of the window-wide
* drop (open in the editor, "Open project in" for a folder) or the browser /
* shell falling through to a system dialog. Images are treated the same way:
* showing the path is the expected behaviour.
* Files dropped from the OS or dragged from the Project view onto a terminal: the
* path is pasted at the prompt (shell-quoted when needed), separated by spaces,
* instead of the window-wide drop (open in the editor, "Open project in" for a
* folder) or the browser / shell falling through to a system dialog. Images are
* treated the same way: showing the path is the expected behaviour.
*/
import { formatArgs } from "@/platform/commandLine";
import { usePlatform } from "@/platform/platformInfo";
import { isRemoteWindow } from "@/platform/remote";
import { FILES_DRAG, registerDropTarget } from "@/ui";
import { registerFileDropHandler } from "@/workbench/fileDrop";
/** Shell-quoted paths for the prompt; the user presses Enter to execute. */
@@ -17,13 +18,31 @@ export function droppedPathsText(paths: readonly string[]): string {
return formatArgs(paths);
}
/** Registers the terminal's drop handler; the disposer unregisters it. */
/** Registers the terminal's drop handlers; the disposer unregisters them. */
export function installTerminalDrop(host: HTMLElement, paste: (text: string) => void): () => void {
return registerFileDropHandler((paths, x, y) => {
const removeOsDrop = registerFileDropHandler((paths, x, y) => {
// A remote terminal can't reach this machine's files.
if (!paths.length || isRemoteWindow()) return false;
const el = document.elementFromPoint(x, y);
if (!el || !host.contains(el)) return false;
paste(droppedPathsText(paths));
return true;
});
// Project view paths are the project's own, so they also hold in a remote terminal.
const removeProjectDrop = registerDropTarget(host, {
accepts: (type) => type === FILES_DRAG,
over: () => {},
leave: () => {},
drop: (data) => {
const paths = data.payload
.split("\n")
.filter(Boolean)
.map((p) => (p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p));
if (paths.length) paste(droppedPathsText(paths));
},
});
return () => {
removeOsDrop();
removeProjectDrop();
};
}
+34 -2
Переглянути файл
@@ -39,7 +39,7 @@ import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platfo
import { tx } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
import { IconButton, Select, Spinner, T, isPopupOpen, showContextMenu } from "@/ui";
import { registerSoftWrapSurface } from "@/workbench/softWrap";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { changedLineGutters, diffOverview, diffWords, languageFor, viewerBase } from "./cm";
@@ -218,6 +218,10 @@ const editableExtensions = (): Extension => [history(), keymap.of(historyKeymap)
type Side = "a" | "b";
type ViewUpdateFn = (u: { docChanged: boolean; selectionSet: boolean }) => void;
/** Esc alone, not while composing text. */
const plainEscape = (e: KeyboardEvent) =>
e.key === "Escape" && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey && !e.nativeEvent.isComposing;
/** Whether `e` is one of the strokes bound to `actionId`. */
function isStrokeOf(e: KeyboardEvent, actionId: string): boolean {
const stroke = strokeOf(e.nativeEvent);
@@ -229,6 +233,7 @@ export function DiffEditor({
toolbarActions,
onNeighbour,
onJumpToSource,
onClose,
}: {
input: EditorInput;
toolbarActions?: ReactNode;
@@ -240,6 +245,8 @@ export function DiffEditor({
onNeighbour?: (data: DiffData) => void;
/** Called as Jump to Source opens the file in an editor: a modal host closes, or the editor stays hidden behind it. */
onJumpToSource?: () => void;
/** Closes the diff's editor tab (Esc, IDEA's Close Diff Editor); hosts that aren't tabs leave it out. */
onClose?: () => void;
}) {
const data = input.data as DiffData;
const { path, left, right } = data;
@@ -976,6 +983,18 @@ export function DiffEditor({
closeFind();
return true;
};
/** Select Opposite Diff Pane (⌃⇧⇥): the other side keeps its own caret, as in IDEA. */
const focusOpposite = () => {
const views = viewsRef.current;
if (!views?.a) return false;
(focusedSide.current === "a" ? views.b : views.a).focus();
return true;
};
const close = () => {
if (!onClose) return false;
onClose();
return true;
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
if (isStrokeOf(e, "find.inFile")) openFind(false);
@@ -984,7 +1003,8 @@ export function DiffEditor({
if (!findOpen.current) findSide.current = focusedSide.current;
if (!searchAgain(findTarget, isStrokeOf(e, "find.next"))) openFind(false);
} else if (e.key === "Escape" && findOpen.current) closeFind();
else return;
// Esc that nothing in the diff took (find bar, selection, completion) closes its tab, as in IDEA.
else if (!plainEscape(e) || isPopupOpen() || !close()) return;
e.preventDefault();
e.stopPropagation();
};
@@ -1122,6 +1142,8 @@ export function DiffEditor({
applyChange,
file,
showSettings,
focusOpposite,
close,
viewer: viewerOf,
});
useLayoutEffect(() => {
@@ -1135,6 +1157,8 @@ export function DiffEditor({
applyChange,
file,
showSettings,
focusOpposite,
close,
viewer: viewerOf,
});
});
@@ -1350,6 +1374,14 @@ export function DiffEditor({
onClick={() => void executeAction("vcs.showDiffInNewWindow", data)}
/>
)}
{auxiliaryWindow()?.kind === "diff" && getAction("vcs.showDiffInEditorTab") && (
<IconButton
icon="editor-only"
title="Show Diff in Editor Tab"
size="small"
onClick={() => void executeAction("vcs.showDiffInEditorTab")}
/>
)}
<IconButton
icon="settings"
title="Settings"
+38 -9
Переглянути файл
@@ -26,12 +26,14 @@ import { TitleBar } from "@/workbench/TitleBar";
import { WindowResizeHandles } from "@/workbench/WindowResizeHandles";
import { useApplyTheme, windowBackgroundHex } from "@/workbench/theme";
import { DiffEditor } from "./DiffEditor";
import { DIFF_KIND, diffInput, diffTitle, type DiffData, type DiffSide } from "./diffInput";
import { DIFF_KIND, diffInput, diffTitle, openDiff, type DiffData, type DiffSide } from "./diffInput";
import { refreshNow, useVcs } from "./store";
import styles from "./common.module.css";
/** Opens `path` in the project window's editor (DiffEditor's "Jump to Source"). */
const OPEN_SOURCE = "openidea://diff/open-source";
/** Opens a diff in an editor tab of the project window (IDEA's "Show Diff in Editor Tab"). */
const OPEN_DIFF = "openidea://diff/open-diff";
const useDiff = create<{
data: DiffData | null;
@@ -105,6 +107,24 @@ export function activateDiffWindow(parent: string): () => void {
}
},
});
// IDEA's Show Diff in Editor Tab: the diff moves to the project window, edits saved first.
const unregisterToEditor = registerAction({
id: "vcs.showDiffInEditorTab",
title: "Show Diff in Editor Tab",
category: "Git",
icon: "editor-only",
run: async () => {
const data = useDiff.getState().data;
if (!data) return;
try {
await documents.save();
await emitTo(parent, OPEN_DIFF, data);
await native.close();
} catch (error) {
showError(tx("Cannot show diff in an editor tab"), error instanceof Error ? error.message : String(error));
}
},
});
const savingReady = installDiffWindowSave(parent, documents.save).then((off) => {
if (disposed) off();
else stopSave = off;
@@ -198,6 +218,7 @@ export function activateDiffWindow(parent: string): () => void {
stopClose?.();
stopSave?.();
unregisterSave();
unregisterToEditor();
window.removeEventListener("keydown", onKey);
window.removeEventListener("keydown", onCloseShortcut, true);
};
@@ -207,23 +228,31 @@ export function activateDiffWindow(parent: string): () => void {
export function installDiffEditorHost(): () => void {
if (!isTauri()) return () => {};
let disposed = false;
let stop: (() => void) | undefined;
void getCurrentWindow()
const stops: Array<() => void> = [];
const keep = (off: () => void) => {
if (disposed) off();
else stops.push(off);
};
const native = getCurrentWindow();
void native
.listen<{ path: string; options?: OpenOptions }>(OPEN_SOURCE, ({ payload }) => {
const folder = projectFolder(useProject.getState().project, payload.path);
if (folder && payload.path.startsWith(`${folder.root}/`)) {
void editorService()
.openFile(payload.path, payload.options)
.then(() => getCurrentWindow().setFocus());
.then(() => native.setFocus());
}
})
.then((off) => {
if (disposed) off();
else stop = off;
});
.then(keep);
void native
.listen<DiffData>(OPEN_DIFF, ({ payload }) => {
if (!projectFolder(useProject.getState().project, payload.path)) return;
void openDiff(payload).then(() => native.setFocus());
})
.then(keep);
return () => {
disposed = true;
stop?.();
for (const stop of stops) stop();
};
}
+2
Переглянути файл
@@ -356,6 +356,8 @@ export function MergeEditor({ input }: { input: EditorInput }) {
applyChange: () => false,
file: () => {},
showSettings: () => void actionHost.run("settings"),
focusOpposite: () => false,
close: () => false,
}));
useEffect(
() => () => {
+43
Переглянути файл
@@ -231,3 +231,46 @@ describe("Find in the diff viewer", () => {
expect(v.state.selection.main.from).toBe(8);
});
});
describe("Esc and the opposite pane", () => {
const escape = (v: EditorView) => fireEvent.keyDown(v.contentDOM, { key: "Escape", code: "Escape", keyCode: 27 });
it("closes the tab with Esc only once nothing in the diff takes it", async () => {
useKeymap.setState({ overrides: KEYMAP });
const onClose = vi.fn();
render(<DiffEditor input={revisions} onClose={onClose} />);
const v = await press(1, "KeyF");
fireEvent.change(await screen.findByRole("textbox", { name: "Find" }), { target: { value: "foo" } });
await waitFor(() => expect(v.state.selection.main.empty).toBe(false));
v.focus();
escape(v); // closes the find bar
await waitFor(() => expect(screen.queryByRole("search")).toBeNull());
escape(v); // collapses the found match
expect(v.state.selection.main.empty).toBe(true);
expect(onClose).not.toHaveBeenCalled();
escape(v);
expect(onClose).toHaveBeenCalledOnce();
});
it("leaves Esc alone where the diff isn't a tab", async () => {
render(<DiffEditor input={revisions} />);
const v = await pane(1);
v.focus();
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
v.contentDOM.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
it("moves focus to the other side with Select Opposite Diff Pane", async () => {
const { focusedDiffNavigator } = await import("../diffNavigator");
render(<DiffEditor input={revisions} />);
const left = await pane(0);
const right = await pane(1);
right.focus();
await waitFor(() => expect(focusedDiffNavigator()).not.toBeNull());
expect(focusedDiffNavigator()!.focusOpposite()).toBe(true);
expect(left.hasFocus).toBe(true);
expect(focusedDiffNavigator()!.focusOpposite()).toBe(true);
expect(right.hasFocus).toBe(true);
});
});
+18
Переглянути файл
@@ -175,6 +175,24 @@ export function registerCompareActions(): () => void {
when: "diffEditorFocus",
run: () => focusedDiffNavigator()?.showSettings(),
},
{
// Diff.FocusOppositePane; it beats the Switcher's ⌃⇧⇥ while a side-by-side diff is focused.
id: "diff.focusOppositePane",
title: "Select Opposite Diff Pane",
category: "Diff",
when: "diffEditorFocus",
keyWhen: "diffEditorFocus && !popupOpen",
enabled: () => (focusedDiffNavigator()?.viewer?.()?.panes().length ?? 0) > 1,
run: () => void focusedDiffNavigator()?.focusOpposite(),
},
{
// CloseDiffEditor: Esc in a diff tab that nothing in the diff takes (see DiffEditor).
id: "diff.closeEditor",
title: "Close Diff Editor",
category: "Diff",
when: "diffEditorFocus",
run: () => void focusedDiffNavigator()?.close(),
},
{
id: "diff.nextFile",
title: "Compare Next File",
+2 -1
Переглянути файл
@@ -1,4 +1,4 @@
import type { EditorInput } from "@/workbench/contributions";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { hideAllToolWindows, useToolWindowLayout } from "@/workbench/toolwindows/store";
import { shortcutLabel } from "@/platform/keymap";
import { IconButton } from "@/ui";
@@ -11,6 +11,7 @@ export function DiffEditorTab({ input }: { input: EditorInput }) {
return (
<DiffEditor
input={input}
onClose={() => void editorService().closeInput?.(input.key)}
toolbarActions={
<IconButton
icon={maximized ? "minimize" : "maximize"}
+4
Переглянути файл
@@ -11,6 +11,10 @@ export interface DiffNavigator {
file(dir: 1 | -1): void;
/** The viewer's settings as a popup menu (⇧⌘D). */
showSettings(): void;
/** IDEA's Select Opposite Diff Pane: focus the other side of a side-by-side diff; false when there is none. */
focusOpposite(): boolean;
/** IDEA's Close Diff Editor (Esc): closes the diff's editor tab; false when the diff isn't one. */
close(): boolean;
/** The built panes, for diff viewer contributions' actions (null while loading). */
viewer?(): DiffViewerHost | null;
}
+14 -2
Переглянути файл
@@ -130,7 +130,11 @@
margin: 1.5em 0;
}
.preview :global(img) {
max-width: 100%;
max-width: calc(100% * var(--markdown-zoom-scale, 1));
}
.preview :global(img[data-natural-width]) {
width: calc(min(var(--image-width), 100%) * var(--markdown-zoom-scale, 1));
height: auto;
}
.preview :global(ul.contains-task-list) {
list-style: none;
@@ -155,10 +159,18 @@
display: none;
}
.preview :global(.mermaid-output) {
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
text-align: center;
}
.preview :global(.mermaid-output svg[data-natural-width]) {
display: block;
margin: 0 auto;
width: calc(min(var(--diagram-width), 100%) * var(--markdown-zoom-scale, 1));
height: auto;
}
.preview :global(.mermaid-output[data-zoomed="true"]) {
overflow: hidden;
cursor: grab;
touch-action: none;
user-select: none;
+30 -4
Переглянути файл
@@ -51,6 +51,7 @@ import {
resolveLink,
slugify,
} from "./render";
import { applyImageZoomWidth, svgDataUrlSize } from "./svgSize";
import styles from "./Markdown.module.css";
import { tx } from "@/platform/i18n";
@@ -110,6 +111,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
const text = useLiveMarkdown((s) => s[path]?.text);
const fontZoom = useLiveMarkdown((s) => s[path]?.fontZoom ?? 0);
const fontSize = useMarkdownPreviewFontSize();
const zoomScale = Number((Math.max(4, fontSize + fontZoom) / fontSize).toFixed(4));
const topLine = useLiveMarkdown((s) => s[path]?.topLine ?? 0);
const scrollSeq = useLiveMarkdown((s) => s[path]?.scrollSeq ?? 0);
const caretLine = useLiveMarkdown((s) => s[path]?.caretLine ?? 0);
@@ -161,10 +163,32 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
const root = ref.current;
if (!root) return;
let live = true;
root.querySelectorAll<HTMLImageElement>("img[data-src]").forEach((img) => {
const bindImageUrl = (img: HTMLImageElement, url: string, asyncLoad: boolean) => {
const svgSize = svgDataUrlSize(url);
if (svgSize && applyImageZoomWidth(img, svgSize) && asyncLoad) {
setDiagramsDrawn((n) => n + 1);
}
img.onload = () => {
if (!live) return;
if (!img.hasAttribute("data-natural-width") && img.naturalWidth > 0 && img.naturalHeight > 0) {
if (applyImageZoomWidth(img, { w: img.naturalWidth, h: img.naturalHeight })) {
setDiagramsDrawn((n) => n + 1);
}
} else if (asyncLoad) {
setDiagramsDrawn((n) => n + 1);
}
};
img.src = url;
if (!img.hasAttribute("data-natural-width") && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
applyImageZoomWidth(img, { w: img.naturalWidth, h: img.naturalHeight });
}
};
const imgs = [...root.querySelectorAll<HTMLImageElement>("img[data-src]")];
imgs.forEach((img) => {
applyImageZoomWidth(img);
const src = img.dataset.src ?? "";
if (/^data:image\//i.test(src)) {
img.src = src;
bindImageUrl(img, src, false);
return;
}
const target = resolveLink(path, src);
@@ -174,7 +198,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
return;
}
imageUrl(target.path).then(
(url) => live && (img.src = url),
(url) => live && bindImageUrl(img, url, true),
() => live && img.classList.add(styles.blocked!),
);
});
@@ -187,6 +211,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
});
return () => {
live = false;
for (const img of imgs) img.onload = null;
};
}, [html, path]);
@@ -195,7 +220,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
const root = ref.current;
if (!root || !sync) return;
root.scrollTop = topLine <= 0 ? 0 : previewOffsetForLine(blocksOf(root), topLine, root.scrollHeight);
}, [html, topLine, scrollSeq, sync, diagramsDrawn]);
}, [html, topLine, scrollSeq, sync, diagramsDrawn, fontZoom, fontSize]);
// The preview's own scroll offset: tab switches and mode toggles unmount the
// preview, so Preview Only would otherwise start over at the top. Writes made
@@ -376,6 +401,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
{
"--markdown-preview-font-size": `${fontSize}px`,
"--markdown-font-zoom": `${fontZoom}px`,
"--markdown-zoom-scale": String(zoomScale),
} as CSSProperties
}
tabIndex={-1}
+9
Переглянути файл
@@ -396,6 +396,15 @@ describe("mermaid in the markdown preview", () => {
expect(root.querySelector("[data-mermaid]")?.getAttribute("data-mermaid-state")).toBe("pending");
});
it("records a diagram's natural width so preview font zoom scales it without clamping", async () => {
const svg = await renderedSvg(
'<svg id="openidea-mermaid-1" width="100%" style="max-width: 540px;" viewBox="0 0 540 320"><text>flow</text></svg>',
);
expect(svg.hasAttribute("data-natural-width")).toBe(true);
expect(svg.style.getPropertyValue("--diagram-width")).toBe("540px");
expect(svg.style.maxWidth).toBe("calc(min(var(--diagram-width), 100%) * var(--markdown-zoom-scale, 1))");
});
it("puts full-screen and download tools on a drawn diagram", async () => {
const root = preview(SEQ);
await renderMermaidIn(root).done;
+10 -1
Переглянути файл
@@ -11,6 +11,7 @@ import { attachPanZoom } from "../panZoom";
import { attachDiagramTools, TOOLS_ATTR } from "./mermaidTools";
import { localSvg } from "./localSvg";
import { reid } from "./svgIds";
import { svgElementSize } from "./svgSize";
export const MERMAID_SELECTOR = "[data-mermaid]";
const OUTPUT_CLASS = "mermaid-output";
@@ -250,7 +251,15 @@ function show(block: HTMLElement, result: Result | null, stale?: Node) {
if (block.getAttribute(RENDERED_ATTR) === "ok") {
attachPanZoom(out);
const svg = out.querySelector("svg");
if (svg) attachDiagramTools(block, svg);
if (svg) {
const size = svgElementSize(svg);
if (size) {
svg.dataset.naturalWidth = "";
svg.style.setProperty("--diagram-width", `${Number(size.w.toFixed(2))}px`);
svg.style.maxWidth = "calc(min(var(--diagram-width), 100%) * var(--markdown-zoom-scale, 1))";
}
attachDiagramTools(block, svg);
}
}
}
+3 -5
Переглянути файл
@@ -11,6 +11,7 @@ import { openLayer, trapTab } from "@/ui/popupState";
import { notifications } from "@/workbench/contributions";
import { attachPanZoom } from "../panZoom";
import { reid } from "./svgIds";
import { svgElementSize } from "./svgSize";
import styles from "./MermaidTools.module.css";
/** Marks the tool strip, so a redrawing block replaces it rather than stacking one. */
@@ -161,11 +162,8 @@ function fittedCopy(svg: SVGSVGElement, availW: number, availH: number): SVGSVGE
}
function naturalSize(svg: SVGSVGElement): { w: number; h: number } {
const box = (svg as SVGSVGElement & { viewBox?: SVGAnimatedRect }).viewBox?.baseVal;
if (box && box.width > 0 && box.height > 0) return { w: box.width, h: box.height };
const width = Number(svg.getAttribute("width"));
const height = Number(svg.getAttribute("height"));
if (width > 0 && height > 0) return { w: width, h: height };
const declared = svgElementSize(svg);
if (declared) return declared;
const rect = svg.getBoundingClientRect();
return { w: rect.width > 0 ? rect.width : 800, h: rect.height > 0 ? rect.height : 600 };
}
+147
Переглянути файл
@@ -0,0 +1,147 @@
/**
* Intrinsic CSS pixel dimensions of SVGs and Markdown preview images, so
* diagrams and embedded `.svg` / raster assets scale with `--markdown-zoom-scale`.
*/
const UNIT_PX: Record<string, number> = {
"": 1,
px: 1,
pt: 96 / 72,
pc: 16,
in: 96,
cm: 96 / 2.54,
mm: 96 / 25.4,
};
/** Parses an absolute SVG/HTML length into CSS pixels (ignoring `%`, `em`, etc.). */
export function parseLengthPx(raw: string | null | undefined): number | null {
if (!raw) return null;
const m = /^\s*(\d+(?:\.\d+)?|\.\d+)\s*([a-z%]*)\s*$/i.exec(raw);
if (!m) return null;
const factor = UNIT_PX[m[2]!.toLowerCase()];
if (!factor) return null;
const n = Number(m[1]) * factor;
return Number.isFinite(n) && n > 0 ? n : null;
}
function parsePercent(raw: string | null | undefined): number | null {
if (!raw) return null;
const m = /^\s*(\d+(?:\.\d+)?|\.\d+)%\s*$/.exec(raw);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) && n > 0 ? n : null;
}
/** Parses `viewBox="minX minY width height"` into `{ w, h }`. */
export function parseViewBoxSize(raw: string | null | undefined): { w: number; h: number } | null {
if (!raw) return null;
const parts = raw
.trim()
.split(/[\s,]+/)
.map(Number);
if (parts.length !== 4) return null;
const w = parts[2]!;
const h = parts[3]!;
return Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? { w, h } : null;
}
function resolveSvgDims(
style: string | null | undefined,
widthAttr: string | null | undefined,
heightAttr: string | null | undefined,
viewBoxAttr: string | null | undefined,
): { w: number; h: number } | null {
const vb = parseViewBoxSize(viewBoxAttr);
const storedW = parseLengthPx(/(?:^|;)\s*--diagram-width\s*:\s*([^;]+)/i.exec(style ?? "")?.[1]);
const inlineMaxW = parseLengthPx(/(?:^|;)\s*max-width\s*:\s*([^;]+)/i.exec(style ?? "")?.[1]);
const attrW = parseLengthPx(widthAttr);
const attrH = parseLengthPx(heightAttr);
const w = storedW ?? inlineMaxW ?? attrW ?? (attrH && vb ? (attrH * vb.w) / vb.h : (vb?.w ?? null));
if (!w) return null;
const h = attrH ?? (vb ? (w * vb.h) / vb.w : w * 0.75);
return { w, h };
}
/** Intrinsic CSS pixel size of an in-DOM `<svg>`, when declared on the element. */
export function svgElementSize(svg: SVGSVGElement): { w: number; h: number } | null {
const fromAttrs = resolveSvgDims(
svg.getAttribute("style"),
svg.getAttribute("width"),
svg.getAttribute("height"),
svg.getAttribute("viewBox"),
);
if (fromAttrs) return fromAttrs;
const box = (svg as SVGSVGElement & { viewBox?: SVGAnimatedRect }).viewBox?.baseVal;
if (box && box.width > 0 && box.height > 0) return { w: box.width, h: box.height };
return null;
}
function readAttr(tag: string, name: string): string | null {
const re = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
const m = re.exec(tag);
return m ? (m[1] ?? m[2] ?? m[3] ?? null) : null;
}
/**
* Intrinsic CSS pixel size of a `data:image/svg+xml` URL from its `<svg>` header.
* Decodes only the leading chunk so large `.excalidraw.svg` scenes stay cheap.
*/
export function svgDataUrlSize(url: string): { w: number; h: number } | null {
const comma = url.indexOf(",");
if (comma < 0) return null;
const header = url.slice(0, comma);
if (!/^data:image\/svg\+xml\b/i.test(header)) return null;
const payload = url.slice(comma + 1, comma + 1 + 16384);
let head: string;
try {
if (/;base64\b/i.test(header)) {
const clean = payload.replace(/\s+/g, "");
const aligned = clean.slice(0, clean.length - (clean.length % 4));
head = atob(aligned);
} else {
const safe = payload.replace(/%[0-9a-f]?$/i, "");
head = decodeURIComponent(safe);
}
} catch {
return null;
}
const withoutComments = head.replace(/<!--[\s\S]*?-->/g, "");
const open = /<svg\b((?:"[^"]*"|'[^']*'|[^'">])*)>/i.exec(withoutComments);
if (!open) return null;
const attrs = open[1]!;
return resolveSvgDims(
readAttr(attrs, "style"),
readAttr(attrs, "width"),
readAttr(attrs, "height"),
readAttr(attrs, "viewBox"),
);
}
const formatPx = (n: number) => `${Number(n.toFixed(2))}px`;
/**
* Sets an `<img>`'s 1× base width so CSS scales it by `--markdown-zoom-scale`,
* respecting explicit HTML `width` / `height` attributes when present.
*/
export function applyImageZoomWidth(img: HTMLImageElement, intrinsic?: { w: number; h: number } | null): boolean {
const rawWidth = img.getAttribute("width");
const pct = parsePercent(rawWidth);
if (pct !== null) {
img.style.width = `calc(${pct}% * var(--markdown-zoom-scale, 1))`;
img.style.height = "auto";
return true;
}
const attrW = parseLengthPx(rawWidth);
if (attrW !== null) {
img.style.setProperty("--image-width", formatPx(attrW));
img.dataset.naturalWidth = "";
return true;
}
if (!intrinsic || intrinsic.w <= 0 || intrinsic.h <= 0) return false;
const attrH = parseLengthPx(img.getAttribute("height"));
const baseW = attrH !== null ? (attrH * intrinsic.w) / intrinsic.h : intrinsic.w;
img.style.setProperty("--image-width", formatPx(baseW));
img.dataset.naturalWidth = "";
return true;
}
+88
Переглянути файл
@@ -101,14 +101,102 @@ describe("editor font size actions", () => {
);
const preview = screen.getByRole("document", { name: "Markdown preview" });
expect(preview.style.getPropertyValue("--markdown-font-zoom")).toBe("0px");
expect(preview.style.getPropertyValue("--markdown-zoom-scale")).toBe("1");
await act(() => executeAction("editor.increaseFontSize"));
await act(() => executeAction("editor.increaseFontSize"));
expect(preview.style.getPropertyValue("--markdown-font-zoom")).toBe("2px");
expect(preview.style.getPropertyValue("--markdown-zoom-scale")).toBe("1.1538");
await act(() => executeAction("editor.decreaseFontSize"));
await act(() => executeAction("editor.decreaseFontSize"));
await act(() => executeAction("editor.decreaseFontSize"));
expect(preview.style.getPropertyValue("--markdown-font-zoom")).toBe("-1px");
expect(preview.style.getPropertyValue("--markdown-zoom-scale")).toBe("0.9231");
await act(() => executeAction("editor.resetFontSize"));
expect(zoom(md)).toBe(0);
expect(preview.style.getPropertyValue("--markdown-font-zoom")).toBe("0px");
expect(preview.style.getPropertyValue("--markdown-zoom-scale")).toBe("1");
});
it("sizes embedded excalidraw.svg, graph SVGs and raster images so they scale with font zoom", async () => {
const excalidrawSvg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 940" width="1280" height="940"><!-- svg-source:excalidraw --></svg>';
const excalidrawUrl = `data:image/svg+xml;base64,${btoa(excalidrawSvg)}`;
const graphvizUrl = `data:image/svg+xml;utf8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="300pt" height="150pt" viewBox="0 0 300 150"></svg>')}`;
const wordmarkUrl = `data:image/svg+xml;base64,${btoa('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 56"></svg>')}`;
const pngUrl = "data:image/png;base64,iVBORw0KGgo=";
const ipc = await import("@/platform/ipc");
const fsReadImage = vi.spyOn(ipc.commands, "fsReadImage").mockImplementation(async (p: string) => {
if (p.endsWith("architecture.excalidraw.svg")) return { status: "ok", data: excalidrawUrl };
if (p.endsWith("wordmark.svg")) return { status: "ok", data: wordmarkUrl };
if (p.endsWith("shot.png")) return { status: "ok", data: pngUrl };
return { status: "error", error: { kind: "notFound", message: p } } as never;
});
try {
const doc = [
"![Architecture](./architecture.excalidraw.svg)",
"",
`![Graph](${graphvizUrl})`,
"",
'<img src="./wordmark.svg" alt="Wordmark" height="56">',
"",
'<img src="./wordmark.svg" alt="Icon" width="80">',
"",
"![Shot](./shot.png)",
].join("\n");
const live = editorContributions().find((c) => c.id === "viewers.markdownLive")!;
const view = new EditorView({
state: EditorState.create({ doc, extensions: [fontZoom, live.extensions(mdCtx)] }),
});
const handle = { view, ctx: mdCtx };
_addEditor(handle);
disposers.push(() => _removeEditor(handle));
views.push(view);
activate(view);
act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [mdCtx.path]: "preview" } })));
render(
<MarkdownEditor path={mdCtx.path} tabId="t">
<div />
</MarkdownEditor>,
);
const preview = screen.getByRole("document", { name: "Markdown preview" });
await vi.waitFor(() => {
const arch = preview.querySelector<HTMLImageElement>('img[alt="Architecture"]');
expect(arch?.getAttribute("src")).toBe(excalidrawUrl);
});
const arch = preview.querySelector<HTMLImageElement>('img[alt="Architecture"]')!;
expect(arch.hasAttribute("data-natural-width")).toBe(true);
expect(arch.style.getPropertyValue("--image-width")).toBe("1280px");
const graph = preview.querySelector<HTMLImageElement>('img[alt="Graph"]')!;
expect(graph.hasAttribute("data-natural-width")).toBe(true);
expect(graph.style.getPropertyValue("--image-width")).toBe("400px");
const wordmark = preview.querySelector<HTMLImageElement>('img[alt="Wordmark"]')!;
expect(wordmark.hasAttribute("data-natural-width")).toBe(true);
expect(wordmark.style.getPropertyValue("--image-width")).toBe("280px");
const icon = preview.querySelector<HTMLImageElement>('img[alt="Icon"]')!;
expect(icon.hasAttribute("data-natural-width")).toBe(true);
expect(icon.style.getPropertyValue("--image-width")).toBe("80px");
const shot = preview.querySelector<HTMLImageElement>('img[alt="Shot"]')!;
Object.defineProperty(shot, "naturalWidth", { value: 960, configurable: true });
Object.defineProperty(shot, "naturalHeight", { value: 600, configurable: true });
act(() => {
shot.dispatchEvent(new Event("load"));
});
expect(shot.hasAttribute("data-natural-width")).toBe(true);
expect(shot.style.getPropertyValue("--image-width")).toBe("960px");
} finally {
fsReadImage.mockRestore();
}
});
it("scales a configured editor font size", () => {
+5
Переглянути файл
@@ -50,6 +50,11 @@ export function attachPanZoom(viewport: HTMLElement, options: PanZoomOptions = {
(e) => {
if (!(e.metaKey || e.ctrlKey) || !e.deltaY) return;
e.preventDefault();
if (view.z === origin.z && (viewport.scrollLeft || viewport.scrollTop)) {
view = { ...view, x: view.x - viewport.scrollLeft, y: view.y - viewport.scrollTop };
viewport.scrollLeft = 0;
viewport.scrollTop = 0;
}
const rect = viewport.getBoundingClientRect();
// One wheel notch (~100 px, 3 lines or a page) zooms about 15%; a trackpad
// sends small pixel deltas, so zooming follows the gesture continuously.
+1
Переглянути файл
@@ -85,6 +85,7 @@ export const CLIENT_SETTINGS_KEYS = [
"run.confirmRerun",
"run.confirmTerminate",
"run.showOnStart",
"run.pinTabs",
"vcs.showDirtyDirectories",
"vcs.git.inlineBlame",
"vcs.git.showBranchInProjectView",
+1
Переглянути файл
@@ -97,6 +97,7 @@ export const eclipseMacos: Record<string, string[]> = {
"diff.nextDifference": ["Ctrl+F7"],
"diff.prevFile": [],
"diff.nextFile": [],
"diff.focusOppositePane": [],
"navigate.nextOccurrence": [],
"editor.nextTab": ["Meta+F6", "Meta+Alt+Right"],
"db.nextResultTab": ["Meta+F6", "Meta+Alt+Right"],
+1
Переглянути файл
@@ -99,6 +99,7 @@ export const eclipseWindows: Record<string, string[]> = {
"diff.nextDifference": ["Ctrl+F7"],
"diff.prevFile": ["Ctrl+Alt+Shift+Right"],
"diff.nextFile": ["Ctrl+Alt+Shift+Left"],
"diff.focusOppositePane": [],
"diff.applyLeftSide": [],
"diff.applyRightSide": [],
"navigate.nextOccurrence": [],
+1
Переглянути файл
@@ -338,6 +338,7 @@ export const intellijMacos: Record<string, string[]> = {
"diff.nextFile": ["Ctrl+Shift+Right"],
"diff.prevFile": ["Ctrl+Shift+Left"],
"diff.showSettings": ["Meta+Shift+D"],
"diff.focusOppositePane": ["Ctrl+Shift+Tab"],
// After Show Diff: ⌘D is Compare With… in the project view.
"project.compareWith": ["Meta+D"],
// Editing extras, image viewer (keyWhen: image focused) and Markdown editor layouts
+1
Переглянути файл
@@ -336,6 +336,7 @@ export const intellijWindows: Record<string, string[]> = {
"diff.nextFile": ["Alt+Shift+Right"],
"diff.prevFile": ["Alt+Shift+Left"],
"diff.showSettings": ["Ctrl+Shift+D"],
"diff.focusOppositePane": ["Ctrl+Shift+Tab"],
// After Show Diff: Ctrl+D is Compare With… in the project view.
"project.compareWith": ["Ctrl+D"],
// Editing extras, image viewer (keyWhen: image focused) and Markdown editor layouts
+1
Переглянути файл
@@ -350,6 +350,7 @@ export const vscodeMacos: Record<string, string[]> = {
"diff.nextFile": [],
"diff.prevFile": [],
"diff.showSettings": [],
"diff.focusOppositePane": [],
"diff.prevDifference": ["Shift+F7"],
"diff.jumpToSource": [],
// HTTP Client / Database console: run the request or statement at the caret.
+7
Переглянути файл
@@ -1,4 +1,11 @@
{
"Select Opposite Diff Pane": "Select Opposite Diff Pane",
"Close Diff Editor": "Close Diff Editor",
"Show Diff in Editor Tab": "Show Diff in Editor Tab",
"Cannot show diff in an editor tab": "Cannot show diff in an editor tab",
"Make configurations pinned by default": "Make configurations pinned by default",
"A finished pinned tab stays open: a later run opens a new tab instead of taking its place.": "A finished pinned tab stays open: a later run opens a new tab instead of taking its place.",
"Cannot paste the image into {name}": "Cannot paste the image into {name}",
"Worktree Conflict": "Worktree Conflict",
"Branch '{branch}' is already checked out in a worktree at:\n{path}": "Branch '{branch}' is already checked out in a worktree at:\n{path}",
"Checkout Anyway": "Checkout Anyway",
+7
Переглянути файл
@@ -1,4 +1,11 @@
{
"Select Opposite Diff Pane": "选择对侧差异窗格",
"Close Diff Editor": "关闭差异编辑器",
"Show Diff in Editor Tab": "在编辑器标签页中显示差异",
"Cannot show diff in an editor tab": "无法在编辑器标签页中显示差异",
"Make configurations pinned by default": "默认固定运行/调试标签页",
"A finished pinned tab stays open: a later run opens a new tab instead of taking its place.": "固定的标签页结束后会保留:之后的运行会打开新标签页,而不是替换它。",
"Cannot paste the image into {name}": "无法将图片粘贴为 {name}",
"Worktree Conflict": "工作树冲突",
"Branch '{branch}' is already checked out in a worktree at:\n{path}": "分支 '{branch}' 已在以下位置的工作树中签出:\n{path}",
"Checkout Anyway": "仍然签出",
+11 -1
Переглянути файл
@@ -116,6 +116,8 @@ export interface RunSettingsValid {
confirmRerun: boolean;
confirmTerminate: boolean;
showOnStart: boolean;
/** New Run/Debug tabs start pinned, so a later run doesn't take their place (IDEA's advanced setting). */
pinTabs: boolean;
terminationTimeoutMs: number;
}
@@ -304,7 +306,14 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
debugServerPage: false,
python: { attachSubprocess: true, gevent: false },
},
run: { defaultTarget: "", confirmRerun: true, confirmTerminate: true, showOnStart: true, terminationTimeoutMs: 3000 },
run: {
defaultTarget: "",
confirmRerun: true,
confirmTerminate: true,
showOnStart: true,
pinTabs: false,
terminationTimeoutMs: 3000,
},
terminal: {
startDirectory: "",
env: {},
@@ -477,6 +486,7 @@ export function validateRun(raw: unknown): RunSettingsValid {
confirmRerun: bool(v.confirmRerun, d.confirmRerun),
confirmTerminate: bool(v.confirmTerminate, d.confirmTerminate),
showOnStart: bool(v.showOnStart, d.showOnStart),
pinTabs: bool(v.pinTabs, d.pinTabs),
terminationTimeoutMs: num(
v.terminationTimeoutMs,
d.terminationTimeoutMs,
+56
Переглянути файл
@@ -1501,6 +1501,62 @@ html {
--db-connected: var(--success);
--db-connected-ring: var(--success);
}
/*
* The bundled schemes' red-green variants (IDEA's deuteranopia / protanopia values): syntax
* without the green-red and purple-blue pairs, errors in orange, highlights in blue / olive.
* Other colour themes and plugin themes keep their own editor colours.
*/
:root[data-red-green]:not(
[data-theme="light"],
[data-theme="dark-hc"],
[data-contrast="high"],
[data-color-theme],
[data-plugin-theme]
) {
--syn-keyword: #597cc2;
--syn-string: #94907f;
--syn-number: #66a6ff;
--syn-constant: #828eba;
--syn-property: #828eba;
--syn-function: #d9af6c;
--syn-annotation: #66a6ff;
--syn-decorator: #66a6ff;
--syn-escape: #597cc2;
--syn-todo: #bfa222;
--syn-invalid: #bd693c;
--syn-markup-heading: var(--syn-keyword);
--syn-markup-code: var(--syn-string);
--wave-error: #e57132;
--editor-error-decoration: underline wavy #e57132;
--editor-warning-background: #454122;
--wave-typo: #4b734f;
--bg-identifier-under-caret: #32394c;
--bg-identifier-under-caret-write: #253873;
--bg-search-result: #404861;
--bg-text-search-result: #363827;
--bg-text-search-result-current: #66731d;
--bg-breakpoint-line: #42372b;
--coverage-covered: #445956;
--coverage-partial: #807540;
--coverage-uncovered: #704b25;
}
:root[data-theme="light"][data-red-green]:not([data-color-theme], [data-plugin-theme]) {
--syn-keyword: #00428c;
--syn-string: #6a84db;
--syn-number: #3777e6;
--syn-constant: #004b9f;
--syn-property: #004b9f;
--syn-function: #121314;
--syn-annotation: #3381ff;
--syn-decorator: #3381ff;
--syn-todo: #3777e6;
--syn-invalid: #d95716;
--syn-markup-heading: var(--syn-keyword);
--syn-markup-code: var(--syn-string);
--editor-warning-background: #fff0a6;
--bg-identifier-under-caret: #d9e6ff;
--bg-identifier-under-caret-write: #e0e0cc;
}
/* PHP editors: variables and function calls use the PHP colours (PHP_VAR / PHP_FUNCTION_CALL). */
.cm-editor[data-language="PHP"] {
--syn-variable: var(--syn-php-variable);
+2 -1
Переглянути файл
@@ -72,7 +72,7 @@
color: var(--fg-faint);
}
/* Toolbar / action buttons: transparent on hover, filled when pressed or selected. */
/* Toolbar / action buttons share the toolbar hover, pressed and selected tokens. */
.iconButton {
flex: none;
display: inline-flex;
@@ -89,6 +89,7 @@
outline-offset: -2px;
}
.iconButton:not(:disabled):hover {
background: var(--bg-toolbar-hover);
color: var(--fg-strong);
}
.iconButton:not(:disabled):active {
+4
Переглянути файл
@@ -146,6 +146,10 @@
background: transparent;
color: var(--fg-secondary);
}
.mainMenuButton:hover,
.mainMenuButton[aria-expanded="true"] {
background: var(--bg-toolbar-hover);
}
.mainMenuButton:focus-visible,
.menuTitle:focus-visible,
.caption:focus-visible {