дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 14:15:39 +02:00
Порівняти коміти
15
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
dcd4f150bd | ||
|
|
8caeb1305f | ||
|
|
7ec0a4fed2 | ||
|
|
733b6cff1d | ||
|
|
b0860ed1e2 | ||
|
|
48a13cb8fa | ||
|
|
c9488df786 | ||
|
|
4427ec2c07 | ||
|
|
cf71a61cdf | ||
|
|
f9af0e7bff | ||
|
|
6449b68bb0 | ||
|
|
5d2d814610 | ||
|
|
3f18a8a274 | ||
|
|
f8a8ecb662 | ||
|
|
e2b1a2d732 |
No files matched your search
@@ -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.
|
||||
|
||||
Створено
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
@@ -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(>k::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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`;检查新增的那一行是否是你想要的信任级别和运行位置。
|
||||
@@ -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>`。
|
||||
|
||||
@@ -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` 还会再校验一次。
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
## 语言冒烟测试
|
||||
|
||||
|
||||
@@ -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",
|
||||
"```",
|
||||
"",
|
||||
"",
|
||||
].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 }) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)});
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[])
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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[])
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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) },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -356,6 +356,8 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
applyChange: () => false,
|
||||
file: () => {},
|
||||
showSettings: () => void actionHost.run("settings"),
|
||||
focusOpposite: () => false,
|
||||
close: () => false,
|
||||
}));
|
||||
useEffect(
|
||||
() => () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = [
|
||||
"",
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
'<img src="./wordmark.svg" alt="Wordmark" height="56">',
|
||||
"",
|
||||
'<img src="./wordmark.svg" alt="Icon" width="80">',
|
||||
"",
|
||||
"",
|
||||
].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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "仍然签出",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Заблокувати користувача