feat(data): move rebuildable local data with OPENIDEA_LOCAL_DATA_DIR (#2373)

- The local data directory (tools, language servers, index caches) is resolved in one place, ide_host::data_dirs::local_data_dir
- The variable must be an absolute path; a missing directory is created, and an unwritable one exits with an error instead of falling back to the system default
- Config and log directories still follow the system
- Add docs/data-directories.md

Co-authored-by: LU XIANGRUI <[email protected]>
Цей коміт міститься в:
LU XIANGRUIтаLU XIANGRUI authored and GitHub committed 2026-11-09 23:01:11 +08:00
1 джерело 16bf9b53a2
коміт 5f970d97d5
4 files changed
+126 -7

No files matched your search

+79
Переглянути файл
@@ -1,9 +1,39 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// `$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
/// silent fallback to the OS location.
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
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());
};
// The working directory depends on how the IDE was launched.
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
let dir = root.join(identifier);
let probe = dir.join(".openidea-write-check");
fs::create_dir_all(&dir)
.and_then(|()| fs::write(&probe, b""))
.and_then(|()| fs::remove_file(&probe))
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
@@ -276,6 +306,55 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
mod tests {
use super::*;
#[test]
fn local_dir_follows_the_os_unless_the_variable_is_set() {
let os = Some(PathBuf::from("/os-local"));
for var in [None, Some(OsString::new())] {
assert_eq!(
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
Path::new("/os-local/ai.l0ng.openidea")
);
}
assert!(resolve_local(None, None, "id").is_err());
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("missing/nested");
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
assert_eq!(dir, root.join("ai.l0ng.openidea"));
assert!(dir.is_dir());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
// Debug worktrees have their own identifiers and stay apart under one root.
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
}
#[test]
fn local_dir_rejects_relative_and_unwritable_paths() {
for relative in ["local", "./local", "../local"] {
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
}
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("file");
fs::write(&blocker, "").unwrap();
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains("cannot write"), "{error}");
}
#[cfg(unix)]
#[test]
fn local_dir_rejects_an_existing_read_only_directory() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
// Root ignores permission bits.
if fs::write(dir.join("probe"), "").is_err() {
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
}
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
+24
Переглянути файл
@@ -0,0 +1,24 @@
# Data directories
OpenIDEA keeps its rebuildable data, such as downloaded tools, language servers,
JDKs and index caches, in the operating system's local data directory:
- **Windows:** `%LOCALAPPDATA%\ai.l0ng.openidea`
- **macOS:** `~/Library/Application Support/ai.l0ng.openidea`
- **Linux:** `~/.local/share/ai.l0ng.openidea`
To keep this data elsewhere, for example on another drive, set
`OPENIDEA_LOCAL_DATA_DIR` to an absolute path before starting OpenIDEA. The IDE
then uses `<OPENIDEA_LOCAL_DATA_DIR>/ai.l0ng.openidea` and creates it when
missing. If the path is relative or cannot be written, OpenIDEA shows an error
and exits instead of falling back to the default location.
```bat
setx OPENIDEA_LOCAL_DATA_DIR "E:\OpenIDEA"
```
Settings, plugins, scratches and other user data stay in the operating system's
data and configuration directories. Logs stay in their usual location, so they
are still written when the variable is wrong. Existing data is not moved: quit
OpenIDEA and move the old directory's contents to the new location, or let the
IDE download tools and rebuild indexes again.
+22 -5
Переглянути файл
@@ -89,11 +89,15 @@ 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);
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
if let Some(data) = dirs::data_local_dir() {
ide_tools::extend_process_path(&data.join(&context.config().identifier));
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier)));
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier)));
match &local_data_dir {
Ok(data) => {
ide_tools::extend_process_path(data);
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(data));
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(data));
}
Err(error) => tracing::error!("{error}"),
}
if let Some(refresh) = refresh_shell_path {
refresh();
@@ -176,7 +180,20 @@ pub fn run() {
})
.setup(move |app| {
builder.mount_events(app);
let state = AppState::new(app.handle().clone())?;
let local_data_dir = match local_data_dir {
Ok(dir) => dir,
Err(error) => {
use tauri_plugin_dialog::DialogExt;
let handle = app.handle().clone();
app.dialog()
.message(error)
.title("OpenIDEA")
.kind(tauri_plugin_dialog::MessageDialogKind::Error)
.show(move |_| handle.exit(1));
return Ok(());
}
};
let state = AppState::new(app.handle().clone(), local_data_dir)?;
let windows = app.state::<WindowsState>();
if let Ok(cwd) = std::env::current_dir() {
match windows::cli_launch(&std::env::args().collect::<Vec<_>>(), &cwd) {
+1 -2
Переглянути файл
@@ -52,7 +52,7 @@ pub struct Startup {
}
impl AppState {
pub fn new(app: AppHandle) -> Result<Self, Box<dyn std::error::Error>> {
pub fn new(app: AppHandle, local_data_dir: std::path::PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
ide_workspace::process::mark_start();
let process_to_main = ide_workspace::process::before_main();
let process_to_setup = ide_workspace::process::process_age();
@@ -67,7 +67,6 @@ impl AppState {
}
let config_dir = app.path().app_config_dir()?;
let data_dir = app.path().app_data_dir()?;
let local_data_dir = app.path().app_local_data_dir()?;
std::fs::create_dir_all(&config_dir)?;
std::fs::create_dir_all(&data_dir)?;
if let Err(error) = std::fs::create_dir_all(&local_data_dir) {