From 63a03d1c082035338a2406d8e7c449dfc7015e89 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:20:36 +0800 Subject: [PATCH] fix: Status refresh loop, double project open at startup, jdtls Java requirement (#5) * fix: Stop the status refresh loop and the double project open at startup Every status rewrote .git/index, even with nothing to persist: gix's `write_changes` writes an empty change set too. The watcher reports .git/index writes as VCS changes, so each status triggered the next one, together with an ahead/behind walk. On a repository with a long history (freqtrade, 33k commits behind upstream) that kept a core busy for as long as the project was open. Write back only when `has_changes()`. A project passed on the command line was opened twice: the launch and the "reopen last project" restore ran concurrently, and the restore checked for an open project before the launch had finished. The second open found the index cache locked and indexed in memory only; with a different last project, the window ended up on whichever finished last. The restore now waits for the startup launch. * fix(lsp): Run jdtls on the Java version its bundles need jdtls's launcher checks for Java 21, and so did JDTLS_MIN_JAVA, but the Eclipse platform a release ships moves on: 1.63 bundles org.eclipse.core.filesystem with JavaSE-25, so on a JDK 21 the server died resolving its bundles ("jdtls is not ready (Crashed)") and the error told users to install the 21 they already had. Read the requirement from the installed jdtls instead: the highest osgi.ee capability / Bundle-RequiredExecutionEnvironment among its plugins' manifests (cached per install), never below 21. The runtime choice, the rescan while no JDK fits and the "needs JDK N" message use it. Fixes #4 --- Cargo.lock | 1 + crates/ide-sdk/Cargo.toml | 1 + crates/ide-sdk/src/java.rs | 163 +++++++++++++++++++++++---- crates/ide-vcs/src/status.rs | 3 + crates/ide-vcs/tests/repo.rs | 20 ++++ src-tauri/src/state.rs | 5 +- src/features/app/windows.ts | 4 +- src/features/welcome/session.test.ts | 54 +++++++++ src/features/welcome/session.ts | 4 +- src/platform/project.ts | 12 ++ 10 files changed, 239 insertions(+), 28 deletions(-) create mode 100644 src/features/welcome/session.test.ts diff --git a/Cargo.lock b/Cargo.lock index ed22c6b25..92ae7cbd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4272,6 +4272,7 @@ dependencies = [ "tempfile", "toml 0.9.12+spec-1.1.0", "tracing", + "zip 2.4.2", ] [[package]] diff --git a/crates/ide-sdk/Cargo.toml b/crates/ide-sdk/Cargo.toml index 5ea28d80c..cce2ab5b1 100644 --- a/crates/ide-sdk/Cargo.toml +++ b/crates/ide-sdk/Cargo.toml @@ -15,6 +15,7 @@ serde_json.workspace = true specta.workspace = true toml.workspace = true tracing.workspace = true +zip = { version = "=2.4.2", default-features = false, features = ["deflate"] } [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 1f16029e2..c21c6eebe 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -126,23 +126,101 @@ pub fn installed_jdks() -> Arc<[Jdk]> { } } -/// Like [`installed_jdks`], but while none can run jdtls the list is rescanned -/// (at most every [`RESCAN_AFTER`]), so a JDK installed after startup is picked -/// up when the server is restarted. -pub fn jdtls_jdks() -> Arc<[Jdk]> { +/// Like [`installed_jdks`], but while none can run jdtls (on Java `min_java`) +/// the list is rescanned (at most every [`RESCAN_AFTER`]), so a JDK installed +/// after startup is picked up when the server is restarted. +pub fn jdtls_jdks(min_java: u32) -> Arc<[Jdk]> { let mut slot = JDKS.lock().unwrap_or_else(|e| e.into_inner()); match &*slot { - Some(s) if jdtls_runtime(&s.jdks).is_some() || s.at.elapsed() < RESCAN_AFTER => s.jdks.clone(), + Some(s) if jdtls_runtime(&s.jdks, min_java).is_some() || s.at.elapsed() < RESCAN_AFTER => s.jdks.clone(), _ => rescan(&mut slot), } } -/// Oldest Java version jdtls itself runs on (projects may target older ones). +/// Oldest Java version jdtls runs on when its install can't tell (its launcher's check). pub const JDTLS_MIN_JAVA: u32 = 21; /// The JDK to run jdtls with: the first found that is new enough (`JAVA_HOME` wins). -pub fn jdtls_runtime(jdks: &[Jdk]) -> Option<&Jdk> { - jdks.iter().find(|j| j.major >= JDTLS_MIN_JAVA) +pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> { + jdks.iter().find(|j| j.major >= min_java) +} + +/// The Java version the jdtls that `cfg` launches needs: the highest any of its +/// bundles requires, never below [`JDTLS_MIN_JAVA`]. Its launcher only checks +/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on +/// an older JDK the server dies resolving its bundles. +pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 { + let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH")); + let required = search + .and_then(|s| ide_run::which::find_executable(&cfg.command, &s)) + .and_then(|exe| dunce::canonicalize(exe).ok()) + .and_then(|exe| Some(exe.parent()?.parent()?.to_path_buf())) + .and_then(|home| bundles_min_java(&home)); + required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA)) +} + +/// Per install: (plugins directory mtime, its requirement). +static BUNDLE_JAVA: Mutex)>> = Mutex::new(BTreeMap::new()); + +/// Highest Java version required by the bundles in `home`'s `plugins` (or Homebrew's +/// `libexec/plugins`); `None` when there are none to read. +fn bundles_min_java(home: &Path) -> Option { + let plugins = [home.join("plugins"), home.join("libexec").join("plugins")].into_iter().find(|p| p.is_dir())?; + let modified = std::fs::metadata(&plugins).and_then(|m| m.modified()).ok()?; + let mut cache = BUNDLE_JAVA.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((at, v)) = cache.get(&plugins) + && *at == modified + { + return *v; + } + let required = std::fs::read_dir(&plugins) + .ok()? + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "jar")) + .filter_map(|jar| jar_manifest(&jar)) + .filter_map(|manifest| manifest_min_java(&manifest)) + .max(); + cache.insert(plugins, (modified, required)); + required +} + +fn jar_manifest(jar: &Path) -> Option { + use std::io::Read; + let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?; + let mut entry = zip.by_name("META-INF/MANIFEST.MF").ok()?; + let mut text = String::new(); + entry.read_to_string(&mut text).ok()?; + Some(text) +} + +/// The Java version an OSGi bundle manifest requires: its `osgi.ee` capability +/// (`version=25`), else the oldest of its `Bundle-RequiredExecutionEnvironment`s. +fn manifest_min_java(manifest: &str) -> Option { + // Long headers continue on lines starting with a space. + let mut headers: Vec = Vec::new(); + for line in manifest.lines() { + match (line.strip_prefix(' '), headers.last_mut()) { + (Some(rest), Some(last)) => last.push_str(rest), + _ => headers.push(line.to_owned()), + } + } + let header = |name: &str| headers.iter().find_map(|h| h.strip_prefix(name)?.strip_prefix(':')).map(str::trim); + if let Some(caps) = header("Require-Capability") { + let ee = caps.split(',').filter(|c| c.trim_start().starts_with("osgi.ee")).find_map(|c| { + let v = c.split("version=").nth(1)?; + let digits: String = v.chars().take_while(char::is_ascii_digit).collect(); + digits.parse::().ok() + }); + if ee.is_some() { + return ee; + } + } + header("Bundle-RequiredExecutionEnvironment")? + .split(',') + .filter_map(|ee| ee.trim().strip_prefix("JavaSE-")) + .filter_map(|v| if v == "1.8" { Some(8) } else { v.parse().ok() }) + .min() } /// jdtls `java.configuration.runtimes`: one JDK per Java version (the first @@ -178,14 +256,15 @@ const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot /// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data /// directory, one per server root, and no Eclipse metadata is written into -/// the project; it runs on a JDK 21+ (via `JAVA_HOME`, which its launcher -/// reads), installed JDKs are registered as runtimes and `bundles` (e.g. the -/// java-debug plugin jar) are loaded into the server. +/// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its +/// launcher reads), installed JDKs are registered as runtimes and `bundles` +/// (e.g. the java-debug plugin jar) are loaded into the server. pub fn configure_jdtls( cfg: &mut LanguageServerConfig, server_root: &Path, data_dir: &Path, jdks: &[Jdk], + min_java: u32, bundles: &[PathBuf], ) { if cfg.name != "jdtls" { @@ -201,11 +280,11 @@ pub fn configure_jdtls( cfg.args.push(format!("--jvm-arg=-D{METADATA_PROPERTY}=false")); } if !cfg.env.contains_key("JAVA_HOME") { - match jdtls_runtime(jdks) { + match jdtls_runtime(jdks, min_java) { Some(jdk) => { cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned()); } - None => cfg.launch_error = Some(missing_runtime_message(jdks)), + None => cfg.launch_error = Some(missing_runtime_message(jdks, min_java)), } } let runtimes = runtimes(jdks); @@ -218,7 +297,7 @@ pub fn configure_jdtls( } } -fn missing_runtime_message(jdks: &[Jdk]) -> String { +fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String { let mut found: Vec = jdks.iter().map(|j| j.major).collect(); found.sort_unstable(); found.dedup(); @@ -229,7 +308,7 @@ fn missing_runtime_message(jdks: &[Jdk]) -> String { format!("found JDK {}", list.join(", ")) }; format!( - "jdtls needs JDK {JDTLS_MIN_JAVA} or newer to run ({found}). Install one (or point JAVA_HOME at it) and \ + "jdtls needs JDK {min_java} or newer to run ({found}). Install one (or point JAVA_HOME at it) and \ restart the server; the project can still target an older Java version." ) } @@ -330,7 +409,7 @@ mod tests { Jdk { major: 8, home: PathBuf::from("/jdk8") }, ]; let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")]; - configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, &bundles); + configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles); assert_eq!(cfg.args[0], "-data"); assert!(cfg.args[1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[1]); assert_eq!(cfg.args[2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false"); @@ -345,36 +424,74 @@ mod tests { // Deterministic (reconfigure compares configs) and idempotent. let before = cfg.clone(); - configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, &bundles); + configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles); assert_eq!(cfg, before); let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap(); - configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, &bundles); + configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles); assert!(other.args.is_empty() && other.initialization_options.is_none()); } #[test] - fn jdtls_needs_a_jdk_21_to_run() { + fn jdtls_needs_a_new_enough_jdk_to_run() { let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; let old = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }]; let mut cfg = jdtls(); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, &[]); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[]); assert!(!cfg.env.contains_key("JAVA_HOME")); let err = cfg.launch_error.unwrap(); assert!(err.contains("JDK 21 or newer") && err.contains("found JDK 8, 17"), "{err}"); let mut cfg = jdtls(); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], &[]); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[]); assert!(cfg.launch_error.unwrap().contains("no JDK was found")); // An explicit JAVA_HOME is the user's call. let mut cfg = jdtls(); cfg.env.insert("JAVA_HOME".into(), "/custom".into()); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, &[]); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[]); assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/custom", None)); let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/jdk21") }]; - assert_eq!(jdtls_runtime(&mixed).unwrap().major, 21); + assert_eq!(jdtls_runtime(&mixed, 21).unwrap().major, 21); + + // A jdtls whose bundles need 25 doesn't start on 21, and says what it needs. + let mut cfg = jdtls(); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, 25, &[]); + let err = cfg.launch_error.unwrap(); + assert!(err.contains("JDK 25 or newer") && err.contains("found JDK 17, 21"), "{err}"); + } + + #[test] + fn bundle_manifests_give_the_java_jdtls_needs() { + let ee = |m: &str| manifest_min_java(m); + assert_eq!(ee("Bundle-RequiredExecutionEnvironment: JavaSE-25\n"), Some(25)); + assert_eq!(ee("Bundle-RequiredExecutionEnvironment: JavaSE-17, JavaSE-1.8\n"), Some(8)); + // The capability wins, also when folded over continuation lines. + let folded = "Require-Capability: osgi.service;filter:=\"(x=1)\",osgi.ee;filter:=\"(&(osgi.ee=Ja\r\n \ + vaSE)(version=21))\"\r\nBundle-RequiredExecutionEnvironment: JavaSE-17\r\n"; + assert_eq!(ee(folded), Some(21)); + assert_eq!(ee("Bundle-Name: x\n"), None); + + let home = tempfile::tempdir().unwrap(); + let plugins = home.path().join("plugins"); + std::fs::create_dir(&plugins).unwrap(); + let jar = |name: &str, manifest: &str| { + let mut zip = zip::ZipWriter::new(std::fs::File::create(plugins.join(name)).unwrap()); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("META-INF/MANIFEST.MF", opts).unwrap(); + std::io::Write::write_all(&mut zip, manifest.as_bytes()).unwrap(); + zip.finish().unwrap(); + }; + jar( + "org.eclipse.jdt.ls.core_1.63.0.jar", + "Require-Capability: osgi.ee;filter:=\"(&(osgi.ee=JavaSE)(version=21))\"\n", + ); + jar("org.eclipse.core.filesystem_1.12.0.jar", "Bundle-RequiredExecutionEnvironment: JavaSE-25\n"); + jar("plain.jar", "Manifest-Version: 1.0\n"); + std::fs::write(plugins.join("notes.txt"), "not a jar").unwrap(); + assert_eq!(bundles_min_java(home.path()), Some(25)); + assert_eq!(bundles_min_java(&home.path().join("missing")), None); } #[test] diff --git a/crates/ide-vcs/src/status.rs b/crates/ide-vcs/src/status.rs index 88057afcb..a98c3d369 100644 --- a/crates/ide-vcs/src/status.rs +++ b/crates/ide-vcs/src/status.rs @@ -87,7 +87,10 @@ pub(crate) fn status(r: &Repo) -> IdeResult> { } } // Persist refreshed stat info so the next status is cheaper (like `git status` does). + // Only when something changed: `write_changes` writes even an empty change set, and + // the rewritten `.git/index` comes back as a VCS change that triggers the next status. if let Some(mut outcome) = iter.into_outcome() + && outcome.has_changes() && let Some(Err(e)) = outcome.write_changes() { tracing::debug!("could not write refreshed index: {e}"); diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index a49f31fd9..fa12939ff 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -145,6 +145,26 @@ fn repository_without_commits() { assert!(repo.status().unwrap().is_empty()); } +#[test] +fn status_leaves_an_up_to_date_index_alone() { + // The watcher reports `.git/index` writes as VCS changes, which refresh the + // status: rewriting it unconditionally made that a loop. + let fx = Fixture::new(); + fx.write("a.txt", "a\n"); + // Older than the index, so it isn't racily clean (that settles after a write or two). + let past = std::time::SystemTime::now() - std::time::Duration::from_secs(60); + std::fs::File::options().write(true).open(fx.path("a.txt")).unwrap().set_modified(past).unwrap(); + fx.commit_all("init"); + let repo = fx.repo(); + repo.status().unwrap(); // may persist refreshed stat info once + let index = fx.path(".git/index"); + let before = std::fs::metadata(&index).unwrap().modified().unwrap(); + for _ in 0..3 { + assert!(repo.status().unwrap().is_empty()); + } + assert_eq!(std::fs::metadata(&index).unwrap().modified().unwrap(), before); +} + #[test] fn status_matrix() { let fx = Fixture::new(); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index bc03077ed..77aed1065 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -246,8 +246,9 @@ impl AppState { if cfg.name == "jdtls" { let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect(); - let jdks = ide_sdk::java::jdtls_jdks(); - ide_sdk::java::configure_jdtls(cfg, server_root, &data_dir, &jdks, &bundles); + let min_java = ide_sdk::java::jdtls_min_java(cfg); + let jdks = ide_sdk::java::jdtls_jdks(min_java); + ide_sdk::java::configure_jdtls(cfg, server_root, &data_dir, &jdks, min_java, &bundles); } })); } diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 45568af18..c4cadfd4a 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -6,7 +6,7 @@ import type { Action } from "@/platform/actions"; import { t } from "@/platform/i18n"; import { call, commands, isTauri } from "@/platform/ipc"; -import { basename, confirmLeaveProject, openProject, useProject } from "@/platform/project"; +import { basename, confirmLeaveProject, openProject, provideStartupLaunch, useProject } from "@/platform/project"; import { notifications } from "@/workbench/contributions"; import { ask } from "./prompt"; @@ -195,7 +195,7 @@ export function installLaunchHandling(): () => void { if (!isTauri()) return () => {}; let disposed = false; let unlisten: (() => void) | undefined; - void consumeLaunch(); + provideStartupLaunch(consumeLaunch()); void import("@tauri-apps/api/webviewWindow").then(async ({ getCurrentWebviewWindow }) => { const off = await getCurrentWebviewWindow().listen(LAUNCH_EVENT, () => void consumeLaunch()); if (disposed) off(); diff --git a/src/features/welcome/session.test.ts b/src/features/welcome/session.test.ts new file mode 100644 index 000000000..a45f5413b --- /dev/null +++ b/src/features/welcome/session.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const openProject = vi.fn(); + +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { + ...real, + isTauri: () => true, + commands: { ...real.commands, settingsGet: async () => ({ general: { reopenLastProject: true } }) }, + }; +}); +vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ label: "main" }) })); +vi.mock("@/platform/project", async (orig) => { + const real = await orig(); + return { ...real, openProject: (p: string) => openProject(p) }; +}); + +const { reopenLastProject, _resetSessionForTests } = await import("./session"); +const { provideStartupLaunch, useProject } = await import("@/platform/project"); + +beforeEach(() => { + openProject.mockReset(); + useProject.setState({ project: null }); + sessionStorage.clear(); + localStorage.setItem("openidea.session.openProject", "/p/last"); + _resetSessionForTests(); +}); + +describe("reopenLastProject", () => { + it("waits for the command-line project instead of opening the last one too", async () => { + let finishLaunch!: () => void; + provideStartupLaunch( + new Promise((resolve) => { + finishLaunch = () => { + useProject.setState({ project: { root: "/p/launch" } as never }); + resolve(); + }; + }), + ); + const reopened = reopenLastProject(); + await new Promise((r) => setTimeout(r, 0)); + expect(openProject).not.toHaveBeenCalled(); + finishLaunch(); + await reopened; + expect(openProject).not.toHaveBeenCalled(); + }); + + it("reopens the last project when the launch named none", async () => { + provideStartupLaunch(Promise.resolve()); + await reopenLastProject(); + expect(openProject).toHaveBeenCalledWith("/p/last"); + }); +}); diff --git a/src/features/welcome/session.ts b/src/features/welcome/session.ts index 08440d41b..6035159af 100644 --- a/src/features/welcome/session.ts +++ b/src/features/welcome/session.ts @@ -6,7 +6,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { create } from "zustand"; import { commands, isTauri, type Settings } from "@/platform/ipc"; -import { openProject, useProject } from "@/platform/project"; +import { openProject, startupLaunchHandled, useProject } from "@/platform/project"; import { notifications } from "@/workbench/contributions"; const SESSION_KEY = "openidea.session.openProject"; @@ -104,6 +104,8 @@ export async function reopenLastProject(): Promise { /* use the local preference */ } if (!reopenEnabled(settings, useSession.getState().reopenPref)) return; + // A project passed on the command line wins over the last session's. + await startupLaunchHandled(); if (useProject.getState().project) return; await reopen(path); } diff --git a/src/platform/project.ts b/src/platform/project.ts index f23c11fd9..e5888bd64 100644 --- a/src/platform/project.ts +++ b/src/platform/project.ts @@ -27,6 +27,18 @@ export function confirmLeaveProject(): Promise { return useProject.getState().project && leaveGuard ? leaveGuard() : Promise.resolve(true); } +let startupLaunch: Promise | null = null; + +/** Installs this window's startup launch (the project it was started or created for, features/app/windows.ts). */ +export function provideStartupLaunch(launch: Promise) { + startupLaunch = launch; +} + +/** Settles once the startup launch has opened its project (if any), so a restore doesn't race it. */ +export function startupLaunchHandled(): Promise { + return (startupLaunch ?? Promise.resolve()).catch(() => {}); +} + export async function openProject(path: string): Promise { useProject.setState({ opening: true }); try {