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

..
Автор SHA1 Повідомлення Дата
arookieofc 228772f094 feat(settings): custom local data directory with one-click migration (#1722) 2026-11-11 15:41:23 +08:00
Charlie.Weiтаcharli117 0abaf6c777 fix(plugins): keep plugin settings that match their defaults (#2619)
Choosing a plugin setting's default value deleted the override, so in a project the global value showed through instead of the user's choice. The setting control now always writes the chosen value.

Co-authored-by: charli117 <[email protected]>
2026-11-11 14:45:40 +08:00
LU XIANGRUIтаwildcatDownstairs 0d9b08ac8c fix(color-scheme): import WebStorm JavaScript and TypeScript colours from .icls (#2634)
WebStorm stores JavaScript and TypeScript colours under JS.* / TS.* keys, which the .icls importer did not map, and DEFAULT_SEMICOLON / _COMMA / _DOT all mapped to Braces and Operators, so a scheme recolouring semicolons painted every brace and paren. JS.* and TS.* now map onto the Language Defaults attributes with TS > JS > PY > defaults precedence, and the separator keys are no longer mapped.

Co-authored-by: wildcatDownstairs <[email protected]>
2026-11-11 14:45:32 +08:00
Maskerтаzed1995 e54580265d fix(agents): show Codex ACP collaboration mode in the composer (#2631)
codex-acp reports its Default/Plan choice as a collaboration_mode config option, which the composer dropped because it only rendered model, mode, permissions, approval and thought_level. collaboration_mode is now one of the composer's ordered settings, so a Codex session can switch to Plan.

Co-authored-by: zed1995 <[email protected]>
2026-11-11 14:45:24 +08:00
LU XIANGRUIтаwildcatDownstairs 4fa700b337 fix(editor): collapse the bookmark gutter fully in files without bookmarks (#2632)
The bookmark gutter's 2px side padding applied to the empty spacer element too, so the column kept 4px in files without bookmarks. The padding now applies only to elements that hold a marker, so the column is 0px until the file has a bookmark.

Co-authored-by: wildcatDownstairs <[email protected]>
2026-11-11 14:45:16 +08:00
DrPepperтаmakise2060 173d5edcdc fix(run): render the Run console with the terminal font (#2615)
The Run console hard-coded the --font-mono stack and --fs-code, so Settings › Tools › Terminal › Font (family, fallback, size, or Use editor font) never reached process output. resolveMonoFont() now resolves the terminal font choice for both xterm and the DOM console, and the console's virtual row height follows the font size (round(size × 1.5), 20px at the default 13px) so rows don't overlap.

Co-authored-by: makise2060 <[email protected]>
2026-11-11 14:43:59 +08:00
26 змінених файлів з 991 додано та 350 видалено

No files matched your search

+320 -26
Переглянути файл
@@ -9,6 +9,14 @@ 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
@@ -17,6 +25,203 @@ 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());
@@ -25,10 +230,14 @@ 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!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
.map_err(|error| format!("{what}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
@@ -77,6 +286,11 @@ 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",
@@ -118,31 +332,7 @@ fn migrate_entries(data: &Path, local: &Path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
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");
}
}
entries
}
/// Returns true when a cross-volume copy is needed.
@@ -597,4 +787,108 @@ 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:?}");
}
}
}
+5 -84
Переглянути файл
@@ -1,14 +1,13 @@
//! Deployment over SFTP or local folders, and SSH run targets.
//! Shared by IPC commands and project save/delete hooks.
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 crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport};
use crate::mapping::{Excludes, Mappings, ResolvedMapping};
use crate::target::{RemoteRuns, RemoteStart, SyncFn};
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};
@@ -360,29 +359,10 @@ 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() };
@@ -397,7 +377,6 @@ 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 {
@@ -408,17 +387,7 @@ impl RemoteService {
ide_core::error::ErrorKind::Io,
format!("upload of {} failed: {}", e.path, e.message),
)),
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(())
}
None => Ok(()),
}
})
}))
@@ -427,58 +396,10 @@ 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()
}
+1 -147
Переглянути файл
@@ -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, PathBuf};
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -118,105 +118,6 @@ 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,
@@ -480,51 +381,4 @@ 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
Переглянути файл
@@ -0,0 +1,83 @@
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,3 +210,15 @@ 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,8 +1,12 @@
//! 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).
@@ -26,3 +30,52 @@ 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,6 +217,8 @@ 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,
+3 -1
Переглянути файл
@@ -90,7 +90,9 @@ pub fn run() {
linux_webkit::configure();
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
let local_data_dir = ide_host::data_dirs::local_data_dir(&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);
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
match &local_data_dir {
Ok(data) => {
+5
Переглянути файл
@@ -75,6 +75,11 @@ 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,6 +391,14 @@ 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")),
@@ -5917,6 +5925,18 @@ 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,6 +135,39 @@ 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,6 +205,8 @@ 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, permissions, approvals, effort. */
/** The settings shown in the composer: model, mode, collaboration mode, permissions, approvals, effort. */
export function mainOptions(config: readonly AgentConfigOption[]): AgentConfigOption[] {
const order = ["model", "mode", "permissions", "approval", "thought_level"];
const order = ["model", "mode", "collaboration_mode", "permissions", "approval", "thought_level"];
return order.flatMap((c) => config.filter((o) => o.category === c).slice(0, 1));
}
+21
Переглянути файл
@@ -55,6 +55,14 @@ 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;
@@ -278,3 +286,16 @@ 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: "" },
});
});
+3 -1
Переглянути файл
@@ -222,7 +222,9 @@ function SettingControl({ pluginId, s }: { pluginId: string; s: SettingSchema })
set={(v) => {
const valid = accepts(s, v);
setInvalid(!valid);
if (valid) form.setMapEntry("plugins", [pluginId, s.key], v === s.default ? null : v);
// 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);
}}
/>
{s.description && <div className={styles.description}>{s.description}</div>}
+19 -8
Переглянути файл
@@ -14,6 +14,8 @@ 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";
@@ -28,8 +30,6 @@ 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,6 +183,12 @@ 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;
@@ -219,7 +225,7 @@ export function ConsoleView({
rangeExtractor,
count,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW,
estimateSize: () => rowHeight,
overscan: 30,
getItemKey: (i) => {
const r = rows[i];
@@ -231,7 +237,7 @@ export function ConsoleView({
// Soft wrap changes row heights; re-measure everything.
useEffect(() => {
virtualizer.measure();
}, [softWrap, virtualizer]);
}, [softWrap, rowHeight, virtualizer]);
useLayoutEffect(() => {
if (!follow || count === 0) return;
@@ -245,7 +251,7 @@ export function ConsoleView({
const onScroll = () => {
const el = scrollRef.current;
if (!el || programmatic.current) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < ROW;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight;
if (atBottom && selecting) return;
if (atBottom !== follow) onFollowChange(atBottom);
};
@@ -531,6 +537,11 @@ 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"}
@@ -557,7 +568,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 : ROW }}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : rowHeight }}
>
{pendingInput}
</div>
@@ -570,7 +581,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 : ROW }}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : rowHeight }}
>
{foldButton(row.fold, false)}
</div>
@@ -584,7 +595,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 : ROW }}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : rowHeight }}
>
<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: 20px;
line-height: inherit;
white-space: pre;
tab-size: 4;
}
+2 -1
Переглянути файл
@@ -172,8 +172,9 @@ 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)",
+133 -2
Переглянути файл
@@ -10,11 +10,13 @@ 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 { isTauri } from "@/platform/ipc";
import { call, commands, isTauri } from "@/platform/ipc";
import type { LocalDataInfo } from "@/bindings";
import { notifications } from "@/workbench/contributions";
import { ZOOM_LIMITS } from "@/platform/zoom";
import { SCHEMES as EDITOR_SCHEMES, listSchemes, customSchemes } from "@/platform/colorScheme";
import { formatTimestamp } from "@/platform/dateFormat";
import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
import { Button, 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";
@@ -466,6 +468,124 @@ 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");
@@ -714,6 +834,17 @@ 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: [
+5 -16
Переглянути файл
@@ -6,7 +6,7 @@
*/
import type { ITheme, Terminal } from "@xterm/xterm";
import { fontWeightOf } from "@/platform/editorSettings";
import { BUNDLED_MONO_FAMILY, familyFaces, fontsLoaded, monoFontFamily, monoStack } from "@/platform/fonts";
import { familyFaces, fontsLoaded, monoStack, resolveMonoFont } from "@/platform/fonts";
import { parseColor, type Rgba } from "@/platform/cssColor";
export { parseColor, type Rgba };
@@ -191,11 +191,6 @@ 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
@@ -211,23 +206,17 @@ export function terminalFont(
editor: EditorFont | undefined,
base = monoStack(),
): TerminalFont {
let family: string, fallback: string, size: number;
let weight = 400,
boldWeight = 700;
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;
const chosen = resolveMonoFont(t, editor, base);
if (t.useEditorFont) {
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(monoFontFamily(family, fallback, base)),
family: withNerdFonts(chosen.family),
size,
lineHeight: t.lineHeight,
letterSpacing,
+19
Переглянути файл
@@ -0,0 +1,19 @@
<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>
+41 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { monoFontFamily, quoteFontFamily, uiFontFamily } from "./fonts";
import { monoFontFamily, quoteFontFamily, resolveMonoFont, uiFontFamily } from "./fonts";
const BASE = "var(--font-mono)";
@@ -47,6 +47,46 @@ 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,6 +126,40 @@ 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,6 +135,48 @@ 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",
+119 -59
Переглянути файл
@@ -40,12 +40,9 @@ 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",
@@ -175,6 +172,49 @@ 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"],
@@ -193,6 +233,23 @@ 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"]> = {
@@ -211,14 +268,10 @@ 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 orderedAttributes(section: Element): Element[] {
function attributeEntries(section: Element): Element[] {
const entries = children(section, "option");
if (!entries.some((e) => e.getAttribute("name")?.startsWith("PY."))) return entries;
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));
return entries.filter((e) => e.getAttribute("name") !== "DEFAULT_BLOCK_COMMENT");
}
export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: number; skipped: number } {
@@ -248,6 +301,15 @@ 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) => {
@@ -265,60 +327,58 @@ export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: nu
} else skipped++;
}
if (section.tagName === "attributes")
for (const entry of orderedAttributes(section)) {
for (const entry of attributeEntries(section)) {
const name = entry.getAttribute("name") ?? "";
const id = Object.hasOwn(ATTRIBUTE_NAMES, name) ? ATTRIBUTE_NAMES[name] : undefined;
if (!id) {
skipped++;
continue;
}
const value = inheritedValue(entry);
if (!value) {
const ids = targets(name).filter((id) => winners.get(id) === entry);
if (!value || !ids.length) {
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++;
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++;
}
}
}
}
+11 -1
Переглянути файл
@@ -7321,5 +7321,15 @@
"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": "高亮所选文本的匹配项"
"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": "无法更改本地数据目录"
}