дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(run): import run configurations from .vscode/launch.json (#1030)
Run/Debug Configurations offers Import from .vscode/launch.json when the project has one (also a Find Action command). debugpy/python launches become Python, Django test, module or pytest configurations with their interpreter; debugpy attach becomes Python Remote Debug; node/pwa-node launches become node or npm configurations, attaches become Attach to Node.js, and node-terminal commands map to npm, a program or a shell line. cwd, env, envFile and the per-OS blocks are applied. Imported configurations join the dialog like new ones and are saved on OK/Apply; names already present are skipped, and a notification lists skipped entries with the reason and keys with no equivalent. Refs #991 Co-authored-by: andreabasile <[email protected]>
Цей коміт міститься в:
1 джерело
f00ce451ac
коміт
5c642819e2
20 files changed
+1908
-9
No files matched your search
@@ -4,6 +4,7 @@ use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_run::RunConfig;
|
||||
use ide_run::import::{RunImport, RunImportSource};
|
||||
|
||||
pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult<RunConfig> {
|
||||
host.require_trust(window)?;
|
||||
@@ -105,6 +106,25 @@ pub fn run_save_templates(host: &Host, window: &str, templates: Vec<RunConfig>)
|
||||
ide_run::save_templates(&root, &templates)
|
||||
}
|
||||
|
||||
/// Project files run configurations can be imported from (`.vscode/launch.json`).
|
||||
pub fn run_import_sources(host: &Host, window: &str) -> IdeResult<Vec<RunImportSource>> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
Ok(ide_run::import::available(&root))
|
||||
}
|
||||
|
||||
/// Configurations read from `source`, not saved; names already in `existing` (the
|
||||
/// configurations being edited) are skipped.
|
||||
pub fn run_import_configs(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
source: RunImportSource,
|
||||
existing: Vec<RunConfig>,
|
||||
) -> IdeResult<RunImport> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window)?.root.clone();
|
||||
ide_run::import_configs(&root, source, &existing)
|
||||
}
|
||||
|
||||
/// The environment runs inherit when "Include system environment variables" is on.
|
||||
pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap<String, String> {
|
||||
ide_run::system_env()
|
||||
@@ -240,6 +260,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
});
|
||||
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
|
||||
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
|
||||
d.add("run_import_sources", |c| async move { run_import_sources(&c.host, &c.window) });
|
||||
d.add("run_import_configs", |c| async move {
|
||||
run_import_configs(&c.host, &c.window, c.arg("source")?, c.arg("existing")?)
|
||||
});
|
||||
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
|
||||
d.add("run_gradle_jvm_args_script", |c| async move {
|
||||
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
|
||||
|
||||
@@ -117,7 +117,7 @@ struct FileConfig {
|
||||
target: Option<String>,
|
||||
}
|
||||
|
||||
fn slug(s: &str) -> String {
|
||||
pub(crate) fn slug(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
if c.is_alphanumeric() {
|
||||
|
||||
@@ -823,6 +823,19 @@ impl PyEnv {
|
||||
}
|
||||
}
|
||||
|
||||
/// The interpreter an imported debugger configuration runs with, as `(command, leading args)`:
|
||||
/// a project virtualenv's `python` (what the debugger launches directly), else the detected
|
||||
/// environment's (`uv run python`, `python3`). The command is relative to the root.
|
||||
pub(crate) fn project_python(root: &Path) -> (String, Vec<String>) {
|
||||
for venv in [".venv", "venv"] {
|
||||
let rel = format!("{venv}/{}/{}", PyEnv::bin_dir(), PyEnv::python_exe());
|
||||
if root.join(&rel).is_file() {
|
||||
return (rel, Vec::new());
|
||||
}
|
||||
}
|
||||
PyEnv::detect(root).python()
|
||||
}
|
||||
|
||||
fn py_config(
|
||||
id: String,
|
||||
name: String,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Run configurations imported on request from another IDE's project files
|
||||
//! (`.vscode/launch.json`). Each source turns its entries into [`Entry`]s; [`finish`]
|
||||
//! gives them ids, skips names that already exist (so importing again adds nothing
|
||||
//! twice) and resolves compound members by name.
|
||||
|
||||
mod vscode;
|
||||
|
||||
use crate::RunConfig;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// A project file run configurations can be imported from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RunImportSource {
|
||||
/// VS Code's `.vscode/launch.json`.
|
||||
Vscode,
|
||||
}
|
||||
|
||||
impl RunImportSource {
|
||||
pub const ALL: [Self; 1] = [Self::Vscode];
|
||||
|
||||
/// The file read, relative to the project root.
|
||||
pub fn file(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vscode => ".vscode/launch.json",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an import found: configurations to add and entries left out, with the reason.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunImport {
|
||||
pub source: RunImportSource,
|
||||
/// The file read, relative to the project root.
|
||||
pub file: String,
|
||||
pub imported: Vec<ImportedRunConfig>,
|
||||
pub skipped: Vec<SkippedRunConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportedRunConfig {
|
||||
pub config: RunConfig,
|
||||
/// Keys of the source entry with no equivalent here (`preLaunchTask`, `justMyCode`…).
|
||||
pub ignored: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkippedRunConfig {
|
||||
pub name: String,
|
||||
pub reason: SkipReason,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum SkipReason {
|
||||
/// A configuration with this name already exists.
|
||||
NameTaken,
|
||||
/// The debugger type (`type`) has no equivalent here, e.g. `cppdbg`.
|
||||
UnsupportedType {
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
},
|
||||
/// A `request` other than `launch` and `attach`.
|
||||
UnsupportedRequest { request: String },
|
||||
/// An attach other than to a host and port (to a process id, or listening for it).
|
||||
UnsupportedAttach,
|
||||
/// Uses a variable only known when VS Code starts it (`${file}`, `${input:…}`, `${command:…}`).
|
||||
Variable { variable: String },
|
||||
/// No program, module or command.
|
||||
NothingToRun,
|
||||
/// A compound member that is neither imported nor an existing configuration.
|
||||
MissingMember { member: String },
|
||||
/// Not an object with a name.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// One entry of the source file, before ids and names are settled.
|
||||
pub(crate) struct Entry {
|
||||
pub name: String,
|
||||
pub result: Result<Found, SkipReason>,
|
||||
}
|
||||
|
||||
pub(crate) struct Found {
|
||||
/// `id` is assigned by [`finish`].
|
||||
pub config: RunConfig,
|
||||
/// Names of the configurations a compound starts.
|
||||
pub members: Vec<String>,
|
||||
pub ignored: Vec<String>,
|
||||
}
|
||||
|
||||
/// Configurations from `source` under `root`, given the ones the project has now (`existing`,
|
||||
/// detected included). `env` resolves `${env:NAME}` and `${userHome}`.
|
||||
pub fn import(
|
||||
root: &Path,
|
||||
source: RunImportSource,
|
||||
existing: &[RunConfig],
|
||||
env: &BTreeMap<String, String>,
|
||||
) -> IdeResult<RunImport> {
|
||||
let file = source.file();
|
||||
let text = match std::fs::read_to_string(root.join(file)) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(IdeError::not_found(format!("{file} not found")));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let entries = match source {
|
||||
RunImportSource::Vscode => vscode::entries(&text, root, env)?,
|
||||
};
|
||||
Ok(finish(source, entries, existing))
|
||||
}
|
||||
|
||||
/// The sources whose file exists under `root`.
|
||||
pub fn available(root: &Path) -> Vec<RunImportSource> {
|
||||
RunImportSource::ALL.into_iter().filter(|s| root.join(s.file()).is_file()).collect()
|
||||
}
|
||||
|
||||
fn unique_id(name: &str, ids: &mut HashSet<String>) -> String {
|
||||
let slug = crate::config::slug(name);
|
||||
let base = format!("user:{}", if slug.is_empty() { "imported" } else { &slug });
|
||||
let mut id = base.clone();
|
||||
let mut n = 2;
|
||||
while ids.contains(&id) {
|
||||
id = format!("{base}-{n}");
|
||||
n += 1;
|
||||
}
|
||||
ids.insert(id.clone());
|
||||
id
|
||||
}
|
||||
|
||||
pub(crate) fn finish(source: RunImportSource, entries: Vec<Entry>, existing: &[RunConfig]) -> RunImport {
|
||||
let mut ids: HashSet<String> = existing.iter().map(|c| c.id.clone()).collect();
|
||||
let mut by_name: HashMap<String, String> = HashMap::new();
|
||||
for c in existing {
|
||||
// Imported names are trimmed: compare existing ones the same way.
|
||||
by_name.entry(c.name.trim().to_owned()).or_insert_with(|| c.id.clone());
|
||||
}
|
||||
let mut imported: Vec<(usize, ImportedRunConfig)> = Vec::new();
|
||||
let mut skipped: Vec<(usize, SkippedRunConfig)> = Vec::new();
|
||||
let mut compounds = Vec::new();
|
||||
let skip = |name: String, reason| SkippedRunConfig { name, reason };
|
||||
|
||||
// Compounds last: their members may be any configuration of the file.
|
||||
for (i, e) in entries.into_iter().enumerate() {
|
||||
match e.result {
|
||||
Err(reason) => skipped.push((i, skip(e.name, reason))),
|
||||
Ok(_) if by_name.contains_key(&e.name) => skipped.push((i, skip(e.name, SkipReason::NameTaken))),
|
||||
Ok(found) if found.config.kind == "compound" => {
|
||||
// Reserve the name so a later configuration can't take it.
|
||||
by_name.insert(e.name.clone(), String::new());
|
||||
compounds.push((i, e.name, found));
|
||||
}
|
||||
Ok(mut found) => {
|
||||
found.config.id = unique_id(&e.name, &mut ids);
|
||||
by_name.insert(e.name.clone(), found.config.id.clone());
|
||||
imported.push((i, ImportedRunConfig { config: found.config, ignored: found.ignored }));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, name, mut found) in compounds {
|
||||
let members: Result<Vec<String>, String> = found
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| by_name.get(m).filter(|id| !id.is_empty()).cloned().ok_or_else(|| m.clone()))
|
||||
.collect();
|
||||
match members {
|
||||
Ok(members) => {
|
||||
found.config.id = unique_id(&name, &mut ids);
|
||||
found.config.extras.compound = members;
|
||||
imported.push((i, ImportedRunConfig { config: found.config, ignored: found.ignored }));
|
||||
}
|
||||
Err(member) => skipped.push((i, skip(name, SkipReason::MissingMember { member }))),
|
||||
}
|
||||
}
|
||||
imported.sort_by_key(|(i, _)| *i);
|
||||
skipped.sort_by_key(|(i, _)| *i);
|
||||
RunImport {
|
||||
source,
|
||||
file: source.file().to_owned(),
|
||||
imported: imported.into_iter().map(|(_, c)| c).collect(),
|
||||
skipped: skipped.into_iter().map(|(_, s)| s).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` as written relative to `base` (both absolute, without `..`), with `/` separators;
|
||||
/// `None` when they don't share a prefix (another drive).
|
||||
pub(crate) fn relative_to(path: &Path, base: &Path) -> Option<String> {
|
||||
let path: Vec<Component> = path.components().collect();
|
||||
let base: Vec<Component> = base.components().collect();
|
||||
let common = path.iter().zip(&base).take_while(|(a, b)| a == b).count();
|
||||
if common == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut parts: Vec<String> = base[common..].iter().map(|_| "..".to_owned()).collect();
|
||||
parts.extend(path[common..].iter().map(|c| c.as_os_str().to_string_lossy().into_owned()));
|
||||
Some(if parts.is_empty() { ".".to_owned() } else { parts.join("/") })
|
||||
}
|
||||
|
||||
/// Drops `.` components and folds `..` into the parent, without touching the disk.
|
||||
pub(crate) fn normalize(path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in path.components() {
|
||||
match c {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relative_paths_climb_out_of_the_base() {
|
||||
let root = Path::new("/p");
|
||||
assert_eq!(relative_to(Path::new("/p/manage.py"), root).as_deref(), Some("manage.py"));
|
||||
assert_eq!(relative_to(Path::new("/p"), root).as_deref(), Some("."));
|
||||
assert_eq!(
|
||||
relative_to(Path::new("/p/.venv/bin/python"), Path::new("/p/api")).as_deref(),
|
||||
Some("../.venv/bin/python")
|
||||
);
|
||||
assert_eq!(normalize(Path::new("/p/./api/../web")), PathBuf::from("/p/web"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
//! `.vscode/launch.json`: JSON with comments and trailing commas, `configurations` and
|
||||
//! `compounds`. Python (`debugpy`, the older `python`) and Node.js (`node`, `pwa-node`,
|
||||
//! `node-terminal`) launches become run configurations of the matching kind, their attach
|
||||
//! requests Python Remote Debug / Attach to Node.js configurations. Variables with a fixed
|
||||
//! meaning (`${workspaceFolder}`, `${userHome}`, `${env:NAME}`…) are replaced when importing;
|
||||
//! an entry using one that depends on the moment VS Code launches it (`${file}`,
|
||||
//! `${input:…}`, `${command:…}`) is skipped.
|
||||
|
||||
use super::{Entry, Found, SkipReason, normalize, relative_to};
|
||||
use crate::{RunConfig, RunExtras};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Map, Value};
|
||||
use std::cell::Cell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Keys this IDE covers on its own (console, source maps, Django detection, child
|
||||
/// processes) or that only shape VS Code's UI: left out without a note.
|
||||
const SILENT: &[&str] = &[
|
||||
"name",
|
||||
"type",
|
||||
"request",
|
||||
"presentation",
|
||||
"internalConsoleOptions",
|
||||
"console",
|
||||
"outputCapture",
|
||||
"redirectOutput",
|
||||
"django",
|
||||
"sourceMaps",
|
||||
"outFiles",
|
||||
"resolveSourceMapLocations",
|
||||
"autoAttachChildProcesses",
|
||||
"showAsyncStacks",
|
||||
"smartStep",
|
||||
"windows",
|
||||
"osx",
|
||||
"linux",
|
||||
];
|
||||
|
||||
const COMMON: &[&str] = &["cwd", "env", "envFile"];
|
||||
const PYTHON_LAUNCH: &[&str] = &["program", "module", "args", "python", "pythonArgs"];
|
||||
const PYTHON_ATTACH: &[&str] = &["connect", "host", "port", "pathMappings"];
|
||||
const NODE_LAUNCH: &[&str] = &["program", "args", "runtimeExecutable", "runtimeArgs"];
|
||||
const NODE_ATTACH: &[&str] = &["address", "port", "localRoot", "remoteRoot"];
|
||||
const NODE_TERMINAL: &[&str] = &["command"];
|
||||
const COMPOUND: &[&str] = &["configurations"];
|
||||
|
||||
const PACKAGE_MANAGERS: &[&str] = &["npm", "pnpm", "yarn", "bun"];
|
||||
/// `npm <command>` lines the npm configuration type edits (`src/features/run/types.ts`).
|
||||
const NPM_COMMANDS: &[&str] = &["run", "test", "start", "install", "ci", "publish", "pack"];
|
||||
|
||||
/// Characters that make a `node-terminal` command a shell line rather than a program with arguments.
|
||||
const SHELL_CHARS: &[char] = &['|', '&', ';', '<', '>', '(', ')', '$', '`', '*', '?', '\n'];
|
||||
|
||||
const OS_KEY: &str = if cfg!(windows) {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"osx"
|
||||
} else {
|
||||
"linux"
|
||||
};
|
||||
|
||||
pub(crate) fn entries(text: &str, root: &Path, env: &BTreeMap<String, String>) -> IdeResult<Vec<Entry>> {
|
||||
let json: Value = serde_json::from_str(&ide_core::jsonc::strip(text))
|
||||
.map_err(|e| IdeError::invalid(format!(".vscode/launch.json: {e}")))?;
|
||||
let windows = cfg!(windows);
|
||||
let homes = if windows { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] };
|
||||
let home = homes.into_iter().find_map(|k| env_var(env, k, windows));
|
||||
let cx = Cx { root: &normalize(root), env, home, machine_values: Cell::new(false) };
|
||||
let list = |key: &str| json.get(key).and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let mut out: Vec<Entry> = list("configurations").iter().map(|v| cx.entry(v)).collect();
|
||||
out.extend(list("compounds").iter().map(|v| cx.compound(v)));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct Cx<'a> {
|
||||
root: &'a Path,
|
||||
env: &'a BTreeMap<String, String>,
|
||||
home: Option<String>,
|
||||
/// The entry being converted used `${env:…}` or `${userHome}`: its values belong to this machine.
|
||||
machine_values: Cell<bool>,
|
||||
}
|
||||
|
||||
type Res<T> = Result<T, SkipReason>;
|
||||
|
||||
fn name_of(v: &Value) -> String {
|
||||
v.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_owned()
|
||||
}
|
||||
|
||||
/// The variable `name` of `env`; Windows names ignore case (`${env:PATH}` finds `Path`).
|
||||
fn env_var(env: &BTreeMap<String, String>, name: &str, windows: bool) -> Option<String> {
|
||||
match env.get(name) {
|
||||
Some(v) => Some(v.clone()),
|
||||
None if windows => env.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.clone()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// String form of a scalar (`"args": [8000]`, `"env": { "DEBUG": true }`).
|
||||
fn scalar(v: &Value) -> Option<String> {
|
||||
match v {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The program's file name without a Windows extension (`npm.cmd` → `npm`).
|
||||
fn command_name(cmd: &str) -> &str {
|
||||
let name = cmd.rsplit(['/', '\\']).next().unwrap_or(cmd);
|
||||
name.strip_suffix(".cmd").or_else(|| name.strip_suffix(".exe")).unwrap_or(name)
|
||||
}
|
||||
|
||||
fn config(name: &str, kind: &str, command: String, args: Vec<String>, debug: Option<&str>) -> RunConfig {
|
||||
RunConfig {
|
||||
id: String::new(),
|
||||
name: name.to_owned(),
|
||||
kind: kind.to_owned(),
|
||||
command,
|
||||
args,
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: debug.map(str::to_owned),
|
||||
allow_parallel: false,
|
||||
extras: RunExtras::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `npm run dev`-style words: an npm configuration's `(command, args)`, `run-script` read as `run`.
|
||||
fn npm_line(words: &[String]) -> Option<(String, Vec<String>)> {
|
||||
let (exe, rest) = words.split_first()?;
|
||||
if !PACKAGE_MANAGERS.contains(&command_name(exe)) {
|
||||
return None;
|
||||
}
|
||||
let mut args = rest.to_vec();
|
||||
if args.first().is_some_and(|a| a == "run-script") {
|
||||
args[0] = "run".into();
|
||||
}
|
||||
NPM_COMMANDS.contains(&args.first()?.as_str()).then(|| (exe.clone(), args))
|
||||
}
|
||||
|
||||
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
|
||||
/// (like `parseArgs` in `src/platform/commandLine.ts`).
|
||||
fn split_args(line: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut has = false;
|
||||
let mut quote: Option<char> = None;
|
||||
let mut chars = line.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match quote {
|
||||
Some(q) if c == q => quote = None,
|
||||
Some('"') if c == '\\' && chars.peek().is_some_and(|n| "\"\\$`".contains(*n)) => {
|
||||
cur.extend(chars.next());
|
||||
}
|
||||
Some(_) => cur.push(c),
|
||||
None if c == '\'' || c == '"' => {
|
||||
quote = Some(c);
|
||||
has = true;
|
||||
}
|
||||
None if c == '\\' && chars.peek().is_some() => {
|
||||
cur.extend(chars.next());
|
||||
has = true;
|
||||
}
|
||||
None if c.is_whitespace() => {
|
||||
if has || !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
has = false;
|
||||
}
|
||||
None => cur.push(c),
|
||||
}
|
||||
}
|
||||
if has || !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Cx<'_> {
|
||||
/// Replaces the variables in `s`.
|
||||
fn expand(&self, s: &str) -> Res<String> {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(start) = rest.find("${") {
|
||||
let Some(len) = rest[start..].find('}') else {
|
||||
break;
|
||||
};
|
||||
out.push_str(&rest[..start]);
|
||||
let name = &rest[start + 2..start + len];
|
||||
let value = match name {
|
||||
"workspaceFolder" | "workspaceRoot" => self.root.to_string_lossy().into_owned(),
|
||||
"workspaceFolderBasename" => {
|
||||
self.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
}
|
||||
"userHome" => {
|
||||
self.machine_values.set(true);
|
||||
self.home.clone().ok_or(SkipReason::Variable { variable: format!("${{{name}}}") })?
|
||||
}
|
||||
"pathSeparator" | "/" => std::path::MAIN_SEPARATOR.to_string(),
|
||||
_ => match name.strip_prefix("env:") {
|
||||
Some(var) => {
|
||||
self.machine_values.set(true);
|
||||
env_var(self.env, var, cfg!(windows)).unwrap_or_default()
|
||||
}
|
||||
None => return Err(SkipReason::Variable { variable: format!("${{{name}}}") }),
|
||||
},
|
||||
};
|
||||
out.push_str(&value);
|
||||
rest = &rest[start + len + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn string(&self, obj: &Map<String, Value>, key: &str) -> Res<Option<String>> {
|
||||
match obj.get(key).and_then(scalar) {
|
||||
Some(s) if !s.trim().is_empty() => self.expand(&s).map(Some),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// An array of words, or a string split like a command line.
|
||||
fn words(&self, obj: &Map<String, Value>, key: &str) -> Res<Vec<String>> {
|
||||
match obj.get(key) {
|
||||
Some(Value::Array(items)) => items.iter().filter_map(scalar).map(|s| self.expand(&s)).collect(),
|
||||
Some(Value::String(s)) => Ok(split_args(&self.expand(s)?)),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An absolute path inside the project, written relative to `dir` (the run's working
|
||||
/// directory, where the process resolves it); anything else as it is.
|
||||
fn path_from(&self, value: String, dir: &Path) -> String {
|
||||
let path = Path::new(&value);
|
||||
if !path.is_absolute() {
|
||||
return value;
|
||||
}
|
||||
let path = normalize(path);
|
||||
if path.starts_with(self.root) && dir.starts_with(self.root) {
|
||||
relative_to(&path, dir).unwrap_or(value)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
/// The working directory (absolute) and the configuration's `cwd` (relative to the root).
|
||||
fn cwd(&self, obj: &Map<String, Value>) -> Res<(PathBuf, Option<PathBuf>)> {
|
||||
let Some(cwd) = self.string(obj, "cwd")? else {
|
||||
return Ok((self.root.to_path_buf(), None));
|
||||
};
|
||||
let abs = normalize(&self.root.join(cwd));
|
||||
let rel = match abs.strip_prefix(self.root) {
|
||||
Ok(r) if r.as_os_str().is_empty() => None,
|
||||
Ok(r) => Some(PathBuf::from(relative_to(&abs, self.root).unwrap_or_else(|| r.to_string_lossy().into()))),
|
||||
Err(_) => Some(abs.clone()),
|
||||
};
|
||||
Ok((abs, rel))
|
||||
}
|
||||
|
||||
/// `env` (a variable set to itself is inherited anyway) and `envFile`.
|
||||
fn environment(&self, obj: &Map<String, Value>, c: &mut RunConfig) -> Res<()> {
|
||||
if let Some(Value::Object(env)) = obj.get("env") {
|
||||
for (k, v) in env {
|
||||
let Some(v) = scalar(v) else { continue };
|
||||
let own = format!("${{env:{k}}}");
|
||||
if v == own || (cfg!(windows) && v.eq_ignore_ascii_case(&own)) {
|
||||
continue;
|
||||
}
|
||||
c.env.insert(k.clone(), self.expand(&v)?);
|
||||
}
|
||||
}
|
||||
if let Some(file) = self.string(obj, "envFile")? {
|
||||
c.extras.env_files.push(self.path_from(file, self.root));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn entry(&self, v: &Value) -> Entry {
|
||||
let name = name_of(v);
|
||||
self.machine_values.set(false);
|
||||
let result = match v.as_object() {
|
||||
Some(obj) if !name.is_empty() => self.convert(&name, &overlay(obj)),
|
||||
_ => Err(SkipReason::Invalid),
|
||||
};
|
||||
let result = result.map(|mut found| {
|
||||
// Values of this machine (its environment, its home) stay out of the shared run.toml.
|
||||
found.config.extras.local = self.machine_values.get();
|
||||
found
|
||||
});
|
||||
Entry { name, result }
|
||||
}
|
||||
|
||||
fn convert(&self, name: &str, obj: &Map<String, Value>) -> Res<Found> {
|
||||
let ty = obj.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
let request = obj.get("request").and_then(Value::as_str).unwrap_or("launch");
|
||||
let (mut config, handled) = match (ty, request) {
|
||||
("debugpy" | "python", "launch") => (self.python_launch(name, obj)?, PYTHON_LAUNCH),
|
||||
("debugpy" | "python", "attach") => (self.python_attach(name, obj)?, PYTHON_ATTACH),
|
||||
("node" | "pwa-node", "launch") => (self.node_launch(name, obj)?, NODE_LAUNCH),
|
||||
("node" | "pwa-node", "attach") => (self.node_attach(name, obj)?, NODE_ATTACH),
|
||||
("node-terminal", "launch") => (self.node_terminal(name, obj)?, NODE_TERMINAL),
|
||||
("debugpy" | "python" | "node" | "pwa-node" | "node-terminal", other) => {
|
||||
return Err(SkipReason::UnsupportedRequest { request: other.to_owned() });
|
||||
}
|
||||
(other, _) => return Err(SkipReason::UnsupportedType { type_: other.to_owned() }),
|
||||
};
|
||||
if request == "launch" {
|
||||
config.cwd = self.cwd(obj)?.1.map(Into::into);
|
||||
self.environment(obj, &mut config)?;
|
||||
}
|
||||
let mut ignored: Vec<String> = obj
|
||||
.keys()
|
||||
.filter(|k| {
|
||||
let k = k.as_str();
|
||||
!SILENT.contains(&k) && !handled.contains(&k) && (request != "launch" || !COMMON.contains(&k))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if obj.get("pathMappings").and_then(Value::as_array).is_some_and(|m| m.len() > 1) {
|
||||
ignored.push("pathMappings".into());
|
||||
}
|
||||
ignored.sort();
|
||||
Ok(Found { config, members: Vec::new(), ignored })
|
||||
}
|
||||
|
||||
fn python_launch(&self, name: &str, obj: &Map<String, Value>) -> Res<RunConfig> {
|
||||
let (dir, _) = self.cwd(obj)?;
|
||||
// `${command:python.interpreterPath}` / `${config:python.defaultInterpreterPath}` name the
|
||||
// interpreter VS Code has selected: the project's here. Options after it (`[exe, "-X", "dev"]`) stay.
|
||||
let selected = |s: &str| s.starts_with("${command:python.") || s.starts_with("${config:python.");
|
||||
let python: Vec<String> = match obj.get("python") {
|
||||
Some(Value::Array(items)) => items.iter().filter_map(scalar).collect(),
|
||||
Some(v) => scalar(v).into_iter().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let (command, mut args) = match python.split_first() {
|
||||
Some((exe, opts)) => {
|
||||
let opts = opts.iter().map(|o| self.expand(o)).collect::<Res<Vec<_>>>()?;
|
||||
if exe.trim().is_empty() || selected(exe) {
|
||||
let (exe, mut lead) = self.project_python(&dir);
|
||||
lead.extend(opts);
|
||||
(exe, lead)
|
||||
} else {
|
||||
(self.path_from(self.expand(exe)?, &dir), opts)
|
||||
}
|
||||
}
|
||||
None => self.project_python(&dir),
|
||||
};
|
||||
args.extend(self.words(obj, "pythonArgs")?);
|
||||
let rest = self.words(obj, "args")?;
|
||||
let mut kind = "python";
|
||||
if let Some(module) = self.string(obj, "module")? {
|
||||
if module == "pytest" {
|
||||
kind = "pytest";
|
||||
}
|
||||
args.extend(["-m".to_owned(), module]);
|
||||
} else if let Some(program) = self.string(obj, "program")? {
|
||||
let mut program = self.path_from(program, &dir);
|
||||
// Only `manage.py` in the working directory (`./manage.py` too): the Django tests form writes `manage.py`.
|
||||
if normalize(Path::new(&program)) == Path::new("manage.py") && rest.first().is_some_and(|a| a == "test") {
|
||||
kind = "django-test";
|
||||
program = "manage.py".into();
|
||||
}
|
||||
args.push(program);
|
||||
} else {
|
||||
return Err(SkipReason::NothingToRun);
|
||||
}
|
||||
args.extend(rest.into_iter().map(|a| self.path_from(a, &dir)));
|
||||
let mut c = config(name, kind, command, args, Some("debugpy"));
|
||||
c.is_test = kind != "python";
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// The project's interpreter (see [`crate::detect::project_python`]), relative to `dir`.
|
||||
fn project_python(&self, dir: &Path) -> (String, Vec<String>) {
|
||||
let (exe, lead) = crate::detect::project_python(self.root);
|
||||
let exe =
|
||||
if exe.contains('/') { self.path_from(self.root.join(&exe).to_string_lossy().into(), dir) } else { exe };
|
||||
(exe, lead)
|
||||
}
|
||||
|
||||
fn python_attach(&self, name: &str, obj: &Map<String, Value>) -> Res<RunConfig> {
|
||||
if obj.contains_key("processId") || obj.contains_key("listen") {
|
||||
return Err(SkipReason::UnsupportedAttach);
|
||||
}
|
||||
let connect = obj.get("connect").and_then(Value::as_object).unwrap_or(obj);
|
||||
let host = self.string(connect, "host")?.unwrap_or_else(|| "localhost".into());
|
||||
let Some(port) = self.string(connect, "port")? else {
|
||||
return Err(SkipReason::UnsupportedAttach);
|
||||
};
|
||||
let mut c = config(name, "python-remote", String::new(), Vec::new(), Some("debugpy"));
|
||||
c.extras.params = BTreeMap::from([("host".into(), host), ("port".into(), port)]);
|
||||
if let Some(Value::Object(m)) = obj.get("pathMappings").and_then(|m| m.get(0)) {
|
||||
self.roots(m, &mut c)?;
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
fn node_launch(&self, name: &str, obj: &Map<String, Value>) -> Res<RunConfig> {
|
||||
let (dir, _) = self.cwd(obj)?;
|
||||
let exe = self.string(obj, "runtimeExecutable")?.map(|e| self.path_from(e, &dir));
|
||||
let mut args = self.words(obj, "runtimeArgs")?;
|
||||
let program = self.string(obj, "program")?.map(|p| self.path_from(p, &dir));
|
||||
if exe.is_none() && program.is_none() {
|
||||
return Err(SkipReason::NothingToRun);
|
||||
}
|
||||
args.extend(program);
|
||||
args.extend(self.words(obj, "args")?.into_iter().map(|a| self.path_from(a, &dir)));
|
||||
let exe = exe.unwrap_or_else(|| "node".into());
|
||||
let words: Vec<String> = std::iter::once(exe.clone()).chain(args.iter().cloned()).collect();
|
||||
Ok(match npm_line(&words) {
|
||||
Some((command, args)) => npm_config(name, command, args),
|
||||
None => config(name, "node", exe, args, Some("js-debug")),
|
||||
})
|
||||
}
|
||||
|
||||
fn node_attach(&self, name: &str, obj: &Map<String, Value>) -> Res<RunConfig> {
|
||||
if obj.contains_key("processId") {
|
||||
return Err(SkipReason::UnsupportedAttach);
|
||||
}
|
||||
let host = self.string(obj, "address")?.unwrap_or_else(|| "localhost".into());
|
||||
let port = self.string(obj, "port")?.unwrap_or_else(|| "9229".into());
|
||||
let mut c = config(name, "node-remote", String::new(), Vec::new(), Some("js-debug"));
|
||||
c.extras.params = BTreeMap::from([("host".into(), host), ("port".into(), port)]);
|
||||
self.roots(obj, &mut c)?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// `localRoot` (relative to the project root, empty for the root itself) and `remoteRoot`.
|
||||
fn roots(&self, obj: &Map<String, Value>, c: &mut RunConfig) -> Res<()> {
|
||||
if let Some(local) = self.string(obj, "localRoot")? {
|
||||
let local = self.path_from(local, self.root);
|
||||
if local != "." {
|
||||
c.extras.params.insert("localRoot".into(), local);
|
||||
}
|
||||
}
|
||||
if let Some(remote) = self.string(obj, "remoteRoot")? {
|
||||
c.extras.params.insert("remoteRoot".into(), remote);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A command typed into a terminal VS Code debugs: an npm script, a program with
|
||||
/// arguments, or a shell line (run by the shell, without the debugger).
|
||||
fn node_terminal(&self, name: &str, obj: &Map<String, Value>) -> Res<RunConfig> {
|
||||
let Some(line) = self.string(obj, "command")? else {
|
||||
return Err(SkipReason::NothingToRun);
|
||||
};
|
||||
let words = split_args(&line);
|
||||
let shell = line.contains(SHELL_CHARS) || words.first().is_some_and(|w| w.contains('='));
|
||||
if shell {
|
||||
return Ok(config(name, "shell", line.trim().to_owned(), Vec::new(), None));
|
||||
}
|
||||
if let Some((command, args)) = npm_line(&words) {
|
||||
return Ok(npm_config(name, command, args));
|
||||
}
|
||||
let Some((exe, args)) = words.split_first() else {
|
||||
return Err(SkipReason::NothingToRun);
|
||||
};
|
||||
Ok(config(name, "shell", exe.clone(), args.to_vec(), Some("js-debug")))
|
||||
}
|
||||
|
||||
fn compound(&self, v: &Value) -> Entry {
|
||||
let name = name_of(v);
|
||||
let result = match (v.as_object(), v.get("configurations").and_then(Value::as_array)) {
|
||||
(Some(obj), Some(list)) if !name.is_empty() => {
|
||||
// Members are names, or `{ "name", "folder" }` in multi-root workspaces.
|
||||
let members: Vec<String> = list
|
||||
.iter()
|
||||
.filter_map(|m| m.as_str().or_else(|| m.get("name").and_then(Value::as_str)))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mut ignored: Vec<String> = obj
|
||||
.keys()
|
||||
.filter(|k| !SILENT.contains(&k.as_str()) && !COMPOUND.contains(&k.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
ignored.sort();
|
||||
Ok(Found { config: config(&name, "compound", String::new(), Vec::new(), None), members, ignored })
|
||||
}
|
||||
_ => Err(SkipReason::Invalid),
|
||||
};
|
||||
Entry { name, result }
|
||||
}
|
||||
}
|
||||
|
||||
fn npm_config(name: &str, command: String, args: Vec<String>) -> RunConfig {
|
||||
config(name, "npm", command, args, Some("js-debug"))
|
||||
}
|
||||
|
||||
/// The entry with its block for this OS (`"linux": { … }`) merged over it, as VS Code does.
|
||||
fn overlay(obj: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut out = obj.clone();
|
||||
if let Some(Value::Object(os)) = obj.get(OS_KEY) {
|
||||
for (k, v) in os {
|
||||
out.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn splits_like_a_shell() {
|
||||
assert_eq!(
|
||||
split_args(r#"next dev --port "3 000" 'a b' c\ d"#),
|
||||
["next", "dev", "--port", "3 000", "a b", "c d"]
|
||||
);
|
||||
assert_eq!(split_args(r#"x "" y"#), ["x", "", "y"]);
|
||||
}
|
||||
|
||||
fn cx_expand(env: &[(&str, &str)], text: &str) -> String {
|
||||
let env: BTreeMap<String, String> = env.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
|
||||
let cx = Cx { root: Path::new("/p"), env: &env, home: None, machine_values: Cell::new(false) };
|
||||
let out = cx.expand(text).unwrap();
|
||||
assert!(cx.machine_values.get() == text.contains("${env:"));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclosed_variable_is_kept_as_written() {
|
||||
assert_eq!(cx_expand(&[], "abc${foo"), "abc${foo");
|
||||
assert_eq!(cx_expand(&[], "${workspaceFolder}/x ${y"), "/p/x ${y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_variable_names_ignore_case() {
|
||||
let env = BTreeMap::from([("Path".to_owned(), r"C:\bin".to_owned())]);
|
||||
assert_eq!(env_var(&env, "PATH", true).as_deref(), Some(r"C:\bin"));
|
||||
assert_eq!(env_var(&env, "PATH", false), None);
|
||||
assert_eq!(env_var(&env, "Path", false).as_deref(), Some(r"C:\bin"));
|
||||
assert_eq!(cx_expand(&[("A", "1")], "${env:A}-${env:B}"), "1-");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod dotnet;
|
||||
pub mod envfile;
|
||||
pub mod go;
|
||||
pub mod gradle_jvm;
|
||||
pub mod import;
|
||||
pub mod java_cmdline;
|
||||
pub mod junit;
|
||||
mod process;
|
||||
@@ -256,6 +257,17 @@ pub fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<Strin
|
||||
process::resolved_env(root, config)
|
||||
}
|
||||
|
||||
/// Run configurations from another IDE's project file (`.vscode/launch.json`), not yet saved:
|
||||
/// names already in `existing` are skipped, so importing again adds nothing twice.
|
||||
pub fn import_configs(
|
||||
root: &Path,
|
||||
source: import::RunImportSource,
|
||||
existing: &[RunConfig],
|
||||
) -> IdeResult<import::RunImport> {
|
||||
// `${env:NAME}` reads what a run inherits, the login shell's variables included.
|
||||
import::import(root, source, existing, &process::inherited_env())
|
||||
}
|
||||
|
||||
/// Path of the user run configuration file for `root`.
|
||||
pub fn user_configs_path(root: &Path) -> PathBuf {
|
||||
config::config_path(root)
|
||||
|
||||
@@ -784,14 +784,19 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
|
||||
Ok(LaunchEnv { clear: !extras.include_system_env, vars })
|
||||
}
|
||||
|
||||
/// What a run inherits with "Include system environment variables" on: this process's
|
||||
/// variables and the login shell's ones it lacks.
|
||||
pub(crate) fn inherited_env() -> BTreeMap<String, String> {
|
||||
let mut vars = crate::system_env();
|
||||
vars.extend(
|
||||
ide_core::process::shell_env().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))),
|
||||
);
|
||||
vars
|
||||
}
|
||||
|
||||
pub(crate) fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
|
||||
let env = launch_env(root, config)?;
|
||||
let mut vars = if env.clear { BTreeMap::new() } else { crate::system_env() };
|
||||
if !env.clear {
|
||||
vars.extend(
|
||||
ide_core::process::shell_env().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))),
|
||||
);
|
||||
}
|
||||
let mut vars = if env.clear { BTreeMap::new() } else { inherited_env() };
|
||||
vars.extend(env.vars);
|
||||
Ok(vars)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
//! `.vscode/launch.json` import, from launch files written the way the VS Code docs and
|
||||
//! framework guides (Django, Flask, FastAPI, Next.js) suggest.
|
||||
|
||||
use ide_run::RunConfig;
|
||||
use ide_run::import::{RunImport, RunImportSource, SkipReason, import};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const VENV_PYTHON: &str = if cfg!(windows) { ".venv/Scripts/python.exe" } else { ".venv/bin/python" };
|
||||
|
||||
fn env() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([("HOME".into(), "/home/me".into()), ("API_TOKEN".into(), "s3cret".into())])
|
||||
}
|
||||
|
||||
fn project(launch: &str) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".vscode")).unwrap();
|
||||
fs::write(dir.path().join(".vscode/launch.json"), launch).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn import_from(root: &Path, existing: &[RunConfig]) -> RunImport {
|
||||
import(root, RunImportSource::Vscode, existing, &env()).unwrap()
|
||||
}
|
||||
|
||||
fn run(launch: &str) -> (tempfile::TempDir, RunImport) {
|
||||
let dir = project(launch);
|
||||
let result = import_from(dir.path(), &[]);
|
||||
(dir, result)
|
||||
}
|
||||
|
||||
fn get<'a>(r: &'a RunImport, name: &str) -> &'a RunConfig {
|
||||
&r.imported.iter().find(|c| c.config.name == name).unwrap_or_else(|| panic!("{name} not imported: {r:#?}")).config
|
||||
}
|
||||
|
||||
fn ignored<'a>(r: &'a RunImport, name: &str) -> &'a [String] {
|
||||
&r.imported.iter().find(|c| c.config.name == name).unwrap().ignored
|
||||
}
|
||||
|
||||
fn reason<'a>(r: &'a RunImport, name: &str) -> &'a SkipReason {
|
||||
&r.skipped.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("{name} not skipped: {r:#?}")).reason
|
||||
}
|
||||
|
||||
fn s(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|a| (*a).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn cwd(c: &RunConfig) -> Option<PathBuf> {
|
||||
c.cwd.as_ref().map(|p| p.as_ref().to_path_buf())
|
||||
}
|
||||
|
||||
const DJANGO: &str = r#"{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Django",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/manage.py",
|
||||
"args": ["runserver", "0.0.0.0:8000", "--noreload"],
|
||||
"django": true,
|
||||
"justMyCode": false,
|
||||
"env": { "DJANGO_SETTINGS_MODULE": "mysite.settings.dev", "DEBUG": true, "PATH": "${env:PATH}" },
|
||||
},
|
||||
/* the test runner */
|
||||
{
|
||||
"name": "Django tests",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${workspaceFolder}/.venv/bin/python",
|
||||
"program": "${workspaceFolder}/manage.py",
|
||||
"args": ["test", "polls", "--keepdb"],
|
||||
"django": true,
|
||||
},
|
||||
],
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn django_runserver_and_tests_with_comments_and_trailing_commas() {
|
||||
let dir = project(DJANGO);
|
||||
let venv = dir.path().join(VENV_PYTHON);
|
||||
fs::create_dir_all(venv.parent().unwrap()).unwrap();
|
||||
fs::write(&venv, "").unwrap();
|
||||
let r = import_from(dir.path(), &[]);
|
||||
assert!(r.skipped.is_empty(), "{r:#?}");
|
||||
assert_eq!(r.file, ".vscode/launch.json");
|
||||
|
||||
let server = get(&r, "Django");
|
||||
assert_eq!(server.id, "user:django");
|
||||
assert_eq!(server.kind, "python");
|
||||
// No `python`: the project's virtualenv, which the debugger launches directly.
|
||||
assert_eq!(server.command, VENV_PYTHON);
|
||||
assert_eq!(server.args, s(&["manage.py", "runserver", "0.0.0.0:8000", "--noreload"]));
|
||||
assert_eq!(server.debug_adapter.as_deref(), Some("debugpy"));
|
||||
assert_eq!(cwd(server), None);
|
||||
// A variable set to its own value is inherited anyway; scalars become strings.
|
||||
assert_eq!(
|
||||
server.env,
|
||||
BTreeMap::from([
|
||||
("DEBUG".into(), "true".into()),
|
||||
("DJANGO_SETTINGS_MODULE".into(), "mysite.settings.dev".into())
|
||||
])
|
||||
);
|
||||
assert!(!server.extras.local && !server.detected && !server.is_test);
|
||||
assert_eq!(ignored(&r, "Django"), ["justMyCode"]);
|
||||
|
||||
let tests = get(&r, "Django tests");
|
||||
assert_eq!(tests.kind, "django-test");
|
||||
assert!(tests.is_test);
|
||||
assert_eq!(tests.command, ".venv/bin/python");
|
||||
assert_eq!(tests.args, s(&["manage.py", "test", "polls", "--keepdb"]));
|
||||
assert!(ignored(&r, "Django tests").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flask_and_fastapi_run_as_modules() {
|
||||
let (_dir, r) = run(r#"{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Flask",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "flask",
|
||||
"python": "/usr/bin/python3.12",
|
||||
"env": { "FLASK_APP": "app.py", "FLASK_DEBUG": "1" },
|
||||
"args": ["run", "--no-debugger", "--no-reload"],
|
||||
"jinja": true,
|
||||
"autoStartBrowser": false
|
||||
},
|
||||
{
|
||||
"name": "FastAPI",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "uvicorn",
|
||||
"python": "${workspaceFolder}/.venv/bin/python",
|
||||
"args": "app.main:app --reload --port 8001",
|
||||
"envFile": "${workspaceFolder}/.env.local"
|
||||
}
|
||||
]
|
||||
}"#);
|
||||
let flask = get(&r, "Python Debugger: Flask");
|
||||
assert_eq!(flask.kind, "python");
|
||||
assert_eq!(flask.command, "/usr/bin/python3.12");
|
||||
assert_eq!(flask.args, s(&["-m", "flask", "run", "--no-debugger", "--no-reload"]));
|
||||
assert_eq!(flask.env["FLASK_APP"], "app.py");
|
||||
assert_eq!(ignored(&r, "Python Debugger: Flask"), ["autoStartBrowser", "jinja"]);
|
||||
|
||||
let api = get(&r, "FastAPI");
|
||||
// A string `args` is split like a command line.
|
||||
assert_eq!(api.args, s(&["-m", "uvicorn", "app.main:app", "--reload", "--port", "8001"]));
|
||||
assert_eq!(api.extras.env_files, s(&[".env.local"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pytest_module_in_a_subdirectory_is_a_test_configuration() {
|
||||
let (dir, r) = run(r#"{
|
||||
"configurations": [{
|
||||
"name": "Backend tests",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "pytest",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"pythonArgs": ["-X", "dev"],
|
||||
"args": ["-x", "${workspaceFolder}/backend/tests", "--ds=${workspaceFolderBasename}.settings"]
|
||||
}]
|
||||
}"#);
|
||||
let c = get(&r, "Backend tests");
|
||||
assert_eq!(c.kind, "pytest");
|
||||
assert!(c.is_test);
|
||||
assert_eq!(cwd(c), Some(PathBuf::from("backend")));
|
||||
// The interpreter VS Code has selected is the project's here: no virtualenv, so python3.
|
||||
assert_eq!(c.command, if cfg!(windows) { "python" } else { "python3" });
|
||||
let base = dir.path().file_name().unwrap().to_string_lossy().into_owned();
|
||||
// Paths inside the project are written relative to the working directory.
|
||||
assert_eq!(c.args, s(&["-X", "dev", "-m", "pytest", "-x", "tests", &format!("--ds={base}.settings")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpreter_from_the_root_virtualenv_is_relative_to_the_working_directory() {
|
||||
let dir = project(
|
||||
r#"{ "configurations": [{ "name": "Worker", "type": "debugpy", "request": "launch",
|
||||
"program": "${workspaceFolder}/api/worker.py", "cwd": "${workspaceFolder}/api" }] }"#,
|
||||
);
|
||||
let venv = dir.path().join(VENV_PYTHON);
|
||||
fs::create_dir_all(venv.parent().unwrap()).unwrap();
|
||||
fs::write(&venv, "").unwrap();
|
||||
let r = import_from(dir.path(), &[]);
|
||||
let c = get(&r, "Worker");
|
||||
assert_eq!(c.command, format!("../{VENV_PYTHON}"));
|
||||
assert_eq!(c.args, s(&["worker.py"]));
|
||||
assert_eq!(cwd(c), Some(PathBuf::from("api")));
|
||||
}
|
||||
|
||||
const NEXT: &str = r#"{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
|
||||
"runtimeArgs": ["--inspect"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"serverReadyAction": { "action": "debugWithEdge", "killOnServerStop": true, "pattern": "- Local:.+(https?://.+)" }
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn nextjs_recipes() {
|
||||
let (_dir, r) = run(NEXT);
|
||||
let server = get(&r, "Next.js: debug server-side");
|
||||
assert_eq!((server.kind.as_str(), server.command.as_str()), ("npm", "npm"));
|
||||
assert_eq!(server.args, s(&["run", "dev"]));
|
||||
assert_eq!(server.debug_adapter.as_deref(), Some("js-debug"));
|
||||
|
||||
assert_eq!(reason(&r, "Next.js: debug client-side"), &SkipReason::UnsupportedType { type_: "chrome".into() });
|
||||
|
||||
let full = get(&r, "Next.js: debug full stack");
|
||||
assert_eq!((full.kind.as_str(), full.command.as_str()), ("node", "node"));
|
||||
assert_eq!(full.args, s(&["--inspect", "node_modules/next/dist/bin/next"]));
|
||||
assert_eq!(ignored(&r, "Next.js: debug full stack"), ["serverReadyAction", "skipFiles"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_runtimes_npm_scripts_and_terminal_commands() {
|
||||
let (_dir, r) = run(r#"{
|
||||
"configurations": [
|
||||
{ "name": "Start", "type": "node", "request": "launch", "runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run-script", "start"], "cwd": "${workspaceFolder}/web", "env": { "PORT": 3001 } },
|
||||
{ "name": "Vite", "type": "pwa-node", "request": "launch", "cwd": "${workspaceFolder}/web",
|
||||
"runtimeExecutable": "${workspaceFolder}/web/node_modules/.bin/vite", "runtimeArgs": ["--port", "5173"] },
|
||||
{ "name": "Script", "type": "node", "request": "launch", "program": "${workspaceFolder}/scripts/seed.js",
|
||||
"args": ["--count", 10], "envFile": "${workspaceFolder}/.env", "preLaunchTask": "tsc: build" },
|
||||
{ "name": "Storybook", "type": "node-terminal", "request": "launch", "command": "pnpm exec storybook dev -p 6006" },
|
||||
{ "name": "Migrate and serve", "type": "node-terminal", "request": "launch", "command": "npm run migrate && npm start" },
|
||||
{ "name": "Nothing", "type": "node", "request": "launch" }
|
||||
]
|
||||
}"#);
|
||||
let start = get(&r, "Start");
|
||||
assert_eq!((start.kind.as_str(), start.command.as_str()), ("npm", "npm"));
|
||||
assert_eq!(start.args, s(&["run", "start"]));
|
||||
assert_eq!(cwd(start), Some(PathBuf::from("web")));
|
||||
assert_eq!(start.env["PORT"], "3001");
|
||||
|
||||
let vite = get(&r, "Vite");
|
||||
assert_eq!((vite.kind.as_str(), vite.command.as_str()), ("node", "node_modules/.bin/vite"));
|
||||
assert_eq!(vite.args, s(&["--port", "5173"]));
|
||||
|
||||
let script = get(&r, "Script");
|
||||
assert_eq!(script.args, s(&["scripts/seed.js", "--count", "10"]));
|
||||
assert_eq!(script.extras.env_files, s(&[".env"]));
|
||||
assert_eq!(ignored(&r, "Script"), ["preLaunchTask"]);
|
||||
|
||||
let storybook = get(&r, "Storybook");
|
||||
assert_eq!((storybook.kind.as_str(), storybook.command.as_str()), ("shell", "pnpm"));
|
||||
assert_eq!(storybook.args, s(&["exec", "storybook", "dev", "-p", "6006"]));
|
||||
assert_eq!(storybook.debug_adapter.as_deref(), Some("js-debug"));
|
||||
|
||||
// A shell line runs through the shell, without the debugger.
|
||||
let line = get(&r, "Migrate and serve");
|
||||
assert_eq!((line.kind.as_str(), line.command.as_str()), ("shell", "npm run migrate && npm start"));
|
||||
assert!(line.args.is_empty() && line.debug_adapter.is_none());
|
||||
|
||||
assert_eq!(reason(&r, "Nothing"), &SkipReason::NothingToRun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compounds_resolve_members_by_name() {
|
||||
let launch = r#"{
|
||||
"configurations": [
|
||||
{ "name": "Django", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/manage.py", "args": ["runserver"] },
|
||||
{ "name": "Next.js: debug server-side", "type": "node-terminal", "request": "launch", "command": "npm run dev" },
|
||||
],
|
||||
"compounds": [
|
||||
{ "name": "Full stack", "configurations": ["Django", "Next.js: debug server-side"], "stopAll": true },
|
||||
{ "name": "With the worker", "configurations": ["Django", { "name": "Celery", "folder": "api" }] },
|
||||
],
|
||||
}"#;
|
||||
let (dir, r) = run(launch);
|
||||
let full = get(&r, "Full stack");
|
||||
assert_eq!(full.kind, "compound");
|
||||
assert_eq!(full.extras.compound, s(&["user:django", "user:next-js-debug-server-side"]));
|
||||
assert_eq!(ignored(&r, "Full stack"), ["stopAll"]);
|
||||
assert_eq!(reason(&r, "With the worker"), &SkipReason::MissingMember { member: "Celery".into() });
|
||||
|
||||
// Importing again adds nothing; a compound still finds members that already exist.
|
||||
let mut existing: Vec<RunConfig> = r.imported.iter().map(|c| c.config.clone()).collect();
|
||||
existing.retain(|c| c.name != "Full stack");
|
||||
let again = import_from(dir.path(), &existing);
|
||||
assert_eq!(again.imported.iter().map(|c| c.config.name.as_str()).collect::<Vec<_>>(), ["Full stack"]);
|
||||
assert_eq!(again.imported[0].config.extras.compound, s(&["user:django", "user:next-js-debug-server-side"]));
|
||||
assert_eq!(reason(&again, "Django"), &SkipReason::NameTaken);
|
||||
assert_eq!(reason(&again, "Next.js: debug server-side"), &SkipReason::NameTaken);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_names_are_skipped_and_ids_stay_unique() {
|
||||
let dir = project(
|
||||
r#"{ "configurations": [
|
||||
{ "name": "manage.py runserver", "type": "debugpy", "request": "launch", "program": "manage.py", "args": ["runserver"] },
|
||||
{ "name": "Server", "type": "debugpy", "request": "launch", "program": "server.py" },
|
||||
{ "name": "Server", "type": "debugpy", "request": "launch", "program": "other.py" }
|
||||
] }"#,
|
||||
);
|
||||
let detected = RunConfig {
|
||||
id: "django:runserver".into(),
|
||||
name: "manage.py runserver".into(),
|
||||
kind: "python".into(),
|
||||
command: "python3".into(),
|
||||
args: s(&["manage.py", "runserver"]),
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: true,
|
||||
is_test: false,
|
||||
debug_adapter: Some("debugpy".into()),
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let user = RunConfig { id: "user:server".into(), name: "Old server".into(), detected: false, ..detected.clone() };
|
||||
let r = import_from(dir.path(), &[detected, user]);
|
||||
assert_eq!(reason(&r, "manage.py runserver"), &SkipReason::NameTaken);
|
||||
// Two entries with the same name: the first wins.
|
||||
assert_eq!(r.imported.len(), 1);
|
||||
assert_eq!(r.imported[0].config.id, "user:server-2");
|
||||
assert_eq!(r.imported[0].config.args, s(&["server.py"]));
|
||||
assert_eq!(r.skipped.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_known_only_at_launch_skip_the_entry() {
|
||||
let (_dir, r) = run(r#"{
|
||||
"configurations": [
|
||||
{ "name": "Current file", "type": "debugpy", "request": "launch", "program": "${file}", "console": "integratedTerminal" },
|
||||
{ "name": "Port", "type": "debugpy", "request": "launch", "module": "app", "args": ["--port", "${input:port}"] },
|
||||
{ "name": "Home", "type": "debugpy", "request": "launch", "program": "${userHome}/bin/tool.py" },
|
||||
{ "name": "Token", "type": "debugpy", "request": "launch", "program": "app.py",
|
||||
"env": { "TOKEN": "${env:API_TOKEN}", "MISSING": "${env:NOT_SET}x" } }
|
||||
],
|
||||
"inputs": [{ "id": "port", "type": "promptString", "description": "Port" }]
|
||||
}"#);
|
||||
assert_eq!(reason(&r, "Current file"), &SkipReason::Variable { variable: "${file}".into() });
|
||||
assert_eq!(reason(&r, "Port"), &SkipReason::Variable { variable: "${input:port}".into() });
|
||||
assert_eq!(get(&r, "Home").args, s(&["/home/me/bin/tool.py"]));
|
||||
// The home directory is this machine's too: the configuration stays out of run.toml.
|
||||
assert!(get(&r, "Home").extras.local);
|
||||
let token = get(&r, "Token");
|
||||
assert_eq!(token.env["TOKEN"], "s3cret");
|
||||
assert_eq!(token.env["MISSING"], "x");
|
||||
// Values from this machine's environment go to the workspace-local file, not run.toml.
|
||||
assert!(token.extras.local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_requests_become_remote_debug_configurations() {
|
||||
let (_dir, r) = run(r#"{
|
||||
"configurations": [
|
||||
{ "name": "Attach (container)", "type": "debugpy", "request": "attach",
|
||||
"connect": { "host": "localhost", "port": 5678 }, "justMyCode": true,
|
||||
"pathMappings": [{ "localRoot": "${workspaceFolder}", "remoteRoot": "/app" }] },
|
||||
{ "name": "Attach to Node", "type": "node", "request": "attach", "port": 9230,
|
||||
"localRoot": "${workspaceFolder}/web", "remoteRoot": "/usr/src/app", "restart": true },
|
||||
{ "name": "Attach to PID", "type": "debugpy", "request": "attach", "processId": "${command:pickProcess}" },
|
||||
{ "name": "Odd", "type": "node", "request": "test" }
|
||||
]
|
||||
}"#);
|
||||
let py = get(&r, "Attach (container)");
|
||||
assert_eq!((py.kind.as_str(), py.command.as_str()), ("python-remote", ""));
|
||||
assert_eq!(py.debug_adapter.as_deref(), Some("debugpy"));
|
||||
assert_eq!(
|
||||
py.extras.params,
|
||||
BTreeMap::from([
|
||||
("host".into(), "localhost".into()),
|
||||
("port".into(), "5678".into()),
|
||||
("remoteRoot".into(), "/app".into())
|
||||
])
|
||||
);
|
||||
assert_eq!(ignored(&r, "Attach (container)"), ["justMyCode"]);
|
||||
|
||||
let node = get(&r, "Attach to Node");
|
||||
assert_eq!(node.kind, "node-remote");
|
||||
assert_eq!(node.extras.params["port"], "9230");
|
||||
assert_eq!(node.extras.params["localRoot"], "web");
|
||||
assert_eq!(node.extras.params["remoteRoot"], "/usr/src/app");
|
||||
assert_eq!(ignored(&r, "Attach to Node"), ["restart"]);
|
||||
|
||||
assert_eq!(reason(&r, "Attach to PID"), &SkipReason::UnsupportedAttach);
|
||||
assert_eq!(reason(&r, "Odd"), &SkipReason::UnsupportedRequest { request: "test".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_block_for_this_os_overrides_the_entry() {
|
||||
let os = if cfg!(windows) {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"osx"
|
||||
} else {
|
||||
"linux"
|
||||
};
|
||||
let (_dir, r) = run(&format!(
|
||||
r#"{{ "configurations": [{{ "name": "App", "type": "debugpy", "request": "launch", "program": "app.py",
|
||||
"python": "python3", "{os}": {{ "python": "/opt/py/bin/python" }} }}] }}"#
|
||||
));
|
||||
assert_eq!(get(&r, "App").command, "/opt/py/bin/python");
|
||||
assert!(ignored(&r, "App").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_entries_are_invalid_and_bad_json_is_an_error() {
|
||||
let (_dir, r) = run(r#"{ "configurations": [{ "type": "debugpy", "request": "launch", "program": "a.py" }, 3] }"#);
|
||||
assert!(r.imported.is_empty());
|
||||
assert!(r.skipped.iter().all(|s| s.reason == SkipReason::Invalid && s.name.is_empty()));
|
||||
|
||||
let dir = project("{ \"configurations\": [ ");
|
||||
assert!(import(dir.path(), RunImportSource::Vscode, &[], &env()).is_err());
|
||||
let empty = tempfile::tempdir().unwrap();
|
||||
assert!(import(empty.path(), RunImportSource::Vscode, &[], &env()).is_err());
|
||||
assert!(ide_run::import::available(empty.path()).is_empty());
|
||||
assert_eq!(ide_run::import::available(dir.path()), [RunImportSource::Vscode]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_configurations_round_trip_through_run_toml() {
|
||||
let (dir, r) = run(DJANGO);
|
||||
let configs: Vec<RunConfig> = r.imported.iter().map(|c| c.config.clone()).collect();
|
||||
ide_run::save_user_configs(dir.path(), &configs).unwrap();
|
||||
assert_eq!(ide_run::load_user_configs(dir.path()).unwrap(), configs);
|
||||
}
|
||||
|
||||
/// Run configurations pass `env` values to the process as written (only `.env` files expand
|
||||
/// `${VAR}`), so `${env:…}` is resolved when importing; the value stays out of the shared run.toml.
|
||||
#[test]
|
||||
fn resolved_environment_values_are_saved_in_the_local_file_only() {
|
||||
let (dir, r) = run(r#"{ "configurations": [
|
||||
{ "name": "Token", "type": "debugpy", "request": "launch", "program": "app.py", "env": { "TOKEN": "${env:API_TOKEN}" } },
|
||||
{ "name": "Plain", "type": "debugpy", "request": "launch", "program": "app.py" }
|
||||
] }"#);
|
||||
let configs: Vec<RunConfig> = r.imported.iter().map(|c| c.config.clone()).collect();
|
||||
ide_run::save_user_configs(dir.path(), &configs).unwrap();
|
||||
let shared = fs::read_to_string(ide_run::user_configs_path(dir.path())).unwrap();
|
||||
assert!(shared.contains("Plain") && !shared.contains("Token") && !shared.contains("s3cret"));
|
||||
let local = fs::read_to_string(dir.path().join(".openidea/run.local.toml")).unwrap();
|
||||
assert!(local.contains("TOKEN = \"s3cret\""), "{local}");
|
||||
let loaded = ide_run::load_user_configs(dir.path()).unwrap();
|
||||
assert!(loaded.iter().any(|c| c.name == "Token" && c.extras.local && c.env["TOKEN"] == "s3cret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_variables_come_from_what_runs_inherit_login_shell_included() {
|
||||
// An app started from the Dock lacks what ~/.zshrc exports; runs get it from the login shell.
|
||||
ide_core::process::set_shell_env(vec![("OPENIDEA_IMPORT_SHELL_ONLY".into(), "from-shell".into())]);
|
||||
let dir = project(
|
||||
r#"{ "configurations": [{ "name": "App", "type": "debugpy", "request": "launch", "program": "app.py",
|
||||
"env": { "DATABASE_URL": "${env:OPENIDEA_IMPORT_SHELL_ONLY}" } }] }"#,
|
||||
);
|
||||
let r = ide_run::import_configs(dir.path(), RunImportSource::Vscode, &[]).unwrap();
|
||||
assert_eq!(get(&r, "App").env["DATABASE_URL"], "from-shell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_interpreter_keeps_its_options_and_subdirectory_manage_py_stays_python() {
|
||||
let (_dir, r) = run(r#"{ "configurations": [
|
||||
{ "name": "Dev mode", "type": "debugpy", "request": "launch", "program": "app.py",
|
||||
"python": ["${command:python.interpreterPath}", "-X", "dev"] },
|
||||
{ "name": "Default", "type": "debugpy", "request": "launch", "program": "app.py",
|
||||
"python": "${config:python.defaultInterpreterPath}" },
|
||||
{ "name": "Spaces", "type": "debugpy", "request": "launch", "program": "app.py",
|
||||
"python": "/opt/My Python/bin/python" },
|
||||
{ "name": "Dot tests", "type": "debugpy", "request": "launch", "program": "./manage.py", "args": ["test", "polls"] },
|
||||
{ "name": "Nested tests", "type": "debugpy", "request": "launch",
|
||||
"program": "${workspaceFolder}/backend/manage.py", "args": ["test"] }
|
||||
] }"#);
|
||||
let py = if cfg!(windows) { "python" } else { "python3" };
|
||||
assert_eq!(get(&r, "Dev mode").command, py);
|
||||
assert_eq!(get(&r, "Dev mode").args, s(&["-X", "dev", "app.py"]));
|
||||
assert_eq!(get(&r, "Default").command, py);
|
||||
assert_eq!(get(&r, "Spaces").command, "/opt/My Python/bin/python");
|
||||
let dot = get(&r, "Dot tests");
|
||||
assert_eq!((dot.kind.as_str(), dot.args.clone()), ("django-test", s(&["manage.py", "test", "polls"])));
|
||||
// The Django tests form runs `manage.py` from the working directory: a nested one stays a Python run.
|
||||
let nested = get(&r, "Nested tests");
|
||||
assert_eq!((nested.kind.as_str(), nested.args.clone()), ("python", s(&["backend/manage.py", "test"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_names_match_without_surrounding_spaces() {
|
||||
let dir = project(
|
||||
r#"{ "configurations": [{ "name": " Django ", "type": "debugpy", "request": "launch", "program": "manage.py" }] }"#,
|
||||
);
|
||||
let existing = RunConfig {
|
||||
id: "user:django".into(),
|
||||
name: "Django ".into(),
|
||||
kind: "python".into(),
|
||||
command: "python3".into(),
|
||||
args: s(&["manage.py"]),
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let r = import_from(dir.path(), &[existing]);
|
||||
assert_eq!(reason(&r, "Django"), &SkipReason::NameTaken);
|
||||
}
|
||||
@@ -317,6 +317,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
run::run_save_configs,
|
||||
run::run_templates,
|
||||
run::run_save_templates,
|
||||
run::run_import_sources,
|
||||
run::run_import_configs,
|
||||
run::run_system_env,
|
||||
run::run_start,
|
||||
run::run_stop,
|
||||
|
||||
@@ -121,6 +121,28 @@ pub fn run_save_templates(window: Window, state: State<'_, AppState>, templates:
|
||||
ide_host::ops::run::run_save_templates(&state.host, window.label(), templates)
|
||||
}
|
||||
|
||||
/// Project files run configurations can be imported from (`.vscode/launch.json`).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn run_import_sources(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> IdeResult<Vec<ide_run::import::RunImportSource>> {
|
||||
ide_host::ops::run::run_import_sources(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// Configurations read from `source`, not saved; names already in `existing` are skipped.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn run_import_configs(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
source: ide_run::import::RunImportSource,
|
||||
existing: Vec<RunConfig>,
|
||||
) -> IdeResult<ide_run::import::RunImport> {
|
||||
ide_host::ops::run::run_import_configs(&state.host, window.label(), source, existing)
|
||||
}
|
||||
|
||||
/// The environment runs inherit when "Include system environment variables" is on.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -585,6 +585,10 @@ export const commands = {
|
||||
/** Configuration templates: per-type defaults applied to new configurations. */
|
||||
runTemplates: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_templates")),
|
||||
runSaveTemplates: (templates: RunConfig_Deserialize[]) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_templates", { templates })),
|
||||
/** Project files run configurations can be imported from (`.vscode/launch.json`). */
|
||||
runImportSources: () => typedError<RunImportSource[], IdeError>(__TAURI_INVOKE("run_import_sources")),
|
||||
/** Configurations read from `source`, not saved; names already in `existing` are skipped. */
|
||||
runImportConfigs: (source: RunImportSource, existing: RunConfig_Deserialize[]) => typedError<RunImport_Serialize, IdeError>(__TAURI_INVOKE("run_import_configs", { source, existing })),
|
||||
/** The environment runs inherit when "Include system environment variables" is on. */
|
||||
runSystemEnv: () => __TAURI_INVOKE<{ [key in string]: string }>("run_system_env"),
|
||||
runStart: (config: RunConfig_Deserialize, onOutput: Channel<OutputChunk>) => typedError<RunInfo, IdeError>(__TAURI_INVOKE("run_start", { config, onOutput })),
|
||||
@@ -3702,6 +3706,20 @@ export type IdentifierOccurrence = {
|
||||
|
||||
export type IgnoreDestination = "gitignore" | "localExclude";
|
||||
|
||||
export type ImportedRunConfig = ImportedRunConfig_Serialize | ImportedRunConfig_Deserialize;
|
||||
|
||||
export type ImportedRunConfig_Deserialize = {
|
||||
config: RunConfig_Deserialize,
|
||||
/** Keys of the source entry with no equivalent here (`preLaunchTask`, `justMyCode`…). */
|
||||
ignored: string[],
|
||||
};
|
||||
|
||||
export type ImportedRunConfig_Serialize = {
|
||||
config: RunConfig_Serialize,
|
||||
/** Keys of the source entry with no equivalent here (`preLaunchTask`, `justMyCode`…). */
|
||||
ignored: string[],
|
||||
};
|
||||
|
||||
export type IndentConfig = IndentConfig_Serialize | IndentConfig_Deserialize;
|
||||
|
||||
export type IndentConfig_Deserialize = {
|
||||
@@ -5490,6 +5508,32 @@ export type RunExtras_Serialize = {
|
||||
/** A process started from a run configuration. */
|
||||
export type RunId = number;
|
||||
|
||||
/** What an import found: configurations to add and entries left out, with the reason. */
|
||||
export type RunImport = RunImport_Serialize | RunImport_Deserialize;
|
||||
|
||||
/** A project file run configurations can be imported from. */
|
||||
export type RunImportSource =
|
||||
/** VS Code's `.vscode/launch.json`. */
|
||||
"vscode";
|
||||
|
||||
/** What an import found: configurations to add and entries left out, with the reason. */
|
||||
export type RunImport_Deserialize = {
|
||||
source: RunImportSource,
|
||||
/** The file read, relative to the project root. */
|
||||
file: string,
|
||||
imported: ImportedRunConfig_Deserialize[],
|
||||
skipped: SkippedRunConfig[],
|
||||
};
|
||||
|
||||
/** What an import found: configurations to add and entries left out, with the reason. */
|
||||
export type RunImport_Serialize = {
|
||||
source: RunImportSource,
|
||||
/** The file read, relative to the project root. */
|
||||
file: string,
|
||||
imported: ImportedRunConfig_Serialize[],
|
||||
skipped: SkippedRunConfig[],
|
||||
};
|
||||
|
||||
export type RunInfo = {
|
||||
id: RunId,
|
||||
configId: string,
|
||||
@@ -5841,6 +5885,29 @@ export type SignatureHelp = {
|
||||
activeParameter: number,
|
||||
};
|
||||
|
||||
export type SkipReason =
|
||||
/** A configuration with this name already exists. */
|
||||
{ kind: "nameTaken" } |
|
||||
/** The debugger type (`type`) has no equivalent here, e.g. `cppdbg`. */
|
||||
{ kind: "unsupportedType"; type: string } |
|
||||
/** A `request` other than `launch` and `attach`. */
|
||||
{ kind: "unsupportedRequest"; request: string } |
|
||||
/** An attach other than to a host and port (to a process id, or listening for it). */
|
||||
{ kind: "unsupportedAttach" } |
|
||||
/** Uses a variable only known when VS Code starts it (`${file}`, `${input:…}`, `${command:…}`). */
|
||||
{ kind: "variable"; variable: string } |
|
||||
/** No program, module or command. */
|
||||
{ kind: "nothingToRun" } |
|
||||
/** A compound member that is neither imported nor an existing configuration. */
|
||||
{ kind: "missingMember"; member: string } |
|
||||
/** Not an object with a name. */
|
||||
{ kind: "invalid" };
|
||||
|
||||
export type SkippedRunConfig = {
|
||||
name: string,
|
||||
reason: SkipReason,
|
||||
};
|
||||
|
||||
/** Editor › General › Smart Keys. */
|
||||
export type SmartKeysSettings = {
|
||||
/** Home moves caret to first non-whitespace character. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, isTauri, type RunConfig } from "@/platform/ipc";
|
||||
import { call, commands, isTauri, type RunConfig, type RunImportSource } from "@/platform/ipc";
|
||||
import { letterOf } from "@/platform/keymap";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui";
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
validateConfig,
|
||||
type ConfigErrors,
|
||||
} from "./configs";
|
||||
import { importConfigs, importLabel } from "./importConfigs";
|
||||
import { targetName } from "./remote";
|
||||
import { saveUserConfigs, selectConfig, useRun } from "./store";
|
||||
import { blankTemplate, COMPOUND, missingParams, newConfigOfType, templateId, typeFor } from "./types";
|
||||
@@ -149,6 +150,44 @@ function EditConfigsDialog() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// "Import from .vscode/launch.json": offered only when the project has the file.
|
||||
const [importSources, setImportSources] = useState<RunImportSource[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const runImport = async (source: RunImportSource, from: readonly RunConfig[]) => {
|
||||
setImporting(true);
|
||||
try {
|
||||
const added = await importConfigs(source, from);
|
||||
if (!added?.length) return;
|
||||
setDraft((d) => [...d, ...added]);
|
||||
setMode("configs");
|
||||
setSelection({ type: "config", id: added[0]!.id });
|
||||
setDirty(true);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
// "Import Run Configurations from…" (Find Action): on opening, or while the dialog is open.
|
||||
const requested = useRun((s) => s.editImport);
|
||||
useEffect(() => {
|
||||
// Consumed from the store, not the render's value: StrictMode runs this twice on mount.
|
||||
const pending = useRun.getState().editImport;
|
||||
if (!pending) return;
|
||||
useRun.setState({ editImport: null });
|
||||
void runImport(pending, draft);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- runs per request, with the draft of that moment
|
||||
}, [requested]);
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
let live = true;
|
||||
call(commands.runImportSources())
|
||||
.then((s) => live && setImportSources(s))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- once, when the dialog opens
|
||||
}, []);
|
||||
|
||||
const close = () => useRun.setState({ editorOpen: false, editTarget: null });
|
||||
const current = selection?.type === "config" ? (draft.find((c) => c.id === selection.id) ?? null) : null;
|
||||
const templateFor = (kind: string): RunConfig | null => {
|
||||
@@ -534,6 +573,17 @@ function EditConfigsDialog() {
|
||||
>
|
||||
<T>Edit configuration templates…</T>
|
||||
</button>
|
||||
{importSources.map((source) => (
|
||||
<button
|
||||
key={source}
|
||||
type="button"
|
||||
className={clsx(styles.noticeLink, styles.editFooterLink, styles.editImportLink)}
|
||||
disabled={importing}
|
||||
onClick={() => void runImport(source, draft)}
|
||||
>
|
||||
{importLabel(source)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -504,6 +504,10 @@
|
||||
align-self: flex-start;
|
||||
margin: 8px 12px;
|
||||
}
|
||||
/* Below "Edit configuration templates…". */
|
||||
.editImportLink {
|
||||
margin-top: 0;
|
||||
}
|
||||
.footerRun {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
const ipc = vi.hoisted(() => ({ runImportSources: vi.fn(), runImportConfigs: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return { ...original, commands: { ...original.commands, ...ipc } };
|
||||
});
|
||||
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
|
||||
import { EditConfigsOverlay } from "./EditConfigsDialog";
|
||||
import { activate } from "./index";
|
||||
import { resetRuns, useRun } from "./store";
|
||||
|
||||
const django: RunConfig = {
|
||||
id: "user:django",
|
||||
name: "Django",
|
||||
kind: "python",
|
||||
command: ".venv/bin/python",
|
||||
args: ["manage.py", "runserver"],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: false,
|
||||
isTest: false,
|
||||
debugAdapter: "debugpy",
|
||||
};
|
||||
const ok = <T,>(data: T) => ({ status: "ok", data });
|
||||
const result = () =>
|
||||
ok({ source: "vscode", file: ".vscode/launch.json", imported: [{ config: django, ignored: [] }], skipped: [] });
|
||||
let notes: Array<Parameters<NotificationService["notify"]>[0]>;
|
||||
|
||||
describe("Find Action › Import Run Configurations from .vscode/launch.json", () => {
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
resetRuns();
|
||||
notes = [];
|
||||
provideNotificationService({ notify: (n) => void notes.push(n) });
|
||||
useProject.setState({ project: { root: "/test-import" } as never });
|
||||
ipc.runImportConfigs.mockReset();
|
||||
ipc.runImportConfigs.mockImplementation(async () => result());
|
||||
useRun.setState({ configs: [], loaded: true });
|
||||
setContext("projectOpen", true);
|
||||
dispose = activate();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
dispose();
|
||||
useProject.setState({ project: null });
|
||||
});
|
||||
|
||||
it("opens the dialog and imports once", async () => {
|
||||
ipc.runImportSources.mockImplementation(async () => ok(["vscode"]));
|
||||
// As in the app (main.tsx): StrictMode runs the dialog's effects twice on mount.
|
||||
render(
|
||||
<StrictMode>
|
||||
<EditConfigsOverlay />
|
||||
</StrictMode>,
|
||||
);
|
||||
await act(() => executeAction("run.importVscodeLaunch"));
|
||||
expect(useRun.getState().editorOpen).toBe(true);
|
||||
expect(await screen.findByRole("treeitem", { name: /^Django/ })).toBeInTheDocument();
|
||||
expect(ipc.runImportConfigs).toHaveBeenCalledTimes(1);
|
||||
expect(useRun.getState().editImport).toBeNull();
|
||||
// No duplicate ids in the draft.
|
||||
expect(screen.getAllByRole("treeitem", { name: /^Django/ })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says so without opening the dialog when the project has no launch.json", async () => {
|
||||
ipc.runImportSources.mockImplementation(async () => ok([]));
|
||||
await act(() => executeAction("run.importVscodeLaunch"));
|
||||
expect(useRun.getState().editorOpen).toBe(false);
|
||||
expect(ipc.runImportConfigs).not.toHaveBeenCalled();
|
||||
expect(notes.at(-1)).toMatchObject({ level: "info", title: "This project has no .vscode/launch.json" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const ipc = vi.hoisted(() => ({
|
||||
runSaveConfigs: vi.fn(),
|
||||
runConfigs: vi.fn(),
|
||||
runTemplates: vi.fn(async () => ({ status: "ok", data: [] })),
|
||||
runImportSources: vi.fn(),
|
||||
runImportConfigs: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/platform/ipc")>()),
|
||||
isTauri: () => true,
|
||||
commands: ipc,
|
||||
}));
|
||||
|
||||
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
|
||||
import type { RunConfig, RunImport } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
|
||||
import { EditConfigsOverlay } from "./EditConfigsDialog";
|
||||
import { importSummary } from "./importConfigs";
|
||||
import { resetRuns, useRun } from "./store";
|
||||
import { registerBuiltinTypes } from "./types";
|
||||
|
||||
const cfg = (over: Partial<RunConfig>): RunConfig => ({
|
||||
id: "user:django",
|
||||
name: "Django",
|
||||
kind: "python",
|
||||
command: ".venv/bin/python",
|
||||
args: ["manage.py", "runserver"],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: false,
|
||||
isTest: false,
|
||||
debugAdapter: "debugpy",
|
||||
...over,
|
||||
});
|
||||
|
||||
const result = (over: Partial<RunImport> = {}): RunImport => ({
|
||||
source: "vscode",
|
||||
file: ".vscode/launch.json",
|
||||
imported: [{ config: cfg({}), ignored: ["justMyCode"] }],
|
||||
skipped: [
|
||||
{ name: "Current file", reason: { kind: "variable", variable: "${file}" } },
|
||||
{ name: "Chrome", reason: { kind: "unsupportedType", type: "chrome" } },
|
||||
{ name: "", reason: { kind: "invalid" } },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
|
||||
const ok = <T,>(data: T) => ({ status: "ok", data });
|
||||
type Note = Parameters<NotificationService["notify"]>[0];
|
||||
let notes: Note[];
|
||||
|
||||
describe("import summary", () => {
|
||||
afterEach(() => _resetI18nForTests());
|
||||
|
||||
it("counts the imported configurations and says why the others were left out", () => {
|
||||
const s = importSummary(result());
|
||||
expect(s.title).toBe("Imported 1 run configuration from .vscode/launch.json");
|
||||
expect(s.level).toBe("info");
|
||||
expect(s.sticky).toBe(true);
|
||||
render(<>{s.body}</>);
|
||||
expect(
|
||||
screen.getByText("Skipped 'Current file': Uses ${file}, which is known only when VS Code starts it"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Skipped 'Chrome': The chrome debugger has no equivalent here")).toBeInTheDocument();
|
||||
expect(screen.getByText("Skipped 'Unnamed': Not a configuration with a name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Left out of 'Django': justMyCode")).toBeInTheDocument();
|
||||
expect(screen.getByText("Click OK or Apply to save them.")).toBeInTheDocument();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("warns when nothing came in, and translates counts and reasons", async () => {
|
||||
const none = importSummary(result({ imported: [], skipped: [{ name: "Django", reason: { kind: "nameTaken" } }] }));
|
||||
expect(none).toMatchObject({ level: "warning", title: "No run configurations imported from .vscode/launch.json" });
|
||||
expect(importSummary(result({ imported: [{ config: cfg({}), ignored: [] }], skipped: [] }))).toMatchObject({
|
||||
sticky: false,
|
||||
});
|
||||
const two = result({
|
||||
imported: [
|
||||
{ config: cfg({}), ignored: [] },
|
||||
{ config: cfg({ id: "b", name: "B" }), ignored: [] },
|
||||
],
|
||||
});
|
||||
expect(importSummary(two).title).toBe("Imported 2 run configurations from .vscode/launch.json");
|
||||
await setLocale("zh");
|
||||
expect(importSummary(two).title).toBe("已从 .vscode/launch.json 导入 2 个运行配置");
|
||||
render(<>{importSummary(result({ imported: [] })).body}</>);
|
||||
expect(screen.getByText("已跳过 'Chrome':此处没有与 chrome 调试器对应的配置类型")).toBeInTheDocument();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Run/Debug Configurations › Import from .vscode/launch.json", () => {
|
||||
let dispose: () => void;
|
||||
const detected = cfg({ id: "django:runserver", name: "manage.py runserver", command: "python3", detected: true });
|
||||
|
||||
beforeEach(() => {
|
||||
resetRuns();
|
||||
localStorage.clear();
|
||||
dispose = registerBuiltinTypes();
|
||||
notes = [];
|
||||
provideNotificationService({ notify: (n) => void notes.push(n) });
|
||||
useProject.setState({ project: { root: "/test-import" } as never });
|
||||
ipc.runImportSources.mockImplementation(async () => ok(["vscode"]));
|
||||
ipc.runImportConfigs.mockReset();
|
||||
ipc.runImportConfigs.mockImplementation(async () => ok(result()));
|
||||
ipc.runSaveConfigs.mockReset();
|
||||
ipc.runSaveConfigs.mockImplementation(async () => ok(null));
|
||||
useRun.setState({ configs: [detected], loaded: true, selectedId: detected.id, editorOpen: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
dispose();
|
||||
useProject.setState({ project: null });
|
||||
});
|
||||
|
||||
it("adds the imported configurations to the dialog, saved on Apply", async () => {
|
||||
render(<EditConfigsOverlay />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Import from .vscode/launch.json" }));
|
||||
// The backend matches names against everything in the dialog, detected configurations included.
|
||||
await waitFor(() => expect(ipc.runImportConfigs).toHaveBeenCalledWith("vscode", [detected]));
|
||||
expect(await screen.findByRole("treeitem", { name: /^Django/ })).toHaveAttribute("aria-selected", "true");
|
||||
expect(notes.at(-1)).toMatchObject({ title: "Imported 1 run configuration from .vscode/launch.json" });
|
||||
expect(ipc.runSaveConfigs).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
await waitFor(() => expect(ipc.runSaveConfigs).toHaveBeenCalledWith([cfg({})], []));
|
||||
});
|
||||
|
||||
it("offers no import without the file, and reports a file it can't read", async () => {
|
||||
ipc.runImportSources.mockImplementation(async () => ok([]));
|
||||
render(<EditConfigsOverlay />);
|
||||
await waitFor(() => expect(ipc.runImportSources).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: "Import from .vscode/launch.json" })).not.toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
ipc.runImportSources.mockImplementation(async () => ok(["vscode"]));
|
||||
ipc.runImportConfigs.mockImplementation(async () => ({
|
||||
status: "error",
|
||||
error: { kind: "invalid", message: ".vscode/launch.json: EOF while parsing", data: null },
|
||||
}));
|
||||
render(<EditConfigsOverlay />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Import from .vscode/launch.json" }));
|
||||
await waitFor(() =>
|
||||
expect(notes.at(-1)).toMatchObject({
|
||||
level: "error",
|
||||
title: "Cannot import run configurations",
|
||||
body: ".vscode/launch.json: EOF while parsing",
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("treeitem", { name: /^Django/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("imports again when asked while the dialog is open", async () => {
|
||||
render(<EditConfigsOverlay />);
|
||||
await screen.findByRole("button", { name: "Import from .vscode/launch.json" });
|
||||
act(() => useRun.setState({ editImport: "vscode" }));
|
||||
expect(await screen.findByRole("treeitem", { name: /^Django/ })).toBeInTheDocument();
|
||||
expect(useRun.getState().editImport).toBeNull();
|
||||
expect(ipc.runImportConfigs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a configuration with this machine's values local, and says so", async () => {
|
||||
const local = cfg({ id: "user:token", name: "Token", local: true });
|
||||
ipc.runImportConfigs.mockImplementation(async () =>
|
||||
ok(result({ imported: [{ config: local, ignored: [] }], skipped: [] })),
|
||||
);
|
||||
render(<EditConfigsOverlay />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Import from .vscode/launch.json" }));
|
||||
await screen.findByRole("treeitem", { name: /^Token/ });
|
||||
render(<>{notes.at(-1)!.body}</>);
|
||||
expect(
|
||||
screen.getByText("'Token' is saved in run.local.toml because it uses values from this machine"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
await waitFor(() => expect(ipc.runSaveConfigs).toHaveBeenCalledWith([local], []));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* "Import from .vscode/launch.json" in Run/Debug Configurations: the backend maps the file's
|
||||
* entries (`ide-run` `import`), the configurations join the dialog's draft and are saved with
|
||||
* it on OK / Apply. A notification says how many came in and why the others were left out.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands, type RunConfig, type RunImport, type RunImportSource, type SkipReason } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
/** The dialog's link for `source`. */
|
||||
export function importLabel(source: RunImportSource): string {
|
||||
switch (source) {
|
||||
case "vscode":
|
||||
return tx("Import from .vscode/launch.json");
|
||||
}
|
||||
}
|
||||
|
||||
/** Why an entry was not imported. */
|
||||
export function skipReasonText(reason: SkipReason): string {
|
||||
switch (reason.kind) {
|
||||
case "nameTaken":
|
||||
return tx("A configuration with this name already exists");
|
||||
case "unsupportedType":
|
||||
return tx`The ${reason.type} debugger has no equivalent here`;
|
||||
case "unsupportedRequest":
|
||||
return tx`Request '${reason.request}' isn't supported`;
|
||||
case "unsupportedAttach":
|
||||
return tx("Only attaching to a host and port is supported");
|
||||
case "variable":
|
||||
return tx`Uses ${reason.variable}, which is known only when VS Code starts it`;
|
||||
case "nothingToRun":
|
||||
return tx("No program, module or command to run");
|
||||
case "missingMember":
|
||||
return tx`Its configuration '${reason.member}' isn't imported`;
|
||||
case "invalid":
|
||||
return tx("Not a configuration with a name");
|
||||
}
|
||||
}
|
||||
|
||||
/** The notification telling what an import brought in and what it left out. */
|
||||
export function importSummary(result: RunImport): {
|
||||
level: "info" | "warning";
|
||||
title: string;
|
||||
body?: ReactNode;
|
||||
sticky: boolean;
|
||||
} {
|
||||
const n = result.imported.length;
|
||||
const file = result.file;
|
||||
const title =
|
||||
n === 0
|
||||
? tx`No run configurations imported from ${file}`
|
||||
: n === 1
|
||||
? tx`Imported ${n} run configuration from ${file}`
|
||||
: tx`Imported ${n} run configurations from ${file}`;
|
||||
const notes = [
|
||||
...result.skipped.map((s) => tx`Skipped '${s.name || tx("Unnamed")}': ${skipReasonText(s.reason)}`),
|
||||
...result.imported
|
||||
.filter((c) => c.ignored.length)
|
||||
.map((c) => tx`Left out of '${c.config.name}': ${c.ignored.join(", ")}`),
|
||||
...result.imported
|
||||
.filter((c) => c.config.local)
|
||||
.map((c) => tx`'${c.config.name}' is saved in run.local.toml because it uses values from this machine`),
|
||||
];
|
||||
const lines = n ? [...notes, tx("Click OK or Apply to save them.")] : notes;
|
||||
return {
|
||||
level: n ? "info" : "warning",
|
||||
title,
|
||||
...(lines.length ? { body: lines.map((line, i) => <div key={i}>{line}</div>) } : {}),
|
||||
// The reasons stay readable until dismissed.
|
||||
sticky: notes.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurations from `source` that `draft` (the dialog's configurations, detected ones
|
||||
* included) doesn't have by name; null when the file can't be read.
|
||||
*/
|
||||
export async function importConfigs(source: RunImportSource, draft: readonly RunConfig[]): Promise<RunConfig[] | null> {
|
||||
try {
|
||||
const result = await call(commands.runImportConfigs(source, [...draft]));
|
||||
notifications().notify({ group: "run", key: "run-import", ...importSummary(result) });
|
||||
return result.imported.map((c) => c.config);
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "error",
|
||||
title: "Cannot import run configurations",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
key: "run-import",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
import { registerEditorContribution } from "@/editor/api";
|
||||
import { executeAction, getAction, registerActions } from "@/platform/actions";
|
||||
import { getContext, setContext } from "@/platform/context";
|
||||
import { onIdeEvent, type RunConfig } from "@/platform/ipc";
|
||||
import { call, commands, onIdeEvent, type RunConfig, type RunImportSource } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { onWorkflowSettingsChange } from "@/platform/workflowSettings";
|
||||
import { showContextMenu, lazyComponent, type MenuItem } from "@/ui";
|
||||
@@ -407,6 +407,27 @@ export function activate(): () => void {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "run.importVscodeLaunch",
|
||||
title: "Import Run Configurations from .vscode/launch.json",
|
||||
category: "Run",
|
||||
when: "projectOpen",
|
||||
keywords: ["launch.json", "vscode", "visual studio code", "debug configuration"],
|
||||
run: async () => {
|
||||
const sources = await call(commands.runImportSources()).catch((): RunImportSource[] => []);
|
||||
if (!sources.includes("vscode")) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "info",
|
||||
title: "This project has no .vscode/launch.json",
|
||||
key: "run-import",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await ensureConfigs();
|
||||
useRun.setState({ editorOpen: true, chooserOpen: false, editTarget: null, editImport: "vscode" });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "run.debugConfig",
|
||||
title: "Debug Configuration",
|
||||
|
||||
@@ -5,6 +5,7 @@ const ipc = vi.hoisted(() => ({
|
||||
runSaveConfigs: vi.fn(),
|
||||
runConfigs: vi.fn(),
|
||||
runTemplates: vi.fn(async () => ({ status: "ok", data: [] })),
|
||||
runImportSources: vi.fn(async () => ({ status: "ok", data: [] })),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/platform/ipc")>()),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type OutputChunk,
|
||||
type RunConfig,
|
||||
type RunId,
|
||||
type RunImportSource,
|
||||
type TestCaseReport,
|
||||
} from "@/platform/ipc";
|
||||
import {
|
||||
@@ -125,6 +126,8 @@ interface RunState {
|
||||
editTarget: string | null;
|
||||
/** The edit dialog opens with unsaved changes (a temporary configuration to make permanent on OK). */
|
||||
editDirty: boolean;
|
||||
/** The edit dialog opens by importing from this project file ("Import Run Configurations from…"). */
|
||||
editImport: RunImportSource | null;
|
||||
}
|
||||
|
||||
export const useRun = create<RunState>(() => ({
|
||||
@@ -141,6 +144,7 @@ export const useRun = create<RunState>(() => ({
|
||||
editorOpen: false,
|
||||
editTarget: null,
|
||||
editDirty: false,
|
||||
editImport: null,
|
||||
}));
|
||||
|
||||
const runtimes = new Map<string, SessionRuntime>();
|
||||
@@ -1093,6 +1097,7 @@ export function resetRuns() {
|
||||
editorOpen: false,
|
||||
editTarget: null,
|
||||
editDirty: false,
|
||||
editImport: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2946,6 +2946,25 @@
|
||||
"No run configurations added.": "未添加运行配置。",
|
||||
"Add new…": "新增…",
|
||||
"Edit configuration templates…": "编辑配置模板…",
|
||||
"Import from .vscode/launch.json": "从 .vscode/launch.json 导入",
|
||||
"Import Run Configurations from .vscode/launch.json": "从 .vscode/launch.json 导入运行配置",
|
||||
"Cannot import run configurations": "无法导入运行配置",
|
||||
"No run configurations imported from {file}": "未从 {file} 导入任何运行配置",
|
||||
"Imported {n} run configuration from {file}": "已从 {file} 导入 {n} 个运行配置",
|
||||
"Imported {n} run configurations from {file}": "已从 {file} 导入 {n} 个运行配置",
|
||||
"Skipped '{name}': {reason}": "已跳过 '{name}':{reason}",
|
||||
"Left out of '{name}': {keys}": "'{name}' 中未导入的设置:{keys}",
|
||||
"Click OK or Apply to save them.": "点击“确定”或“应用”以保存。",
|
||||
"A configuration with this name already exists": "已存在同名配置",
|
||||
"The {type} debugger has no equivalent here": "此处没有与 {type} 调试器对应的配置类型",
|
||||
"Request '{request}' isn't supported": "不支持请求 '{request}'",
|
||||
"Only attaching to a host and port is supported": "仅支持附加到主机和端口",
|
||||
"Uses {variable}, which is known only when VS Code starts it": "使用了 {variable},其值只有在 VS Code 启动时才能确定",
|
||||
"No program, module or command to run": "没有要运行的程序、模块或命令",
|
||||
"Its configuration '{member}' isn't imported": "其中的配置 '{member}' 未导入",
|
||||
"Not a configuration with a name": "不是带名称的配置",
|
||||
"'{name}' is saved in run.local.toml because it uses values from this machine": "'{name}' 使用了本机的值,因此保存在 run.local.toml 中",
|
||||
"This project has no .vscode/launch.json": "此项目没有 .vscode/launch.json",
|
||||
"Templates": "模板",
|
||||
"Configuration templates": "配置模板",
|
||||
"Back to configurations": "返回配置",
|
||||
|
||||
Reference in new issue
Заблокувати користувача