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

..
Автор SHA1 Повідомлення Дата
arookieofc 606aff1613 fix(remote): sync outside-root classpath entries for SSH Java launches (#2641) 2026-11-11 14:58:49 +08:00
26 змінених файлів з 351 додано та 992 видалено

No files matched your search

+26 -320
Переглянути файл
@@ -9,14 +9,6 @@ use std::path::{Path, PathBuf};
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// A custom local-data root chosen in Settings › System Settings › Caches,
/// stored in the configuration directory. The environment variable still wins.
pub const OVERRIDE_FILE: &str = "local-data-dir.txt";
/// A pending move of the local data to a newly chosen directory, carried out
/// at the next start (before any project opens), like File › Invalidate Caches.
const MIGRATION_FILE: &str = "local-data-migration.json";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
@@ -25,203 +17,6 @@ pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
/// The directory to actually start with: the environment override, else a valid
/// custom directory from Settings, else the OS default. A stored but unusable
/// custom directory is an error, never a silent fallback.
pub fn startup_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
startup_resolve(std::env::var_os(LOCAL_DATA_ENV), stored_override(identifier), dirs::data_local_dir(), identifier)
}
fn startup_resolve(
var: Option<OsString>,
stored: Option<PathBuf>,
os: Option<PathBuf>,
identifier: &str,
) -> Result<PathBuf, String> {
if var.as_ref().is_some_and(|v| !v.is_empty()) {
return resolve_local(var, os, identifier);
}
let Some(root) = stored else { return resolve_local(None, os, identifier) };
if !root.is_absolute() {
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
}
resolve_root(root, identifier, "custom local data directory")
}
/// The file holding the custom local-data root, if the platform exposes its
/// configuration directory. Both startup and Settings use this one path.
pub fn override_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(OVERRIDE_FILE))
}
fn migration_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(MIGRATION_FILE))
}
/// The custom root stored by Settings, if any (validated when it is used).
pub fn stored_override(identifier: &str) -> Option<PathBuf> {
stored_override_in(&override_path(identifier)?)
}
fn stored_override_in(path: &Path) -> Option<PathBuf> {
let text = fs::read_to_string(path).ok()?;
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
}
/// The OS default local data directory (what clearing the custom directory
/// returns to), ignoring any stored override. The environment still applies.
pub fn default_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
local_data_dir(identifier)
}
/// Stores (`Some`) or clears (`None`) the custom local-data root, returning the
/// directory that takes effect after a restart. A pending custom migration to
/// anywhere else is dropped: only the caller schedules the matching one.
pub fn set_override(identifier: &str, root: Option<&Path>) -> Result<PathBuf, String> {
let Some(path) = override_path(identifier) else {
return Err("cannot find the configuration directory".into());
};
set_override_in(&path, &migration_path(identifier), root, identifier)
}
fn set_override_in(
path: &Path,
migration: &Option<PathBuf>,
root: Option<&Path>,
identifier: &str,
) -> Result<PathBuf, String> {
if let Some(migration) = migration {
let _ = fs::remove_file(migration);
}
let Some(root) = root else {
if let Err(error) = fs::remove_file(path)
&& error.kind() != io::ErrorKind::NotFound
{
return Err(format!("cannot clear the custom local data directory: {error}"));
}
return local_data_dir(identifier);
};
if !root.is_absolute() {
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
}
let dir = resolve_root(root.to_path_buf(), identifier, "custom local data directory")?;
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
return Err(format!("cannot store the custom local data directory: {error}"));
}
if let Err(error) = fs::write(path, format!("{}\n", root.display())) {
return Err(format!("cannot store the custom local data directory: {error}"));
}
Ok(dir)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct CustomMigration {
from: PathBuf,
to: PathBuf,
}
/// Schedules a move of the local data from `from` to `to` at the next start.
/// Returns false when there is nothing to move. The caller restarts the IDE.
pub fn schedule_custom_migration(identifier: &str, from: &Path, to: &Path) -> Result<bool, String> {
let Some(path) = migration_path(identifier) else {
return Err("cannot find the configuration directory".into());
};
schedule_custom_migration_in(&path, from, to)
}
fn schedule_custom_migration_in(path: &Path, from: &Path, to: &Path) -> Result<bool, String> {
if from == to {
return Ok(false);
}
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
return Err(format!("cannot schedule the data move: {error}"));
}
let json = serde_json::to_vec(&CustomMigration { from: from.into(), to: to.into() })
.map_err(|error| format!("cannot schedule the data move: {error}"))?;
if let Err(error) = fs::write(path, json) {
return Err(format!("cannot schedule the data move: {error}"));
}
Ok(true)
}
/// Carries out a scheduled custom migration aimed at `local` (call at start-up,
/// before projects open). Entries that appear in the meantime are never
/// overwritten; a partially moved tree is retried at the next start.
pub fn apply_custom_migration(identifier: &str, local: &Path) {
let Some(path) = migration_path(identifier) else { return };
apply_custom_migration_in(&path, local);
}
fn apply_custom_migration_in(path: &Path, local: &Path) {
let Ok(bytes) = fs::read(path) else { return };
let Ok(job) = serde_json::from_slice::<CustomMigration>(&bytes) else {
let _ = fs::remove_file(path);
return;
};
if job.to != local || job.from == local {
let _ = fs::remove_file(path);
return;
}
move_entries(&job.from, local, &custom_entries(&job.from));
let leftover = custom_entries(&job.from).into_iter().any(|entry| job.from.join(entry).exists());
if !leftover {
let _ = fs::remove_file(path);
}
}
/// Entries of a local-data tree that move to a newly chosen directory.
/// Non-relocatable tool installs (virtual environments with absolute interpreter
/// paths) stay behind and are reinstalled on demand, as in `migrate_entries`.
fn custom_entries(root: &Path) -> Vec<PathBuf> {
let mut entries = local_entries(root);
for extra in ["logs", "agents/tools"] {
let entry = PathBuf::from(extra);
if !entries.contains(&entry) {
entries.push(entry);
}
}
entries
}
/// Moves the local-data tree from `from` to `to`, entry by entry. Same-volume
/// entries rename at once; cross-volume copies run in the background. Targets
/// created in the meantime are never merged into or overwritten.
fn move_entries(from: &Path, to: &Path, entries: &[PathBuf]) {
if from == to {
return;
}
let mut copies = Vec::new();
for entry in entries {
let source = from.join(entry);
let target = to.join(entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data move failed; keeping old data, using new location")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying data to the new location in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data");
}
}
}
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
@@ -230,14 +25,10 @@ fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
resolve_root(root, identifier, LOCAL_DATA_ENV)
}
fn resolve_root(root: PathBuf, identifier: &str, what: &str) -> Result<PathBuf, String> {
let dir = root.join(identifier);
fs::create_dir_all(&dir)
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
.map_err(|error| format!("{what}: cannot write to {}: {error}", dir.display()))?;
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
@@ -286,11 +77,6 @@ pub fn migrate(data: &Path, local: &Path) {
}
fn migrate_entries(data: &Path, local: &Path) {
move_entries(data, local, &local_entries(data));
}
/// The rebuildable entries of the legacy roaming tree that move to Local.
fn local_entries(data: &Path) -> Vec<PathBuf> {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
@@ -332,7 +118,31 @@ fn local_entries(data: &Path) -> Vec<PathBuf> {
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
entries
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
}
/// Returns true when a cross-volume copy is needed.
@@ -787,108 +597,4 @@ mod tests {
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
#[test]
fn custom_override_round_trips_and_validates() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("local-data-dir.txt");
let migration = tmp.path().join("local-data-migration.json");
let id = "ai.test.openidea";
assert_eq!(stored_override_in(&file), None);
let root = tmp.path().join("custom-root");
let dir = set_override_in(&file, &Some(migration.clone()), Some(&root), id).unwrap();
assert_eq!(dir, root.join(id));
assert!(dir.is_dir());
assert_eq!(stored_override_in(&file), Some(root.clone()));
assert_eq!(startup_resolve(None, stored_override_in(&file), Some(tmp.path().join("os")), id).unwrap(), dir);
// A relative root is rejected before anything is written.
let before = fs::read_to_string(&file).unwrap();
let error = set_override_in(&file, &Some(migration.clone()), Some(Path::new("relative")), id).unwrap_err();
assert!(error.contains("absolute"), "{error}");
assert_eq!(fs::read_to_string(&file).unwrap(), before);
// An unwritable root (an existing file) is rejected too.
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "").unwrap();
let error = set_override_in(&file, &Some(migration.clone()), Some(&blocker), id).unwrap_err();
assert!(error.contains("cannot write"), "{error}");
// Clearing removes the file and returns the default.
let dir = set_override_in(&file, &Some(migration), None, id).unwrap();
assert!(!file.exists());
assert_eq!(dir, local_data_dir(id).unwrap());
assert_eq!(stored_override_in(&file), None);
}
#[test]
fn startup_prefers_the_environment_then_the_override() {
let tmp = tempfile::tempdir().unwrap();
let os = tmp.path().join("os");
let custom = tmp.path().join("custom");
let env = tmp.path().join("env");
let id = "ai.test.openidea";
let error = startup_resolve(None, Some(PathBuf::from("relative")), Some(os.clone()), id).unwrap_err();
assert!(error.contains("absolute"), "{error}");
// The environment wins over a stored override.
let dir = startup_resolve(Some(env.clone().into()), Some(custom.clone()), Some(os.clone()), id).unwrap();
assert_eq!(dir, env.join(id));
// Without the environment the override wins over the OS default.
let dir = startup_resolve(None, Some(custom.clone()), Some(os.clone()), id).unwrap();
assert_eq!(dir, custom.join(id));
// And without either the OS default applies.
let dir = startup_resolve(None, None, Some(os.clone()), id).unwrap();
assert_eq!(dir, os.join(id));
}
#[test]
fn custom_migration_moves_the_tree_and_clears_itself() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("local-data-migration.json");
let from = tmp.path().join("old-local");
let to = tmp.path().join("new-local");
fs::create_dir_all(from.join("index/p1")).unwrap();
fs::write(from.join("index/p1/symbols"), "index").unwrap();
fs::create_dir_all(from.join("cache/p1")).unwrap();
fs::write(from.join("cache/p1/build"), "cache").unwrap();
fs::create_dir_all(from.join("tools/demo/bin")).unwrap();
fs::write(from.join("tools/demo/bin/demo.cmd"), "payload").unwrap();
fs::write(from.join("history"), "roaming stays").unwrap();
// Nothing to move: no marker is written.
assert!(!schedule_custom_migration_in(&marker, &from, &from).unwrap());
assert!(!marker.exists());
assert!(schedule_custom_migration_in(&marker, &from, &to).unwrap());
apply_custom_migration_in(&marker, &to);
assert!(!marker.exists());
assert_eq!(fs::read_to_string(to.join("index/p1/symbols")).unwrap(), "index");
assert_eq!(fs::read_to_string(to.join("cache/p1/build")).unwrap(), "cache");
assert!(to.join("tools/demo/bin/demo.cmd").exists());
assert!(!from.join("index").exists());
// Roaming content is not local data and stays behind.
assert_eq!(fs::read_to_string(from.join("history")).unwrap(), "roaming stays");
// A migration aimed elsewhere is dropped without touching anything.
fs::create_dir_all(from.join("lsp")).unwrap();
assert!(schedule_custom_migration_in(&marker, &from, &tmp.path().join("elsewhere")).unwrap());
apply_custom_migration_in(&marker, &to);
assert!(!marker.exists());
assert!(from.join("lsp").exists());
assert!(!to.join("lsp").exists());
}
#[test]
fn custom_entries_cover_logs_and_agent_tools() {
let tmp = tempfile::tempdir().unwrap();
let entries = custom_entries(tmp.path());
for expected in ["logs", "agents/tools", "index", "cache", "lsp", "agents/registry.json"] {
assert!(entries.contains(&PathBuf::from(expected)), "{entries:?}");
}
}
}
+84 -5
Переглянути файл
@@ -1,13 +1,14 @@
//! Deployment over SFTP or local folders, and SSH run targets.
//! Shared by IPC commands and project save/delete hooks.
use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport};
use crate::mapping::{Excludes, Mappings, ResolvedMapping};
use crate::target::{RemoteRuns, RemoteStart, SyncFn};
use crate::deploy::{Deployment, FileStamp, SyncEntry, SyncStatus, SyncStep, TransferReport, status};
use crate::mapping::{Excludes, Mappings, ResolvedMapping, posix};
use crate::target::{RemoteRuns, RemoteStart, SyncFn, rewrite_external_classpath};
use ide_core::remote_settings::{AutoUpload, DeploymentServer, DeploymentType, RunTarget};
use ide_core::settings::Settings;
use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink};
use ide_run::{OutputChunk, RunConfig, RunInfo};
use ide_ssh::sftp::{join, mtime_of};
use ide_ssh::{FsConnection, RemoteEntry, RemoteFs, SshManager};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -359,10 +360,29 @@ impl RemoteService {
return Err(e);
}
};
// #2641: the resolved `-cp` may reference jars outside the project root
// (`~/.m2`), which deployment sync never uploads. Mirror them under the
// deployment and rewrite the command onto the remote copies. Entries inside
// the project root are untouched: `target_command` maps those already.
let (args, libraries) = rewrite_external_classpath(&config.args, root, &remote_root);
let mut config = config.clone();
config.args = args;
for (local, _) in &libraries {
if !local.exists() {
fs.close().await;
return Err(IdeError::invalid(format!(
"cannot run on '{}': classpath entry {} does not exist on this machine; \
package the application (for example `mvn package`) or fix the launch classpath",
target.name,
local.display()
)));
}
}
let sync: Option<SyncFn> = if target.sync_before_run {
let deployment = settings.deployment.clone();
let root = root.to_path_buf();
let remote_root = remote_root.clone();
let preserve_time = settings.deployment.preserve_timestamps;
Some(Box::new(move |out| {
Box::pin(async move {
let server = DeploymentServer { root_path: remote_root.clone(), ..Default::default() };
@@ -377,6 +397,7 @@ impl RemoteService {
};
let mut progress = |_: &str, _: usize, _: usize| {};
let report = d.push_changes(&root, &mut progress).await;
let synced = sync_external_libraries(&fs, &libraries, preserve_time).await;
fs.close().await;
let report = report?;
out(OutputChunk::System {
@@ -387,7 +408,17 @@ impl RemoteService {
ide_core::error::ErrorKind::Io,
format!("upload of {} failed: {}", e.path, e.message),
)),
None => Ok(()),
None => {
let synced = synced?;
if synced > 0 {
out(OutputChunk::System {
text: format!(
"Synced {synced} external classpath file(s) to {remote_root}/.remote-libs\n"
),
});
}
Ok(())
}
}
})
}))
@@ -396,10 +427,58 @@ impl RemoteService {
None
};
let start = RemoteStart { session, target, root: root.to_path_buf(), remote_root, sync };
Ok(self.runs.start(start, config, sink, on_output))
Ok(self.runs.start(start, &config, sink, on_output))
}
}
/// Uploads outside-root classpath entries to their mirrored remote copies,
/// skipping files the remote already has (same size and time). Returns the
/// uploaded count. Missing locals are rejected in `run` before the sync starts.
async fn sync_external_libraries(
fs: &RemoteFs,
libraries: &[(PathBuf, String)],
preserve_time: bool,
) -> IdeResult<usize> {
fn fail(path: &str, e: IdeError) -> IdeError {
IdeError::new(ide_core::error::ErrorKind::Io, format!("sync of {path} failed: {}", e.message))
}
let mut synced = 0;
for (local, remote_base) in libraries {
let mut files = Vec::new();
if local.is_dir() {
let walk = ignore::WalkBuilder::new(local).standard_filters(false).follow_links(false).build();
for entry in walk.flatten() {
let path = entry.path();
// `is_file` (not the entry type): symlinked jars count.
if !path.is_file() {
continue;
}
let Ok(rel) = path.strip_prefix(local) else { continue };
files.push((path.to_path_buf(), join(remote_base, &posix(rel))));
}
} else {
files.push((local.clone(), remote_base.clone()));
}
for (file, remote) in files {
let size = std::fs::metadata(&file).map_err(|e| fail(&remote, e.into()))?.len();
let local_stamp = FileStamp { size, mtime: mtime_of(&file) };
let remote_stamp = match fs.stat(&remote).await.map_err(|e| fail(&remote, e))? {
Some(e) if e.is_dir => {
return Err(IdeError::invalid(format!("cannot sync onto the folder {remote}")));
}
Some(e) => Some(FileStamp { size: e.size, mtime: e.mtime }),
None => None,
};
if matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same) {
continue;
}
fs.upload(&file, &remote, preserve_time).await.map_err(|e| fail(&remote, e))?;
synced += 1;
}
}
Ok(synced)
}
fn sanitize(s: &str) -> String {
s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect()
}
+147 -1
Переглянути файл
@@ -10,7 +10,7 @@ use ide_ssh::{Output, RemoteProcess, Session, Sig};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::future::Future;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -118,6 +118,105 @@ pub fn argv_command(config: &RunConfig, target: &RunTarget, root: &Path, remote_
target_command(config, target, root, remote_root, false)
}
/// `java` flags whose value is a path list that may point outside the project
/// (`~/.m2` jars, `--module-path` entries): deployment sync only uploads the
/// project root, so these entries need their own sync + rewrite (#2641).
const PATH_LIST_FLAGS: [&str; 6] =
["-cp", "-classpath", "--class-path", "-p", "--module-path", "--upgrade-module-path"];
/// The separator of a `-cp` value on this (client) machine.
pub fn classpath_separator() -> char {
if cfg!(windows) { ';' } else { ':' }
}
fn split_path_list(value: &str) -> Vec<&str> {
value.split(classpath_separator()).filter(|e| !e.is_empty()).collect()
}
/// Absolute classpath entries of `args` that live outside the project root.
/// Unresolved placeholders (`${…}`), wildcards and relative entries pass through:
///
/// callers map the project root already, and only concrete local files can be synced.
pub fn external_classpath_entries(args: &[String], root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
if PATH_LIST_FLAGS.contains(&args[i].as_str()) && i + 1 < args.len() {
let value = &args[i + 1];
if !value.contains("${") {
for entry in split_path_list(value) {
if entry.contains('*') {
continue;
}
let path = PathBuf::from(entry);
if path.is_absolute() && !path.starts_with(root) && !out.contains(&path) {
out.push(path);
}
}
}
i += 2;
} else {
i += 1;
}
}
out
}
/// The remote home of an outside-root dependency: mirrored under `.remote-libs`
/// (never `.openidea*`: deployment excludes those names), so the layout stays
/// debuggable and collision-free per client machine.
pub fn external_lib_remote(remote_root: &str, local: &Path) -> String {
let spelling = local.to_string_lossy().replace('\\', "/");
let stripped = spelling.trim_start_matches('/').replace(':', "");
join(remote_root.trim_end_matches('/'), &format!(".remote-libs/{stripped}"))
}
/// Rewrites the outside-root classpath entries of `args` onto their synced remote
/// copies; returns the new args and the (local entry, remote base) pairs to upload.
/// Entries inside the project root are untouched: `target_command` maps those.
pub fn rewrite_external_classpath(
args: &[String],
root: &Path,
remote_root: &str,
) -> (Vec<String>, Vec<(PathBuf, String)>) {
let mut table: Vec<(PathBuf, String)> = Vec::new();
let mut out = args.to_vec();
let mut i = 0;
while i < out.len() {
if PATH_LIST_FLAGS.contains(&out[i].as_str()) && i + 1 < out.len() {
let value = out[i + 1].clone();
if !value.contains("${") {
let mut rewritten = Vec::new();
let mut changed = false;
for entry in split_path_list(&value) {
let path = PathBuf::from(entry);
if !entry.contains('*') && path.is_absolute() && !path.starts_with(root) {
let remote = match table.iter().find(|(p, _)| p == &path) {
Some((_, r)) => r.clone(),
None => {
let r = external_lib_remote(remote_root, &path);
table.push((path, r.clone()));
r
}
};
rewritten.push(remote);
changed = true;
} else {
rewritten.push(entry.to_owned());
}
}
if changed {
out[i + 1] = rewritten.join(&classpath_separator().to_string());
}
}
i += 2;
} else {
i += 1;
}
}
(out, table)
}
fn target_command(
config: &RunConfig,
target: &RunTarget,
@@ -381,4 +480,51 @@ mod tests {
assert_eq!(replace_root("/work/proj", "/work/proj", "/srv"), "/srv");
assert_eq!(replace_root(r"C:\p\src/a.py C:\p2", r"C:\p", "/srv"), r"/srv/src/a.py C:\p2");
}
/// Unix/Windows spellings of the same layout: one inside-root entry, one
/// `~/.m2` jar twice, a placeholder value, a wildcard and a module path.
fn classpath_fixture() -> (PathBuf, Vec<String>, String, String) {
let (root, classes, m2) = if cfg!(windows) {
(r"C:\proj", r"C:\proj\target\classes", r"C:\Users\me\.m2\a.jar")
} else {
("/proj", "/proj/target/classes", "/home/me/.m2/a.jar")
};
let sep = classpath_separator();
let args = vec![
"-cp".to_owned(),
format!("{classes}{sep}{m2}{sep}{m2}"),
"-p".to_owned(),
m2.to_owned(),
"-cp".to_owned(),
"${classpath}".to_owned(),
"-cp".to_owned(),
"/elsewhere/lib/*".to_owned(),
];
(PathBuf::from(root), args, m2.to_owned(), classes.to_owned())
}
#[test]
fn collects_outside_root_classpath_entries_once() {
let (root, args, m2, _) = classpath_fixture();
let entries = external_classpath_entries(&args, &root);
assert_eq!(entries, vec![PathBuf::from(m2)]);
}
#[test]
fn rewrites_outside_root_entries_onto_remote_lib_copies() {
let (root, args, m2, classes) = classpath_fixture();
let (rewritten, table) = rewrite_external_classpath(&args, &root, "/srv/app");
let remote = if cfg!(windows) {
"/srv/app/.remote-libs/C/Users/me/.m2/a.jar".to_owned()
} else {
"/srv/app/.remote-libs/home/me/.m2/a.jar".to_owned()
};
assert_eq!(table, vec![(PathBuf::from(m2), remote.clone())]);
let sep = classpath_separator();
assert_eq!(rewritten[1], format!("{classes}{sep}{remote}{sep}{remote}"));
assert_eq!(rewritten[3], remote);
// Placeholders and wildcards pass through untouched.
assert_eq!(rewritten[5], "${classpath}");
assert_eq!(rewritten[7], "/elsewhere/lib/*");
}
}
-83
Переглянути файл
@@ -1,83 +0,0 @@
import { expect, test } from "./fixtures";
/** The Run console follows Tools › Terminal › Font, which can follow Editor › Font. */
test("the Run console renders with the configured terminal font and fallback", async ({ workbench: page }) => {
await page.evaluate(async () => {
const reactPath = "/node_modules/.vite/deps/react.js";
const domPath = "/node_modules/.vite/deps/react-dom_client.js";
const projectPath = "/src/platform/project.ts";
const consoleModelPath = "/src/features/run/console.ts";
const consoleViewPath = "/src/features/run/ConsoleView.tsx";
const react = await import(reactPath);
const dom = await import(domPath);
const { createElement } = react.default ?? react;
const { createRoot } = dom.default ?? dom;
const { useProject } = await import(projectPath);
const { ConsoleModel } = await import(consoleModelPath);
const { ConsoleView } = await import(consoleViewPath);
const settings = useProject.getState().settings;
useProject.setState({
settings: {
...settings,
terminal: {
...settings?.terminal,
useEditorFont: false,
fontFamily: "Fira Code",
fallbackFontFamily: "Maple Mono NF CN",
fontSize: 16,
},
},
});
const model = new ConsoleModel();
model.append({ kind: "stdout", text: "Hello from the Run console\nSecond line\n" });
model.flush();
const host = document.createElement("div");
host.id = "console-font-host";
host.style.cssText = "position:fixed;top:60px;left:420px;width:640px;height:240px;z-index:9999";
document.body.append(host);
createRoot(host).render(
createElement(ConsoleView, {
model,
version: 1,
softWrap: false,
follow: false,
onFollowChange: () => {},
cwd: "/",
}),
);
});
const log = page.locator("#console-font-host").getByRole("log");
await expect(log).toBeVisible();
await expect(log).toHaveCSS("font-family", /"Fira Code"/);
await expect(log).toHaveCSS("font-family", /"Maple Mono NF CN"/);
await expect(log).toHaveCSS("font-size", "16px");
await expect(log).toHaveCSS("line-height", "24px");
const rowGap = async () => {
const rows = log.locator("[data-index]");
const first = (await rows.nth(0).boundingBox())!;
const second = (await rows.nth(1).boundingBox())!;
return second.y - first.y;
};
await expect.poll(rowGap).toBe(24);
// Turning "Use editor font" on hands the console to Editor › Font: family,
// fallback and size, without restarting the session.
await page.evaluate(async () => {
const projectPath = "/src/platform/project.ts";
const { useProject } = await import(projectPath);
const settings = useProject.getState().settings;
useProject.setState({
settings: {
...settings,
terminal: { ...settings?.terminal, useEditorFont: true, fallbackFontFamily: "" },
editor: { ...settings?.editor, fontFamily: "Maple Mono NF", fallbackFont: "HarmonyOS Sans SC", fontSize: 14 },
},
});
});
await expect(log).toHaveCSS("font-family", /"Maple Mono NF"/);
await expect(log).toHaveCSS("font-family", /"HarmonyOS Sans SC"/);
await expect(log).toHaveCSS("font-size", "14px");
await expect(log).toHaveCSS("line-height", "21px");
await expect.poll(rowGap).toBe(21);
});
-12
Переглянути файл
@@ -210,15 +210,3 @@ test("Markdown preview highlights ⌘F matches and selected word occurrences", a
)
.toEqual({ current: ["Order"], other: ["Order", "Order"] });
});
test("the bookmark gutter takes no width until the file has a bookmark", async ({ workbench: page }) => {
const editor = await openFromTree(page, "README.md");
const column = editor.locator(".cm-gutter.cm-openidea-bookmarks");
const width = () => column.evaluate((el) => el.getBoundingClientRect().width);
await expect.poll(width).toBe(0);
await editor.locator(".cm-content").click();
await page.keyboard.press("F3");
await expect(column.locator(".cm-openidea-bookmark")).toBeVisible();
await expect.poll(width).toBeGreaterThan(0);
});
-53
Переглянути файл
@@ -1,12 +1,8 @@
//! File › Invalidate Caches… and restarting the IDE.
use crate::state::AppState;
use crate::windows::WindowsState;
use ide_core::IdeResult;
use ide_host::data_dirs;
use ide_workspace::caches::{self, InvalidateCachesOptions};
use serde::Serialize;
use std::path::PathBuf;
use tauri::{AppHandle, Manager, State};
/// Schedules the invalidation for the next start (`None` cancels a pending one).
@@ -30,52 +26,3 @@ pub fn app_restart(app: AppHandle, windows: State<'_, WindowsState>) {
windows.set_restart();
super::window::app_quit(app, windows);
}
/// Settings › System Settings › Caches: where the local data lives.
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LocalDataInfo {
/// The directory in use by this run.
pub current: String,
/// The OS default (what clearing the custom directory returns to).
pub default_dir: String,
/// The custom root stored by Settings, if any (`current` is `<root>/<identifier>`).
pub custom_root: Option<String>,
/// `OPENIDEA_LOCAL_DATA_DIR` is set and wins over everything Settings stores.
pub env_override: bool,
}
#[tauri::command]
#[specta::specta]
pub fn caches_local_data_info(app: AppHandle, state: State<'_, AppState>) -> IdeResult<LocalDataInfo> {
let identifier = app.config().identifier.clone();
let stringify = |path: PathBuf| path.display().to_string();
Ok(LocalDataInfo {
current: stringify(state.host.local_data_dir.clone()),
default_dir: stringify(data_dirs::default_local_data_dir(&identifier).map_err(ide_core::IdeError::internal)?),
custom_root: data_dirs::stored_override(&identifier).map(stringify),
env_override: std::env::var_os(data_dirs::LOCAL_DATA_ENV).is_some_and(|v| !v.is_empty()),
})
}
/// Stores (`Some`) or clears (`None`, an empty string counts) the custom
/// local-data root; with `migrate`, the current tree moves at the next start.
/// Returns the directory that takes effect after a restart. The caller
/// restarts the IDE (see `app_restart`).
#[tauri::command]
#[specta::specta]
pub fn caches_set_local_data_dir(
app: AppHandle,
state: State<'_, AppState>,
path: Option<String>,
migrate: bool,
) -> IdeResult<String> {
let identifier = app.config().identifier.clone();
let current = state.host.local_data_dir.clone();
let root = path.filter(|s| !s.trim().is_empty()).map(PathBuf::from);
let next = data_dirs::set_override(&identifier, root.as_deref()).map_err(ide_core::IdeError::internal)?;
if migrate {
data_dirs::schedule_custom_migration(&identifier, &current, &next).map_err(ide_core::IdeError::internal)?;
}
Ok(next.display().to_string())
}
-2
Переглянути файл
@@ -217,8 +217,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
refactor::refactor_undo_file_move,
refactor::refactor_can_undo_file_move,
caches::caches_schedule_invalidation,
caches::caches_local_data_info,
caches::caches_set_local_data_dir,
caches::app_restart,
nav::index_status,
nav::index_file_extensions,
+1 -3
Переглянути файл
@@ -90,9 +90,7 @@ pub fn run() {
linux_webkit::configure();
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
// A custom directory from Settings › System Settings › Caches applies here too
// (`OPENIDEA_LOCAL_DATA_DIR` still wins), so tools resolve against the new root.
let local_data_dir = ide_host::data_dirs::startup_local_data_dir(&context.config().identifier);
let local_data_dir = ide_host::data_dirs::local_data_dir(&context.config().identifier);
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
match &local_data_dir {
Ok(data) => {
-5
Переглянути файл
@@ -75,11 +75,6 @@ impl AppState {
tracing::info!(?data_dir, ?local_data_dir, "application data directories");
// Saved git HTTPS credentials (askpass answers them silently).
ide_ssh::git_creds::init(data_dir.join("git-credentials.json"));
// A newly chosen local-data directory from Settings › System Settings ›
// Caches: the previous tree moves first, so a pending invalidation still
// wipes what was just moved.
let identifier = app.config().identifier.clone();
ide_host::data_dirs::apply_custom_migration(&identifier, &local_data_dir);
// File › Invalidate Caches… requested at the last run.
let removed = ide_workspace::caches::apply_pending(&data_dir, &local_data_dir);
if !removed.is_empty() {
-20
Переглянути файл
@@ -391,14 +391,6 @@ export const commands = {
/** Build tool sync results (Maven / Gradle) and other per-project caches. */
buildCaches?: boolean,
} | null) => typedError<null, IdeError>(__TAURI_INVOKE("caches_schedule_invalidation", { options })),
cachesLocalDataInfo: () => typedError<LocalDataInfo, IdeError>(__TAURI_INVOKE("caches_local_data_info")),
/**
* Stores (`Some`) or clears (`None`, an empty string counts) the custom
* local-data root; with `migrate`, the current tree moves at the next start.
* Returns the directory that takes effect after a restart. The caller
* restarts the IDE (see `app_restart`).
*/
cachesSetLocalDataDir: (path: string | null, migrate: boolean) => typedError<string, IdeError>(__TAURI_INVOKE("caches_set_local_data_dir", { path, migrate })),
/** Quits through every window's unsaved-changes check, then starts again. */
appRestart: () => __TAURI_INVOKE<void>("app_restart"),
indexStatus: () => typedError<IndexStatus, IdeError>(__TAURI_INVOKE("index_status")),
@@ -5925,18 +5917,6 @@ export type LineMatch = {
textOffset: number,
};
/** Settings › System Settings › Caches: where the local data lives. */
export type LocalDataInfo = {
/** The directory in use by this run. */
current: string,
/** The OS default (what clearing the custom directory returns to). */
defaultDir: string,
/** The custom root stored by Settings, if any (`current` is `<root>/<identifier>`). */
customRoot: string | null,
/** `OPENIDEA_LOCAL_DATA_DIR` is set and wins over everything Settings stores. */
envOverride: boolean,
};
/** What a local definition declares (nvim-treesitter `@local.definition.<kind>`). */
export type LocalKind = "var" | "parameter" | "function" | "type" | "import" | "macro" | "label" |
/** Plain `@local.definition` or a kind without a better mapping. */
-33
Переглянути файл
@@ -135,39 +135,6 @@ it("shows Allow All inside the mode popup when both options exist", () => {
expect(onChange).toHaveBeenNthCalledWith(2, "allow_all", "on");
});
it("shows Codex's collaboration mode next to its approval mode and sends Plan", () => {
const onChange = vi.fn();
render(
<ConfigPickers
config={[
{
id: "mode",
name: "Mode",
category: "mode",
description: null,
current: "workspace-write",
via: "option",
values: [value("read-only", "Read-only"), value("workspace-write", "Workspace access")],
},
{
id: "collaboration_mode",
name: "Collaboration mode",
category: "collaboration_mode",
description: null,
current: "default",
via: "option",
values: [value("default", "Default"), value("plan", "Plan")],
},
]}
onChange={onChange}
/>,
);
expect(screen.getByRole("button", { name: "Collaboration mode" })).toHaveTextContent("Default");
fireEvent.click(screen.getByRole("button", { name: "Collaboration mode" }));
fireEvent.click(screen.getByRole("option", { name: "Plan" }));
expect(onChange).toHaveBeenCalledExactlyOnceWith("collaboration_mode", "plan");
});
it("sends the complete nested model id through ConfigPickers", () => {
const onChange = vi.fn();
render(<ConfigPickers config={[config([value("9router/cmc/Beam")])]} onChange={onChange} />);
-2
Переглянути файл
@@ -205,8 +205,6 @@ describe("agent composer", () => {
const permissions = option("allow_all", ["off", "on"], "off");
permissions.category = "permissions";
expect(mainOptions([...config, permissions]).map((o) => o.id)).toEqual(["model", "mode", permissions.id]);
const collaboration = option("collaboration_mode", ["default", "plan"], "default");
expect(mainOptions([...config, collaboration]).map((o) => o.id)).toEqual(["model", "mode", collaboration.id]);
const approvals = {
...option("openidea.cursor.approval", ["ask", "bypass"], "ask"),
category: "approval",
+2 -2
Переглянути файл
@@ -171,9 +171,9 @@ export function atEdgeLine(text: string, caret: number, dir: -1 | 1): boolean {
return dir < 0 ? !text.slice(0, caret).includes("\n") : !text.slice(caret).includes("\n");
}
/** The settings shown in the composer: model, mode, collaboration mode, permissions, approvals, effort. */
/** The settings shown in the composer: model, mode, permissions, approvals, effort. */
export function mainOptions(config: readonly AgentConfigOption[]): AgentConfigOption[] {
const order = ["model", "mode", "collaboration_mode", "permissions", "approval", "thought_level"];
const order = ["model", "mode", "permissions", "approval", "thought_level"];
return order.flatMap((c) => config.filter((o) => o.category === c).slice(0, 1));
}
-21
Переглянути файл
@@ -55,14 +55,6 @@ const declaration: SettingsDecl = {
properties: [JSON.parse(JSON.stringify(profile))],
actions: [{ title: "Test", command: "test" }],
},
{
id: "defaults",
title: "Defaults",
properties: [
{ key: "enabled", title: "Enabled", type: "boolean", default: false },
{ key: "name", title: "Name", type: "string", default: "" },
],
},
],
};
let dispose: () => void;
@@ -286,16 +278,3 @@ it("indexes string-list enum options", () => {
"documentation",
);
});
it("keeps values that equal the setting's default instead of dropping them", () => {
useProject.setState({
settings: { plugins: { "example.helper": { enabled: true, name: "Custom" } } } as never,
});
openSettings();
render(<Harness page="defaults" />);
fireEvent.click(screen.getByRole("checkbox", { name: "Enabled" }));
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "" } });
expect(stagedValue(useSettingsDialog.getState(), "global", "plugins")).toMatchObject({
"example.helper": { enabled: false, name: "" },
});
});
+1 -3
Переглянути файл
@@ -222,9 +222,7 @@ function SettingControl({ pluginId, s }: { pluginId: string; s: SettingSchema })
set={(v) => {
const valid = accepts(s, v);
setInvalid(!valid);
// Keep the chosen value even when it equals the default: false and ""
// are real plugin settings, not "remove the override".
if (valid) form.setMapEntry("plugins", [pluginId, s.key], v);
if (valid) form.setMapEntry("plugins", [pluginId, s.key], v === s.default ? null : v);
}}
/>
{s.description && <div className={styles.description}>{s.description}</div>}
+8 -19
Переглянути файл
@@ -14,8 +14,6 @@ import {
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useProject } from "@/platform/project";
import { resolveMonoFont } from "@/platform/fonts";
import { Icon, showContextMenu } from "@/ui";
import { spanStyle } from "@/ui/ansi";
import ansi from "@/ui/ansi.module.css";
@@ -30,6 +28,8 @@ import { tx } from "@/platform/i18n";
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions } from "@/ui/consoleFind";
const ROW = 20;
const MARK_COLOR = "var(--bg-search-match)";
const CURRENT_MARK_COLOR = "var(--bg-search-match-current)";
@@ -183,12 +183,6 @@ export function ConsoleView({
const innerRef = useRef<HTMLDivElement>(null);
const lines = model.lines;
const patterns = useWorkflowSettings((s) => s.console.foldPatterns);
// The Run console uses Tools › Terminal › Font (which can follow Editor › Font),
// so the font and fallback configured there apply to process output too.
const terminalFont = useWorkflowSettings((s) => s.terminal);
const editorFont = useProject((s) => s.settings?.editor);
const consoleFont = resolveMonoFont(terminalFont, editorFont);
const rowHeight = Math.max(16, Math.round(consoleFont.size * 1.5));
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set());
const [internalFindOpen, setInternalFindOpen] = useState(false);
const findOpen = controlledFindOpen ?? internalFindOpen;
@@ -225,7 +219,7 @@ export function ConsoleView({
rangeExtractor,
count,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
estimateSize: () => ROW,
overscan: 30,
getItemKey: (i) => {
const r = rows[i];
@@ -237,7 +231,7 @@ export function ConsoleView({
// Soft wrap changes row heights; re-measure everything.
useEffect(() => {
virtualizer.measure();
}, [softWrap, rowHeight, virtualizer]);
}, [softWrap, virtualizer]);
useLayoutEffect(() => {
if (!follow || count === 0) return;
@@ -251,7 +245,7 @@ export function ConsoleView({
const onScroll = () => {
const el = scrollRef.current;
if (!el || programmatic.current) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < ROW;
if (atBottom && selecting) return;
if (atBottom !== follow) onFollowChange(atBottom);
};
@@ -537,11 +531,6 @@ export function ConsoleView({
<div
ref={scrollRef}
className={clsx(styles.console, ansi.palette, softWrap && styles.wrap)}
style={{
fontFamily: consoleFont.family,
fontSize: `${consoleFont.size}px`,
lineHeight: `${rowHeight}px`,
}}
role="log"
aria-live="off"
aria-label={aria["aria-label"] ?? "Console"}
@@ -568,7 +557,7 @@ export function ConsoleView({
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={styles.line}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : rowHeight }}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
{pendingInput}
</div>
@@ -581,7 +570,7 @@ export function ConsoleView({
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={styles.line}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : rowHeight }}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
{foldButton(row.fold, false)}
</div>
@@ -595,7 +584,7 @@ export function ConsoleView({
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={clsx(styles.line, styles[line.kind])}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : rowHeight }}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
<Line line={line} marks={marksByLine.get(row.index)} length={line.text.length} cwd={cwd} />
{row.fold && foldButton(row.fold, true)}
+1 -1
Переглянути файл
@@ -151,7 +151,7 @@
left: 0;
min-width: 100%;
padding: 0 12px;
line-height: inherit;
line-height: 20px;
white-space: pre;
tab-size: 4;
}
+1 -2
Переглянути файл
@@ -172,9 +172,8 @@ const theme = EditorView.baseTheme({
alignItems: "center",
// Spare width stays left of the bookmark, which sits next to the code.
justifyContent: "flex-end",
padding: "0 2px",
},
// Not on the empty spacer, so the column collapses fully in files without bookmarks.
".cm-openidea-bookmarks .cm-gutterElement:not(:empty)": { padding: "0 2px" },
".cm-openidea-bookmark": {
fill: "none",
stroke: "var(--accent)",
+2 -133
Переглянути файл
@@ -10,13 +10,11 @@ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "rea
import clsx from "clsx";
import { shortcutLabel } from "@/platform/keymap";
import { usePlatform } from "@/platform/platformInfo";
import { call, commands, isTauri } from "@/platform/ipc";
import type { LocalDataInfo } from "@/bindings";
import { notifications } from "@/workbench/contributions";
import { isTauri } from "@/platform/ipc";
import { ZOOM_LIMITS } from "@/platform/zoom";
import { SCHEMES as EDITOR_SCHEMES, listSchemes, customSchemes } from "@/platform/colorScheme";
import { formatTimestamp } from "@/platform/dateFormat";
import { Button, Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore";
import { ColorSwatches, FormGroup, NumberInput, Row, SearchLabel, Select, Toggle } from "../components/Form";
import { useLabelMatch, useSetting } from "../components/context";
@@ -468,124 +466,6 @@ function DefaultDirectoryRow() {
);
}
/**
* Settings › System Settings › Caches: where tools, language servers, indexes
* and caches live (the C: problem of #1722). A custom directory takes effect
* after a restart; the previous tree moves along when asked.
*/
function LocalDataDirRow() {
const [info, setInfo] = useState<LocalDataInfo | null>(null);
const [root, setRoot] = useState("");
const [move, setMove] = useState(true);
const [busy, setBusy] = useState(false);
const id = useId();
useEffect(() => {
if (!isTauri()) return;
let live = true;
void call(commands.cachesLocalDataInfo()).then(
(v) => {
if (!live) return;
setInfo(v);
setRoot(v.customRoot ?? "");
},
() => {},
);
return () => {
live = false;
};
}, []);
const browse = async () => {
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: true,
multiple: false,
title: "Local data directory",
defaultPath: root || undefined,
});
const path = Array.isArray(picked) ? picked[0] : picked;
if (path) setRoot(path);
};
const apply = async (next: string | null) => {
setBusy(true);
try {
await call(commands.cachesSetLocalDataDir(next, move));
await commands.appRestart();
} catch (e) {
notifications().notify({
level: "error",
title: "Could not change the local data directory",
body: e instanceof Error ? e.message : String(e),
key: "app.localDataDir",
});
} finally {
setBusy(false);
}
};
if (!isTauri()) {
return (
<p className={styles.note}>
<T>Restart is only available in the desktop app</T>
</p>
);
}
const locked = busy || info?.envOverride === true;
return (
<>
<Row label="Current location">
<Input className={styles.pathInput} value={info?.current ?? ""} readOnly />
</Row>
<Row
label="Custom directory"
htmlFor={id}
hint="Tools, language servers, indexes and caches live here. Empty means the OS default. Changing it restarts the IDE."
>
<Input
id={id}
className={styles.pathInput}
value={root}
placeholder="System default"
disabled={locked}
onChange={(e) => setRoot(e.target.value)}
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
aria-label="Browse"
disabled={locked}
onClick={() => void browse()}
/>
</Row>
{info?.envOverride === true && (
<p role="note" className={styles.note}>
<T>The OPENIDEA_LOCAL_DATA_DIR environment variable is set and takes precedence.</T>
</p>
)}
<div className={styles.fontRow}>
<Button
variant="primary"
size="small"
disabled={locked}
onClick={() => void apply(root.trim() ? root.trim() : null)}
>
Apply and Restart
</Button>
<Button
size="small"
disabled={locked || (!root && !info?.customRoot)}
onClick={() => {
setRoot("");
void apply(null);
}}
>
Reset to default
</Button>
<Checkbox label="Move existing data to the new location" checked={move} onChange={setMove} disabled={locked} />
</div>
</>
);
}
function IdleSaveRow() {
const [on, setOn] = useSetting<boolean>("general.saveOnIdle");
const [seconds, setSeconds] = useSetting<number>("general.idleSeconds");
@@ -834,17 +714,6 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
{ kind: "custom", label: "Default directory", component: DefaultDirectoryRow },
],
},
{
title: "Caches",
options: [
{
kind: "custom",
label: "Local data directory",
keywords: ["cache", "local data", "data directory", "C盘", "缓存", "目录"],
component: LocalDataDirRow,
},
],
},
{
title: "Autosave",
options: [
+16 -5
Переглянути файл
@@ -6,7 +6,7 @@
*/
import type { ITheme, Terminal } from "@xterm/xterm";
import { fontWeightOf } from "@/platform/editorSettings";
import { familyFaces, fontsLoaded, monoStack, resolveMonoFont } from "@/platform/fonts";
import { BUNDLED_MONO_FAMILY, familyFaces, fontsLoaded, monoFontFamily, monoStack } from "@/platform/fonts";
import { parseColor, type Rgba } from "@/platform/cssColor";
export { parseColor, type Rgba };
@@ -191,6 +191,11 @@ function withNerdFonts(family: string): string {
return `${family.replace(/,\s*monospace\s*$/i, "")}, ${fallbacks}, monospace`;
}
function cssNumber(name: string): number {
if (typeof document === "undefined") return NaN;
return parseFloat(getComputedStyle(document.documentElement).getPropertyValue(name));
}
/**
* Font from Settings › Tools › Terminal: the editor font (Editor › Font, with its
* fallback and Typography Settings weights) or the terminal's own family / size. The line
@@ -206,17 +211,23 @@ export function terminalFont(
editor: EditorFont | undefined,
base = monoStack(),
): TerminalFont {
let family: string, fallback: string, size: number;
let weight = 400,
boldWeight = 700;
const chosen = resolveMonoFont(t, editor, base);
if (t.useEditorFont) {
if (!t.useEditorFont) {
family = t.fontFamily;
fallback = t.fallbackFontFamily;
size = t.fontSize;
} else {
family = editor?.fontFamily || BUNDLED_MONO_FAMILY;
fallback = t.fallbackFontFamily.trim() || editor?.fallbackFont || "";
size = editor?.fontSize || cssNumber("--fs-code") || 13;
weight = fontWeightOf(editor?.fontWeight, weight);
boldWeight = fontWeightOf(editor?.boldFontWeight, boldWeight);
}
const size = chosen.size;
const letterSpacing = Math.round((t.columnWidth - 1) * size * 0.6 * 10) / 10;
return {
family: withNerdFonts(chosen.family),
family: withNerdFonts(monoFontFamily(family, fallback, base)),
size,
lineHeight: t.lineHeight,
letterSpacing,
-19
Переглянути файл
@@ -1,19 +0,0 @@
<scheme name="WebStorm Ember" version="142" parent_scheme="Darcula">
<attributes>
<option name="TS.KEYWORD"><value><option name="FOREGROUND" value="e89c45"/></value></option>
<option name="DEFAULT_KEYWORD"><value><option name="FOREGROUND" value="cc7832"/></value></option>
<option name="JS.KEYWORD"><value><option name="FOREGROUND" value="d9a35b"/></value></option>
<option name="DEFAULT_SEMICOLON"><value><option name="FOREGROUND" value="d56075"/></value></option>
<option name="DEFAULT_STRING"><value><option name="FOREGROUND" value="8bb770"/></value></option>
<option name="JS.GLOBAL_FUNCTION"><value><option name="FOREGROUND" value="6dacff"/></value></option>
<option name="JS.INSTANCE_MEMBER_FUNCTION"><value><option name="FOREGROUND" value="e06c75"/></value></option>
<option name="JS.GLOBAL_VARIABLE"><value><option name="FOREGROUND" value="c678dd"/></value></option>
<option name="JS.EXPORTED.VARIABLE"><value><option name="FOREGROUND" value="b07ad8"/></value></option>
<option name="JS.INSTANCE_MEMBER_VARIABLE"><value><option name="FOREGROUND" value="cd87ea"/></value></option>
<option name="TS.INSTANCE_MEMBER_VARIABLE"><value><option name="FOREGROUND" value="ef8a80"/></value></option>
<option name="TS.STATIC_MEMBER_VARIABLE"><value><option name="FOREGROUND" value="c580ea"/></value></option>
<option name="JS.INTERFACE"><value><option name="FOREGROUND" value="6fa4e5"/></value></option>
<option name="TS.DOC_TAG_VALUE"><value><option name="FOREGROUND" value="e5c07b"/></value></option>
<option name="JS.REGEXP"><value><option name="FOREGROUND" value="56b6c2"/></value></option>
</attributes>
</scheme>
+1 -41
Переглянути файл
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { monoFontFamily, quoteFontFamily, resolveMonoFont, uiFontFamily } from "./fonts";
import { monoFontFamily, quoteFontFamily, uiFontFamily } from "./fonts";
const BASE = "var(--font-mono)";
@@ -47,46 +47,6 @@ describe("uiFontFamily", () => {
});
});
describe("resolveMonoFont", () => {
const terminal = {
useEditorFont: true,
fontFamily: "JetBrains Mono",
fallbackFontFamily: "",
fontSize: 13,
};
const editor = { fontFamily: "Fira Code", fallbackFont: "PingFang SC", fontSize: 16 };
it("follows Editor › Font while Use editor font is on", () => {
expect(resolveMonoFont(terminal, editor, BASE)).toEqual({
family: `"Fira Code", "PingFang SC", ${BASE}`,
size: 16,
});
});
it("uses the terminal's own family, fallback and size when it is off", () => {
expect(
resolveMonoFont(
{ ...terminal, useEditorFont: false, fontFamily: "Hack", fallbackFontFamily: "Maple Mono NF CN", fontSize: 14 },
editor,
BASE,
),
).toEqual({ family: `"Hack", "Maple Mono NF CN", ${BASE}`, size: 14 });
});
it("prefers the terminal's fallback over the editor's while following the editor font", () => {
expect(resolveMonoFont({ ...terminal, fallbackFontFamily: "Maple Mono NF CN" }, editor, BASE).family).toBe(
`"Fira Code", "Maple Mono NF CN", ${BASE}`,
);
});
it("falls back to the bundled family and 13px without settings", () => {
expect(resolveMonoFont(undefined, undefined, BASE)).toEqual({
family: `"JetBrains Mono", "JetBrains Mono Variable", ${BASE}`,
size: 13,
});
});
});
describe("macOS user font resolver", () => {
afterEach(() => {
vi.unstubAllGlobals();
-34
Переглянути файл
@@ -126,40 +126,6 @@ export function monoFontFamily(family: string | undefined, fallback = "", base =
return [...head, ...(fb ? familyFaces(fb) : []), base].join(", ");
}
/** Tools › Terminal › Font, shared by the terminal and the Run console. */
export interface MonoFontChoice {
useEditorFont: boolean;
fontFamily: string;
fallbackFontFamily: string;
fontSize: number;
}
export interface EditorFontChoice {
fontFamily?: string | null;
fallbackFont?: string | null;
fontSize?: number | null;
}
/**
* The family and size Settings › Tools › Terminal resolves against Editor › Font.
* The console uses the terminal's choice too, so the font picker there controls
* both the terminal and Run output; `base` is `var(--font-mono)` for CSS consumers
* and the resolved stack for canvas consumers (xterm).
*/
export function resolveMonoFont(
choice: MonoFontChoice | undefined,
editor: EditorFontChoice | undefined,
base = "var(--font-mono)",
): { family: string; size: number } {
const useEditor = choice?.useEditorFont !== false;
const family = (useEditor ? editor?.fontFamily : choice?.fontFamily) || BUNDLED_MONO_FAMILY;
const fallback = useEditor
? choice?.fallbackFontFamily.trim() || editor?.fallbackFont || ""
: choice?.fallbackFontFamily.trim() || "";
const size = (useEditor ? editor?.fontSize : choice?.fontSize) || 13;
return { family: monoFontFamily(family, fallback, base), size };
}
/**
* Resolves once the faces `family` uses at `size` are loaded, so code that measures
* glyphs (xterm's cell size) measures the real font rather than the swap fallback.
-42
Переглянути файл
@@ -135,48 +135,6 @@ describe(".icls schemes", () => {
expect([applied, skipped]).toEqual([16, 2]);
});
it("maps WebStorm's JavaScript and TypeScript keys, TypeScript first, then JavaScript, then defaults", () => {
const { scheme, applied, skipped } = parseIcls(fixture("webstorm-darcula"));
const fg = (id: string) => scheme.attributes[id]?.foreground;
expect(scheme.attributes.keyword).toEqual({ foreground: "#e89c45", bold: false, italic: false });
expect(fg("string")).toBe("#8bb770");
// A global function's key colours its calls too, and wins over the member function key.
expect(fg("functionDeclaration")).toBe("#6dacff");
expect(fg("functionCall")).toBe("#6dacff");
expect(fg("globalVariable")).toBe("#c678dd");
expect(fg("field")).toBe("#ef8a80");
expect(fg("constant")).toBe("#c580ea");
expect(fg("className")).toBe("#6fa4e5");
expect(fg("docCommentTagValue")).toBe("#e5c07b");
expect(fg("regexp")).toBe("#56b6c2");
// Superseded keys and DEFAULT_SEMICOLON are counted as skipped.
expect(scheme.attributes.operator).toBeUndefined();
expect([applied, skipped]).toEqual([10, 6]);
});
it("maps punctuation only from keys that do not spread one colour to other punctuation", () => {
const opt = (name: string, c: string) =>
`<option name="${name}"><value><option name="FOREGROUND" value="${c}"/></value></option>`;
const parse = (...options: string[]) =>
parseIcls(`<scheme name="P"><attributes>${options.join("")}</attributes></scheme>`);
const separators = parse(
opt("DEFAULT_SEMICOLON", "d56075"),
opt("DEFAULT_COMMA", "111111"),
opt("DEFAULT_DOT", "222222"),
);
expect(separators.scheme.attributes.operator).toBeUndefined();
expect([separators.applied, separators.skipped]).toEqual([0, 3]);
const braces = parse(opt("DEFAULT_BRACES", "333333"), opt("DEFAULT_PARENTHS", "444444"));
expect(braces.scheme.attributes.operator?.foreground).toBe("#444444");
const sign = parse(
opt("DEFAULT_PARENTHS", "444444"),
opt("DEFAULT_SEMICOLON", "d56075"),
opt("DEFAULT_OPERATION_SIGN", "555555"),
);
expect(sign.scheme.attributes.operator?.foreground).toBe("#555555");
expect([sign.applied, sign.skipped]).toEqual([1, 2]);
});
it.each([
"",
"<scheme",
+60 -120
Переглянути файл
@@ -40,9 +40,12 @@ const ATTRIBUTE_NAMES: Record<string, string> = {
DEFAULT_DOC_MARKUP: "docCommentMarkup",
DEFAULT_METADATA: "metadata",
DEFAULT_OPERATION_SIGN: "operator",
DEFAULT_PARENTHS: "operator",
DEFAULT_BRACES: "operator",
DEFAULT_BRACKETS: "operator",
DEFAULT_PARENTHS: "operator",
DEFAULT_DOT: "operator",
DEFAULT_COMMA: "operator",
DEFAULT_SEMICOLON: "operator",
DEFAULT_MARKUP_TAG: "markupTag",
DEFAULT_MARKUP_ATTRIBUTE: "markupAttribute",
BAD_CHARACTER: "invalid",
@@ -172,49 +175,6 @@ Object.assign(ATTRIBUTE_NAMES, {
"PY.OPERATION_SIGN": "operator",
});
// WebStorm's JavaScript keys; TypeScript repeats them with a TS. prefix. Language Defaults serve both.
const SCRIPT_KEYS: Record<string, string> = {
KEYWORD: "keyword",
NUMBER: "number",
STRING: "string",
VALID_STRING_ESCAPE: "escape",
INVALID_STRING_ESCAPE: "invalid",
BADCHARACTER: "invalid",
REGEXP: "regexp",
LINE_COMMENT: "lineComment",
BLOCK_COMMENT: "lineComment",
DOC_COMMENT: "docComment",
DOC_TAG: "docCommentTag",
DOC_TAG_VALUE: "docCommentTagValue",
DOC_MARKUP: "docCommentMarkup",
LOCAL_VARIABLE: "identifier",
PARAMETER: "parameter",
GLOBAL_VARIABLE: "globalVariable",
EXPORTED_VARIABLE: "globalVariable",
"EXPORTED.VARIABLE": "globalVariable",
INSTANCE_MEMBER_VARIABLE: "field",
STATIC_MEMBER_VARIABLE: "constant",
GLOBAL_FUNCTION: "functionDeclaration",
LOCAL_FUNCTION: "functionDeclaration",
EXPORTED_FUNCTION: "functionDeclaration",
INSTANCE_MEMBER_FUNCTION: "functionDeclaration",
STATIC_MEMBER_FUNCTION: "functionDeclaration",
CLASS: "className",
EXPORTED_CLASS: "className",
INTERFACE: "className",
"TYPE.ALIAS": "className",
TYPE_PARAMETER: "className",
DECORATOR: "metadata",
OPERATION_SIGN: "operator",
};
// WebStorm colours a function's calls with its declaration's key.
const ALSO_CALLS = new Set<string>();
for (const prefix of ["JS.", "TS."])
for (const [name, id] of Object.entries(SCRIPT_KEYS)) {
ATTRIBUTE_NAMES[prefix + name] = id;
if (id === "functionDeclaration") ALSO_CALLS.add(prefix + name);
}
const COLOR_NAMES: Record<string, [string, "foreground" | "background"]> = {
CARET_COLOR: ["caret", "foreground"],
CARET_ROW_COLOR: ["caretRow", "background"],
@@ -233,23 +193,6 @@ const COLOR_NAMES: Record<string, [string, "foreground" | "background"]> = {
for (const attr of ATTRIBUTES)
if (!Object.values(ATTRIBUTE_NAMES).includes(attr.id)) ATTRIBUTE_NAMES[`OPENIDEA_${attr.id}`] = attr.id;
// One key colours an attribute, as IntelliJ shows it in the file the user sees: an explicit value replaces
// its fallback's. TypeScript keys win over JavaScript (TS.* falls back to JS.*), then Python, then the
// shared defaults; within a language, the key listed first above. DEFAULT_DOT, _COMMA and _SEMICOLON are
// not mapped: the one punctuation colour here would spread them to every brace and operator.
const KEY_ORDER = new Map(Object.keys(ATTRIBUTE_NAMES).map((name, i) => [name, i]));
const LANGUAGE_PRECEDENCE = ["TS.", "JS.", "PY."];
const precedence = (name: string) => {
const language = LANGUAGE_PRECEDENCE.findIndex((prefix) => name.startsWith(prefix));
return (language < 0 ? LANGUAGE_PRECEDENCE.length : language) * KEY_ORDER.size + KEY_ORDER.get(name)!;
};
const targets = (name: string): string[] =>
Object.hasOwn(ATTRIBUTE_NAMES, name)
? ALSO_CALLS.has(name)
? [ATTRIBUTE_NAMES[name]!, "functionCall"]
: [ATTRIBUTE_NAMES[name]!]
: [];
// IntelliJ's EFFECT_TYPE codes. The box (0) and faded text (6) have no counterpart here; the bold and the
// bold dotted line (4, 5) become an underline.
const EFFECTS: Record<string, TextAttributesOverride["effect"]> = {
@@ -268,10 +211,14 @@ const children = (el: Element, tag: string) => Array.from(el.children).filter((e
const color = (v: string | null): string | undefined =>
v && /^[0-9a-f]{1,6}$/i.test(v) ? `#${v.padStart(6, "0").toLowerCase()}` : undefined;
function attributeEntries(section: Element): Element[] {
function orderedAttributes(section: Element): Element[] {
const entries = children(section, "option");
if (!entries.some((e) => e.getAttribute("name")?.startsWith("PY."))) return entries;
return entries.filter((e) => e.getAttribute("name") !== "DEFAULT_BLOCK_COMMENT");
const priority = (e: Element) =>
e.getAttribute("name")?.startsWith("PY.") ? 2 : e.getAttribute("name")?.startsWith("DEFAULT_") ? 1 : 0;
return entries
.filter((e) => e.getAttribute("name") !== "DEFAULT_BLOCK_COMMENT")
.sort((a, b) => priority(a) - priority(b));
}
export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: number; skipped: number } {
@@ -301,15 +248,6 @@ export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: nu
}
return undefined;
};
const winners = new Map<string, Element>();
for (const entry of children(root, "attributes").flatMap(attributeEntries)) {
const name = entry.getAttribute("name") ?? "";
if (!inheritedValue(entry)) continue;
for (const id of targets(name)) {
const current = winners.get(id);
if (!current || precedence(name) < precedence(current.getAttribute("name")!)) winners.set(id, entry);
}
}
let applied = 0;
let skipped = 0;
const merge = (id: string, value: TextAttributesOverride) => {
@@ -327,58 +265,60 @@ export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: nu
} else skipped++;
}
if (section.tagName === "attributes")
for (const entry of attributeEntries(section)) {
for (const entry of orderedAttributes(section)) {
const name = entry.getAttribute("name") ?? "";
const value = inheritedValue(entry);
const ids = targets(name).filter((id) => winners.get(id) === entry);
if (!value || !ids.length) {
const id = Object.hasOwn(ATTRIBUTE_NAMES, name) ? ATTRIBUTE_NAMES[name] : undefined;
if (!id) {
skipped++;
continue;
}
for (const id of ids) {
const attr = ATTRIBUTES.find((a) => a.id === id)!;
// An explicit TextAttributes value defaults to Font.PLAIN, not the last alias's style.
if (attr.token) merge(id, { bold: false, italic: false });
if (name.startsWith("PY.")) merge(id, { effect: "none" });
// IntelliJ draws no effect without an effect colour.
const drawn = children(value, "option").some(
(o) => o.getAttribute("name") === "EFFECT_COLOR" && color(o.getAttribute("value")),
);
for (const option of children(value, "option")) {
const name = option.getAttribute("name");
const raw = option.getAttribute("value");
const c = color(raw);
const field =
name === "FOREGROUND"
? "foreground"
: name === "BACKGROUND"
? "background"
: name === "EFFECT_COLOR"
? "effectColor"
: undefined;
const supported =
field === "foreground"
? !!attr.fg
: field === "background"
? !!attr.bg || !!attr.token
: !!attr.effect || !!attr.token;
if (field && c && supported) {
merge(id, { [field]: c });
applied++;
} else if (name === "FONT_TYPE" && attr.token && raw && /^[0-3]$/.test(raw)) {
merge(id, { bold: !!(Number(raw) & 1), italic: !!(Number(raw) & 2) });
applied++;
} else if (
name === "EFFECT_TYPE" &&
drawn &&
(attr.token || attr.effect) &&
raw &&
Object.hasOwn(EFFECTS, raw)
) {
merge(id, { effect: EFFECTS[raw] });
applied++;
} else skipped++;
}
const value = inheritedValue(entry);
if (!value) {
skipped++;
continue;
}
const attr = ATTRIBUTES.find((a) => a.id === id)!;
// An explicit TextAttributes value defaults to Font.PLAIN, not the last alias's style.
if (attr.token) merge(id, { bold: false, italic: false });
if (name.startsWith("PY.")) merge(id, { effect: "none" });
// IntelliJ draws no effect without an effect colour.
const drawn = children(value, "option").some(
(o) => o.getAttribute("name") === "EFFECT_COLOR" && color(o.getAttribute("value")),
);
for (const option of children(value, "option")) {
const name = option.getAttribute("name");
const raw = option.getAttribute("value");
const c = color(raw);
const field =
name === "FOREGROUND"
? "foreground"
: name === "BACKGROUND"
? "background"
: name === "EFFECT_COLOR"
? "effectColor"
: undefined;
const supported =
field === "foreground"
? !!attr.fg
: field === "background"
? !!attr.bg || !!attr.token
: !!attr.effect || !!attr.token;
if (field && c && supported) {
merge(id, { [field]: c });
applied++;
} else if (name === "FONT_TYPE" && attr.token && raw && /^[0-3]$/.test(raw)) {
merge(id, { bold: !!(Number(raw) & 1), italic: !!(Number(raw) & 2) });
applied++;
} else if (
name === "EFFECT_TYPE" &&
drawn &&
(attr.token || attr.effect) &&
raw &&
Object.hasOwn(EFFECTS, raw)
) {
merge(id, { effect: EFFECTS[raw] });
applied++;
} else skipped++;
}
}
}
+1 -11
Переглянути файл
@@ -7321,15 +7321,5 @@
"Imported {count} MCP servers from the project.": "已从项目导入 {count} 个 MCP 服务器。",
"Show branch and incoming/outgoing commits in the Project view": "在项目视图中显示分支及传入/传出提交",
"Up to date with {upstream}": "已与 {upstream} 同步",
"Highlight occurrences of selected text": "高亮所选文本的匹配项",
"Caches": "缓存",
"Local data directory": "本地数据目录",
"Current location": "当前位置",
"Custom directory": "自定义目录",
"Tools, language servers, indexes and caches live here. Empty means the OS default. Changing it restarts the IDE.": "工具、语言服务器、索引和缓存存放在此。留空则使用系统默认位置。修改后将重启 IDE。",
"The OPENIDEA_LOCAL_DATA_DIR environment variable is set and takes precedence.": "已设置 OPENIDEA_LOCAL_DATA_DIR 环境变量,其优先级更高。",
"Apply and Restart": "应用并重启",
"Reset to default": "恢复默认",
"Move existing data to the new location": "将现有数据移动到新位置",
"Could not change the local data directory": "无法更改本地数据目录"
"Highlight occurrences of selected text": "高亮所选文本的匹配项"
}