дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-11 12:15:41 +02:00
Порівняти коміти
6
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
228772f094 | ||
|
|
0abaf6c777 | ||
|
|
0d9b08ac8c | ||
|
|
e54580265d | ||
|
|
4fa700b337 | ||
|
|
173d5edcdc |
No files matched your search
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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/*");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, ¤t, &next).map_err(ide_core::IdeError::internal)?;
|
||||
}
|
||||
Ok(next.display().to_string())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "" },
|
||||
});
|
||||
});
|
||||
@@ -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>}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
left: 0;
|
||||
min-width: 100%;
|
||||
padding: 0 12px;
|
||||
line-height: 20px;
|
||||
line-height: inherit;
|
||||
white-space: pre;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "无法更改本地数据目录"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача