дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-12 12:45:39 +02:00
Порівняти коміти
2
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
5df2c763a4 | ||
|
|
6b32100980 |
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,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())
|
||||
}
|
||||
@@ -224,6 +224,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() {
|
||||
|
||||
@@ -406,6 +406,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")),
|
||||
@@ -6473,6 +6481,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. */
|
||||
|
||||
@@ -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";
|
||||
@@ -467,6 +469,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");
|
||||
@@ -715,6 +835,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: [
|
||||
|
||||
@@ -7673,6 +7673,16 @@
|
||||
"Featured": "精选",
|
||||
"All Plugins": "全部插件",
|
||||
"Examples": "示例插件",
|
||||
"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": "无法更改本地数据目录"
|
||||
"Settings › Tools › SSH Configurations": "设置 › 工具 › SSH 配置",
|
||||
"Edit SSH Configurations": "编辑 SSH 配置",
|
||||
"Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication.": "通过 SSH 连接 Docker 使用系统 ssh:请选择认证方式为“OpenSSH 配置和身份验证代理”的 SSH 配置。"
|
||||
|
||||
Reference in new issue
Заблокувати користувача