Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions windows/tauri/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions windows/tauri/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ keyring = { version = "3.6.3", features = ["windows-native"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
sha2 = "0.10"
tauri = { version = "2", features = ["common-controls-v6", "protocol-asset"] }
tauri-plugin-clipboard-manager = "2"
tauri-plugin-deep-link = "2"
Expand Down
3 changes: 3 additions & 0 deletions windows/tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod file_events;
mod host;
mod logging;
mod lsp;
mod maven;
mod memory;
mod platform;
mod run;
Expand Down Expand Up @@ -118,6 +119,8 @@ fn main() {
host::create_app_window,
lsp::lsp_resolve_java_launch,
lsp::lsp_rebuild_java_index,
maven::maven_load_configuration,
maven::maven_write_configuration,
run::run_list_java_sources,
run::run_write_generated,
run::run_write_documents,
Expand Down
250 changes: 250 additions & 0 deletions windows/tauri/src-tauri/src/maven.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
//! Windows persistence for Maven project and machine-local configuration.
//!
//! Portable selections stay below the workspace `.lithe` directory. Maven,
//! JDK, and settings paths are stored only in the application data directory.

use crate::run::atomic_write;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager};

const MAVEN_CONFIGURATION_VERSION: u32 = 1;

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MavenPortableConfiguration {
pub version: u32,
#[serde(default)]
pub selected_profiles: Vec<String>,
#[serde(default)]
pub custom_profiles: Vec<String>,
#[serde(default)]
pub skip_tests: bool,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MavenLocalConfiguration {
pub version: u32,
#[serde(default)]
pub settings_path: Option<String>,
#[serde(default)]
pub maven_executable_path: Option<String>,
#[serde(default)]
pub java_home_path: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MavenStoredConfiguration {
pub portable: Option<MavenPortableConfiguration>,
pub local: Option<MavenLocalConfiguration>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteMavenConfigurationArgs {
pub root: PathBuf,
pub reactor_path: String,
pub configuration: MavenStoredConfiguration,
}

#[tauri::command]
pub fn maven_load_configuration(
app: AppHandle,
root: PathBuf,
reactor_path: String,
) -> Result<MavenStoredConfiguration, String> {
let root = existing_directory(&root)?;
let portable = read_optional::<MavenPortableConfiguration>(&portable_path(&root))?;
let local = read_optional::<MavenLocalConfiguration>(&local_path(&app, &root, &reactor_path)?)?;
validate_versions(portable.as_ref(), local.as_ref())?;
Ok(MavenStoredConfiguration { portable, local })
}

#[tauri::command]
pub fn maven_write_configuration(
app: AppHandle,
args: WriteMavenConfigurationArgs,
) -> Result<(), String> {
let root = existing_directory(&args.root)?;
validate_versions(
args.configuration.portable.as_ref(),
args.configuration.local.as_ref(),
)?;
write_optional(&portable_path(&root), args.configuration.portable.as_ref())?;
write_optional(
&local_path(&app, &root, &args.reactor_path)?,
args.configuration.local.as_ref(),
)
}

fn validate_versions(
portable: Option<&MavenPortableConfiguration>,
local: Option<&MavenLocalConfiguration>,
) -> Result<(), String> {
if portable.is_some_and(|value| value.version != MAVEN_CONFIGURATION_VERSION)
|| local.is_some_and(|value| value.version != MAVEN_CONFIGURATION_VERSION)
{
return Err(
"The Maven configuration was created by an unsupported version of Lithe.".into(),
);
}
Ok(())
}

fn portable_path(root: &Path) -> PathBuf {
root.join(".lithe").join("maven").join("config.json")
}

fn local_path(app: &AppHandle, root: &Path, reactor_path: &str) -> Result<PathBuf, String> {
let app_data = app
.path()
.app_data_dir()
.map_err(|error| error.to_string())?;
let mut digest = Sha256::new();
let identity = storage_identity(&root.to_string_lossy(), reactor_path);
digest.update(identity.as_bytes());
Ok(app_data
.join("maven")
.join(format!("{:x}.json", digest.finalize())))
}

fn storage_identity(workspace_path: &str, reactor_path: &str) -> String {
format!(
"{}\0{}",
workspace_path.to_lowercase(),
reactor_path.replace('\\', "/")
)
}

fn existing_directory(path: &Path) -> Result<PathBuf, String> {
let root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if !root.is_dir() {
return Err("The project directory is unavailable.".into());
}
Ok(root)
}

fn read_optional<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, String> {
if !path.is_file() {
return Ok(None);
}
let contents = fs::read(path).map_err(|_| {
format!(
"Unable to read Maven configuration {}.",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("file")
)
})?;
serde_json::from_slice(&contents).map(Some).map_err(|_| {
format!(
"The Maven configuration in {} is invalid.",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("file")
)
})
}

fn write_optional<T: Serialize>(path: &Path, value: Option<&T>) -> Result<(), String> {
let Some(value) = value else {
if path.is_file() {
fs::remove_file(path).map_err(|error| error.to_string())?;
}
return Ok(());
};
let parent = path
.parent()
.ok_or_else(|| "Maven configuration path has no parent directory.".to_string())?;
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let mut contents = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
contents.push('\n');
atomic_write(path, contents.as_bytes())
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};

fn temp_directory() -> PathBuf {
static NEXT_DIRECTORY_ID: AtomicU64 = AtomicU64::new(1);
let id = NEXT_DIRECTORY_ID.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("lithe-maven-config-{}-{id}", std::process::id()));
fs::create_dir_all(&path).expect("temp directory");
path
}

#[test]
fn portable_configuration_round_trips_without_local_paths() {
let root = temp_directory();
let path = portable_path(&root);
let portable = MavenPortableConfiguration {
version: 1,
selected_profiles: vec!["dev".into(), "qa".into()],
custom_profiles: vec!["qa".into()],
skip_tests: true,
};
write_optional(&path, Some(&portable)).expect("write portable configuration");
let loaded = read_optional::<MavenPortableConfiguration>(&path)
.expect("read portable configuration")
.expect("portable configuration");

assert_eq!(loaded.selected_profiles, ["dev", "qa"]);
assert!(loaded.skip_tests);
let text = fs::read_to_string(&path).expect("portable text");
assert!(!text.contains("settingsPath"));
assert!(!text.contains("mavenExecutablePath"));
fs::remove_dir_all(root).ok();
}

#[test]
fn rejects_unsupported_configuration_versions() {
let portable = MavenPortableConfiguration {
version: 2,
selected_profiles: Vec::new(),
custom_profiles: Vec::new(),
skip_tests: false,
};
assert!(validate_versions(Some(&portable), None)
.unwrap_err()
.contains("unsupported version"));
}

#[test]
fn windows_storage_identity_matches_shared_contract() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../shared/fixtures/maven/platform-contract-v1.json"
)))
.expect("Maven platform contract fixture");
let cases = fixture["storageIdentityCases"]
.as_array()
.expect("storage identity cases");
let windows_cases: Vec<_> = cases
.iter()
.filter(|item| item["platform"] == "windows")
.collect();

assert!(
!windows_cases.is_empty(),
"Windows fixture case is required"
);
for item in windows_cases {
assert_eq!(
storage_identity(
item["workspacePath"].as_str().expect("workspace path"),
item["reactorPath"].as_str().expect("reactor path"),
),
item["expectedIdentity"]
.as_str()
.expect("expected identity")
);
}
}
}
2 changes: 1 addition & 1 deletion windows/tauri/src-tauri/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ fn validate_write_target(root: &Path, target: &Path) -> Result<(), String> {
Ok(())
}

fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> {
pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> {
if path.exists() {
if let Ok(existing) = fs::read(path) {
if existing == contents {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { InlineEditPopover } from "@/features/editor/inline-edit/inline-edit-pop
import { useInlineEdit } from "@/features/editor/inline-edit/use-inline-edit";
import { useInlineEditToolbarStore } from "@/features/editor/stores/inline-edit-toolbar.store";
import { useFileSystemStore } from "@/features/file-system/stores/file-system.store";
import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store";
import { useGitBlame } from "@/features/git/hooks/use-git-blame";
import { keymapRegistry } from "@/features/keymaps/utils/registry";
import { useSettingsStore } from "@/features/settings/stores/settings.store";
Expand Down Expand Up @@ -272,6 +273,7 @@ export function MonacoEditor({
javaMarkerRefreshRevision(state.lspStatus),
);
const inlineGitBlameEnabled = useSettingsStore((state) => state.settings.enableInlineGitBlame);
const workspaceId = useActiveWorkspaceId();
const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath);
const workspaceFolders = useFileSystemStore((state) => state.workspaceFolders);
const vimModeEnabled = useSettingsStore((state) => state.settings.vimMode);
Expand Down Expand Up @@ -795,7 +797,7 @@ export function MonacoEditor({
editor,
model,
documentTarget,
workspaceRoot: rootFolderPath,
workspaceScope: rootFolderPath ? { workspaceId, root: rootFolderPath } : undefined,
enabled: enableExpensiveServices,
});
let definitionClickIntent = 0;
Expand Down Expand Up @@ -1127,6 +1129,7 @@ export function MonacoEditor({
renderIndentGuides,
renderWhitespace,
rootFolderPath,
workspaceId,
scrollable,
scheduleInlineGitBlameRender,
selectEntireModel,
Expand Down Expand Up @@ -1371,7 +1374,7 @@ export function MonacoEditor({
const markers = await loadJavaNavigationMarkers({
client: lspClient,
target: documentTarget,
workspaceRoot: rootFolderPath,
workspaceScope: { workspaceId, root: rootFolderPath },
content: model.getValue(),
});
if (isDisposed()) {
Expand Down Expand Up @@ -1450,6 +1453,7 @@ export function MonacoEditor({
javaMarkerRevision,
monacoLanguageId,
rootFolderPath,
workspaceId,
]);

useEffect(() => {
Expand Down
Loading
Loading