diff --git a/crates/ide-host/src/ops/doc.rs b/crates/ide-host/src/ops/doc.rs index d47bfd60..c6b190dc 100644 --- a/crates/ide-host/src/ops/doc.rs +++ b/crates/ide-host/src/ops/doc.rs @@ -8,6 +8,9 @@ pub async fn open(host: &Host, window: &str, path: PathBuf) -> IdeResult, window: &str, path: &Path) -> IdeResult Option { - host.project(window).ok().map(|p| info(&p)) + host.project(window).ok().filter(|p| !p.light).map(|p| info(&p)) } /// Effective settings for the window's project, or global settings without one. @@ -75,7 +75,8 @@ pub fn set_settings( let value: serde_json::Value = serde_json::from_str(value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?; let current = host.project(window).ok(); - let project_scope = matches!(scope, SettingsScope::Project) && current.is_some(); + // A LightEdit window has no project layer: its values are global. + let project_scope = matches!(scope, SettingsScope::Project) && current.as_ref().is_some_and(|p| !p.light); let settings = match ¤t { Some(p) if project_scope => p.settings.set(scope, key, value)?, Some(p) => { @@ -90,7 +91,7 @@ pub fn set_settings( // A global value reaches every other open project too. if !project_scope { for project in host.projects() { - if current.as_ref().is_some_and(|c| c.root == project.root) { + if current.as_ref().is_some_and(|c| Arc::ptr_eq(c, &project)) { continue; } let effective = project.settings.reload(); diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 53a36bce..fd745227 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -50,6 +50,9 @@ pub struct Project { /// Watchers of the Project Structure content roots outside the project folder. pub(crate) content_watchers: Mutex>, pub(crate) _scratch_watcher: Option, + /// A LightEdit window's loose files ([`Host::open_light`]): global settings only, + /// nothing indexed, no language servers, repositories or project services. + pub light: bool, } impl Host { @@ -66,6 +69,7 @@ impl Host { // it, with its warm index, language servers and unsaved documents. if let Ok(current) = self.project(window) && current.root == root + && !current.light { return Ok(current); } @@ -275,29 +279,7 @@ impl Host { mark("git"); tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis()); - let db = Arc::new(ide_db::DbManager::new( - root.clone(), - self.data_dir.join("db-passwords.json"), - self.data_dir.join("db-history.json"), - self.ssh.clone(), - )); - let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key))); - let agents = Arc::new(ide_agent::AgentManager::new( - ide_agent::AgentPaths { - project_root: root.clone(), - store_dir: self.data_dir.join("agents").join(&project_key), - // Outside the app data dir: its path has spaces on macOS, which trips up build scripts. - worktrees_dir: dirs::home_dir() - .unwrap_or_else(|| self.data_dir.clone()) - .join(".openidea") - .join("worktrees") - .join(&project_key), - models_file: self.data_dir.join("agents").join("models.json"), - custom_agents_dir: Some(self.config_dir.join("agents")), - home_dir: dirs::home_dir(), - }, - self.runtime.clone(), - )); + let (db, http_history, agents) = self.project_services(&root, &project_key); let project = Arc::new(Project { root, sink, @@ -319,6 +301,7 @@ impl Host { watcher, content_watchers: Mutex::new(content_watchers), _scratch_watcher: scratch_watcher, + light: false, }); { let weak = Arc::downgrade(&project); @@ -329,6 +312,146 @@ impl Host { self.insert_project(window, project.clone()); Ok(project) } + + /// Database connections, HTTP Client history and coding agents of the project at `root`. + pub(crate) fn project_services( + &self, + root: &Path, + project_key: &str, + ) -> (Arc, Arc, Arc) { + let db = Arc::new(ide_db::DbManager::new( + root.to_path_buf(), + self.data_dir.join("db-passwords.json"), + self.data_dir.join("db-history.json"), + self.ssh.clone(), + )); + let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key))); + let agents = Arc::new(ide_agent::AgentManager::new( + ide_agent::AgentPaths { + project_root: root.to_path_buf(), + store_dir: self.data_dir.join("agents").join(project_key), + // Outside the app data dir: its path has spaces on macOS, which trips up build scripts. + worktrees_dir: dirs::home_dir() + .unwrap_or_else(|| self.data_dir.clone()) + .join(".openidea") + .join("worktrees") + .join(project_key), + models_file: self.data_dir.join("agents").join("models.json"), + custom_agents_dir: Some(self.config_dir.join("agents")), + home_dir: dirs::home_dir(), + }, + self.runtime.clone(), + )); + (db, http_history, agents) + } +} + +impl Host { + /// Opens `window` for editing loose files (LightEdit), `file` first: documents + /// with syntax highlighting, saving and Local History over the global settings, + /// without an index, language servers, repositories or project settings. A + /// window already in LightEdit keeps its session; only its watchers grow. + pub fn open_light(&self, window: &str, file: &Path) -> IdeResult> { + let file = dunce::canonicalize(file)?; + if file.is_dir() { + return Err(IdeError::invalid(format!("{} is a folder", file.display()))); + } + let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?; + if let Ok(current) = self.project(window) + && current.light + { + current.watch_light_dir(&dir); + return Ok(current); + } + self.close_project(window); + let sink: SharedSink = (self.sink_for)(window); + let settings = Arc::new(SettingsStore::load(&self.config_dir, None)); + let effective = settings.effective(); + let languages = Arc::new(self.languages.fork()); + languages.set_file_types(FileTypes::from_settings(&effective.files, None)); + let documents = { + let languages = languages.clone(); + Arc::new(Documents::new( + sink.clone(), + Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)), + )) + }; + documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, None)); + let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &dir)?); + let index = ProjectIndex::unindexed(&dir, languages.clone(), self.syntax(), sink.clone())?; + let lsp = LspManager::new(dir.clone(), languages.clone(), sink.clone()); + lsp.set_servers_hook(Box::new(|_, _| Vec::new())); + let syntax = Arc::new(SyntaxCache::new(self.syntax())); + documents.add_observer(Arc::new(SyntaxObserver(syntax.clone()))); + documents.add_observer(Arc::new(HistoryObserver(history.clone()))); + let watcher = light_watcher(&dir, &sink, &documents, &history)?; + let project_key = project_key(&dir); + let (db, http_history, agents) = self.project_services(&dir, &project_key); + let project = Arc::new(Project { + sink, + languages, + settings, + documents, + history, + moves: FileMoves::new(), + index, + lsp, + repos: Arc::default(), + structure: Arc::new(StructureService::new(syntax.clone())), + syntax, + detected_roots: Arc::default(), + cache_dir: self.data_dir.join("cache").join(&project_key), + db, + http_history, + agents, + watcher, + content_watchers: Mutex::new(Vec::new()), + _scratch_watcher: None, + light: true, + root: dir, + }); + tracing::info!("window {window} edits {} without a project", file.display()); + self.insert_project(window, project.clone()); + Ok(project) + } +} + +impl Project { + /// LightEdit: follow external changes to the files of `dir` (its own entries only). + pub fn watch_light_dir(&self, dir: &Path) { + if !self.light { + return; + } + let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); + let mut watchers = self.content_watchers.lock(); + if dir == self.root || watchers.iter().any(|(p, _)| *p == dir) { + return; + } + match light_watcher(&dir, &self.sink, &self.documents, &self.history) { + Ok(w) => watchers.push((dir, w)), + Err(e) => tracing::warn!("watching {}: {e}", dir.display()), + } + } +} + +fn light_watcher( + dir: &Path, + sink: &SharedSink, + documents: &Arc, + history: &Arc, +) -> IdeResult { + let (documents, history) = (documents.clone(), history.clone()); + Watcher::start_with( + dir, + Vec::new(), + sink.clone(), + Box::new(move |batch| { + let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); + documents.on_disk_changed(&changed); + record_external_changes(&history, &changed, &batch.removed); + }), + ide_vfs::WatchMode::Flat, + ) } /// Looks for repositories nested below the project (and the one containing it); @@ -516,6 +639,9 @@ impl Project { /// Applies changed content roots and exclude settings to the index (which /// rescans in the background) and the file watchers, without reopening the project. pub fn refresh_index_scope(&self) { + if self.light { + return; + } let (roots, excludes) = (self.content_roots(), self.exclude_globs()); self.index.set_scope(roots.clone(), excludes.clone()); self.watcher.set_excludes(&excludes); diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs new file mode 100644 index 00000000..919f05d6 --- /dev/null +++ b/crates/ide-host/tests/light_edit.rs @@ -0,0 +1,78 @@ +//! LightEdit: a window editing loose files without a project. + +use ide_core::{ChangeSet, NullSink, TextChange}; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; +use std::time::Duration; + +fn host(base: &std::path::Path) -> Arc { + Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + })) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn edits_loose_files_without_project_services() { + let tmp = tempfile::tempdir().unwrap(); + let tmp_root = dunce::canonicalize(tmp.path()).unwrap(); + let (a_dir, b_dir) = (tmp_root.join("a"), tmp_root.join("b")); + std::fs::create_dir_all(a_dir.join(".git")).unwrap(); + std::fs::create_dir_all(&b_dir).unwrap(); + std::fs::write(a_dir.join("one.py"), "print(1)\n").unwrap(); + std::fs::write(b_dir.join("two.rs"), "fn main() {}\n").unwrap(); + let host = host(&tmp_root.join("app")); + + let light = host.open_light("main", &a_dir.join("one.py")).unwrap(); + assert!(light.light); + assert_eq!(light.root, a_dir); + assert!(host.open_light("main", &b_dir).is_err(), "a folder is not opened for LightEdit"); + + let doc = ops::doc::open(&host, "main", a_dir.join("one.py")).await.unwrap(); + assert!(doc.text.starts_with("print")); + let changes = ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "#\n".into() }] }; + let version = ops::doc::edit(&host, "main", doc.id, doc.version, changes).unwrap(); + assert!(version > doc.version); + ops::doc::save(&host, "main", doc.id).await.unwrap(); + assert_eq!(std::fs::read_to_string(a_dir.join("one.py")).unwrap(), "#\nprint(1)\n"); + + // More files join the same session, wherever they are. + let again = host.open_light("main", &b_dir.join("two.rs")).unwrap(); + assert!(Arc::ptr_eq(&light, &again)); + ops::doc::open(&host, "main", b_dir.join("two.rs")).await.unwrap(); + + assert!(light.index.wait_idle(Duration::from_secs(10))); + assert_eq!(light.index.status().files, 0, "nothing is indexed"); + assert!(light.lsp.servers().is_empty(), "no language servers"); + assert!(light.repos().is_empty(), "no repositories"); + + // Opening the folder as a project replaces the LightEdit session. + let project = host.open_project("main", &a_dir).unwrap(); + assert!(!project.light); + assert!(!Arc::ptr_eq(&project, &light)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn light_windows_ignore_project_scoped_settings() { + let tmp = tempfile::tempdir().unwrap(); + let dir = dunce::canonicalize(tmp.path()).unwrap().join("loose"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("notes.txt"), "x\n").unwrap(); + let host = host(&tmp.path().join("app")); + host.open_light("main", &dir.join("notes.txt")).unwrap(); + let applied = ops::project::set_settings( + &host, + "main", + ide_workspace::settings::SettingsScope::Project, + "general.projectName", + "\"loose\"", + ) + .unwrap(); + assert!(!applied.project_scope); + assert!(!dir.join(".openidea").exists(), "no project settings next to the file"); +} diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs index ac7361c2..4e479453 100644 --- a/crates/ide-index/src/lib.rs +++ b/crates/ide-index/src/lib.rs @@ -90,11 +90,29 @@ impl ProjectIndex { let store = store::Store::open(cache_dir, &root, &syntax.fingerprint()); let filter = filter::PathFilter::new(&root, &content_roots, &excludes); let shared = Arc::new(Shared::new(filter, languages, syntax, sink, store)); + Self::start(shared, root, true) + } + + /// An index that never scans or persists anything (a window editing loose + /// files): queries answer empty, recent files are kept in memory only. + pub fn unindexed( + root: &Path, + languages: Arc, + syntax: Arc, + sink: SharedSink, + ) -> IdeResult> { + let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_owned()); + let filter = filter::PathFilter::new(&root, &[], &[]); + let shared = Arc::new(Shared::new(filter, languages, syntax, sink, None)); + Self::start(shared, root, false) + } + + fn start(shared: Arc, root: PathBuf, scan: bool) -> IdeResult> { let worker = { let shared = shared.clone(); std::thread::Builder::new() .name("openidea-index".into()) - .spawn(move || shared.run()) + .spawn(move || shared.run(scan)) .map_err(|e| ide_core::IdeError::internal(format!("cannot start indexer: {e}")))? }; Ok(Arc::new(Self { shared, worker: Mutex::new(Some(worker)), root })) diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs index 3d6bbdd9..8094cdc8 100644 --- a/crates/ide-index/src/tests.rs +++ b/crates/ide-index/src/tests.rs @@ -473,3 +473,25 @@ fn file_type_changes_reparse_affected_files() { assert_eq!(symbols(&idx, "beta", SymbolFilter::All), ["beta"]); assert_eq!(idx.shared.parsed.load(Ordering::SeqCst), parsed + 1, "only the affected file is re-parsed"); } + +#[test] +fn unindexed_index_never_scans_or_persists() { + let fx = Fixture::new(&[("a.rs", "fn alpha() {}\n")]); + let events = Arc::new(Mutex::new(Vec::new())); + let sink: SharedSink = { + let events = events.clone(); + Arc::new(move |e: IdeEvent| events.lock().push(e)) + }; + let languages = Arc::new(LanguageRegistry::load(None).unwrap()); + let idx = ProjectIndex::unindexed(&fx.root, languages, Arc::new(Syntax::new()), sink).unwrap(); + assert!(idx.wait_idle(WAIT)); + let st = idx.status(); + assert!(!st.indexing); + assert_eq!(st.files, 0); + assert!(files(&idx, "a").is_empty()); + idx.mark_recent(&fx.root.join("a.rs")); + assert!(idx.wait_idle(WAIT)); + assert_eq!(idx.recent_files(), vec![fx.root.join("a.rs")]); + assert!(!fx.cache.exists()); + assert!(!events.lock().iter().any(|e| matches!(e, IdeEvent::Task { .. })), "no indexing task"); +} diff --git a/crates/ide-index/src/worker.rs b/crates/ide-index/src/worker.rs index e3fbd919..21319a57 100644 --- a/crates/ide-index/src/worker.rs +++ b/crates/ide-index/src/worker.rs @@ -127,13 +127,18 @@ impl Shared { // ---------------------------------------------------------------- thread - pub(crate) fn run(&self) { + /// `scan`: load the persisted index and scan the roots before serving updates. + pub(crate) fn run(&self, scan: bool) { { let mut q = self.queue.lock(); q.busy = true; } - self.load_persisted(); - self.full_scan(); + if scan { + self.load_persisted(); + self.full_scan(); + } else { + self.indexing.store(false, Ordering::SeqCst); + } { let mut q = self.queue.lock(); q.busy = false; diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index 3cc884e4..f009ead3 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -32,6 +32,8 @@ pub enum WatchMode { /// One non-recursive watch per directory, skipping excluded and ignored /// directories; new directories are picked up as they appear. PerDirectory, + /// The root's own entries only (loose files edited outside a project). + Flat, } impl Default for WatchMode { @@ -81,7 +83,8 @@ impl Watcher { on_change(batch); }; let inner = match mode { - WatchMode::Recursive => Self::start_recursive(root, filter, emit), + WatchMode::Recursive => Self::start_recursive(root, filter, emit, RecursiveMode::Recursive), + WatchMode::Flat => Self::start_recursive(root, filter, emit, RecursiveMode::NonRecursive), WatchMode::PerDirectory => Self::start_per_directory(root, filter, emit), }?; Ok(Self { inner, excludes: shared }) @@ -94,7 +97,7 @@ impl Watcher { *self.excludes.write() = ExcludeMatcher::new(excludes); } - /// Number of OS-level watches (1 in recursive mode). + /// Number of OS-level watches (1 in recursive and flat mode). pub fn watch_count(&self) -> usize { match &self.inner { Inner::Recursive(_) => 1, @@ -106,6 +109,7 @@ impl Watcher { root: PathBuf, filter: EventFilter, emit: impl Fn(&FsBatch) + Send + 'static, + recursive: RecursiveMode, ) -> IdeResult { // No file-id cache: building it walks and stats the whole tree before `watch` returns, // which made opening large projects block for seconds. Renames arrive as remove + create. @@ -125,9 +129,7 @@ impl Watcher { let mut debouncer = new_debouncer_opt::<_, RecommendedWatcher, _>(DEBOUNCE, None, handler, NoCache, notify::Config::default()) .map_err(|e| io_error(format!("cannot start file watcher: {e}")))?; - debouncer - .watch(&root, RecursiveMode::Recursive) - .map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?; + debouncer.watch(&root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?; Ok(Inner::Recursive(debouncer)) } @@ -787,4 +789,30 @@ mod tests { std::thread::sleep(Duration::from_millis(400)); assert_eq!(batches.lock().len(), n, "no events after drop"); } + + #[test] + fn flat_mode_reports_the_roots_own_entries_only() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + std::fs::create_dir_all(root.join("sub")).unwrap(); + let batches: Arc>> = Arc::default(); + let sink: SharedSink = Arc::new(|_e: IdeEvent| {}); + let watcher = { + let batches = batches.clone(); + Watcher::start_with( + &root, + Vec::new(), + sink, + Box::new(move |b| batches.lock().push(b.clone())), + WatchMode::Flat, + ) + .unwrap() + }; + assert_eq!(watcher.watch_count(), 1); + std::fs::write(root.join("sub/deep.txt"), "x").unwrap(); + std::fs::write(root.join("a.txt"), "x").unwrap(); + assert!(wait_for(&batches, |b| all_paths(b).contains(&root.join("a.txt"))), "{:?}", batches.lock()); + std::thread::sleep(Duration::from_millis(300)); + assert!(!all_paths(&batches.lock()).contains(&root.join("sub/deep.txt"))); + } } diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 6456fbf7..acec4749 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -61,6 +61,22 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult IdeResult { + let app = window.app_handle().clone(); + let label = window.label().to_owned(); + if app.state::().project(&label).is_ok_and(|p| !p.light) { + crate::windows::close_settings_window(&app, &label); + } + let project = tauri::async_runtime::spawn_blocking(move || app.state::().open_light(&label, &path)) + .await + .map_err(|e| IdeError::internal(e.to_string()))??; + Ok(project.settings.effective()) +} + fn project_info(project: &crate::state::Project) -> ProjectInfo { ide_host::ops::project::info(project) } @@ -200,7 +216,7 @@ pub fn project_roots(window: Window, state: State<'_, AppState>) -> IdeResult) -> Option { - state.project(window.label()).ok().map(|p| project_info(&p)) + state.project(window.label()).ok().filter(|p| !p.light).map(|p| project_info(&p)) } #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6f03b04a..859118a6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -59,6 +59,7 @@ pub fn builder() -> tauri_specta::Builder { app::recent_projects, app::remove_recent_project, app::open_project, + app::light_edit_open, app::project_current, app::close_project, app::languages, @@ -296,6 +297,7 @@ pub fn builder() -> tauri_specta::Builder { sdk::sdk_python_packages, sdk::sdk_python_pip, window::window_open_project, + window::window_open_files, window::window_open_remote, window::window_new, window::window_open_settings, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 64b6c593..ca91bc11 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -11,10 +11,25 @@ use tauri::{AppHandle, Manager, State, WebviewWindow}; /// Opens `path` in a new window (`new_window`) or tells the caller to open it /// itself (`current`). A project already open in some window is focused instead. +/// `files` then open in that project's editor. #[tauri::command] #[specta::specta] -pub async fn window_open_project(app: AppHandle, path: PathBuf, new_window: bool) -> IdeResult { - windows::open_project_window(&app, &path, new_window) +pub async fn window_open_project( + app: AppHandle, + path: PathBuf, + new_window: bool, + files: Vec, +) -> IdeResult { + windows::open_project_window(&app, &path, new_window, files) +} + +/// Loose `files` opened from a window without a local project: handed to the LightEdit +/// window when another one exists (`focused`), opened in a new one from a remote project +/// window (`opened`), else `current` (the caller opens them in LightEdit itself). +#[tauri::command] +#[specta::specta] +pub async fn window_open_files(app: AppHandle, window: WebviewWindow, files: Vec) -> IdeResult { + windows::open_files_window(&app, window.label(), files) } /// [`window_open_project`] for folder `path` on the host of SSH configuration @@ -94,7 +109,7 @@ pub fn window_open_projects( Ok(windows::project_window_labels(&app) .into_iter() .filter_map(|label| { - let project = state.project(&label).ok()?; + let project = state.project(&label).ok().filter(|p| !p.light)?; Some(OpenProject { name: project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), root: project.root.clone(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9593cbea..87cec86a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -82,7 +82,7 @@ pub fn run() { if let Ok(cwd) = std::env::current_dir() && let Some(path) = windows::cli_path(&std::env::args().collect::>(), &cwd) { - windows.set_launch("main", windows::Launch { project: Some(path.into()), ..Default::default() }); + windows.set_launch("main", windows::path_launch(path)); } let menu_state = menu::MenuState::default(); menu::install(app.handle(), &menu_state, &state.recent.list()); diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 16176d77..d0ee9cf1 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -207,6 +207,7 @@ pub fn spec(os: Os) -> Vec { act("file.new", "New…"), act("file.newScratchFile", "New Scratch File…"), act("file.openProject", "Open…"), + act("file.openFile", "Open File…"), act("file.openInNewWindow", "Open in New Window…"), sub( "openRecent", @@ -1234,6 +1235,7 @@ mod tests { "app.about", "app.quit", "file.openProject", + "file.openFile", "file.openInNewWindow", "file.closeProject", "file.save", diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7508e849..ffbd909e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -138,6 +138,12 @@ impl AppState { Ok(project) } + /// Opens `window` for editing the loose file `file` (LightEdit); not a recent project. + pub fn open_light(&self, window: &str, file: &Path) -> IdeResult> { + self.remote_dev.disconnect(window); + self.host.open_light(window, file) + } + pub fn close_project(&self, window: &str) { self.remote_dev.disconnect(window); self.host.close_project(window); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index deb97acd..06f1d75e 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -38,6 +38,9 @@ const SETTINGS_MIN_SIZE: (f64, f64) = (640.0, 420.0); pub struct Launch { /// Project folder to open. pub project: Option, + /// Files to open in the editor: in `project` once it is open, else in this + /// window's project or (without one) in LightEdit. + pub files: Vec, /// Remote project to open (Remote Development). pub remote: Option, /// Settings windows: the page to show. @@ -189,10 +192,26 @@ pub fn find_project_window(app: &AppHandle, root: &Path) -> Option()?; app.webview_windows().into_iter().filter(|(label, _)| !is_settings_window(label)).find_map(|(label, w)| { let project = state.project(&label).ok()?; - (project.root == root).then_some(w) + (project.root == root && !project.light).then_some(w) }) } +/// The window editing loose files (LightEdit), if any: more files open there. +pub fn find_light_window(app: &AppHandle) -> Option { + let state = app.try_state::()?; + let windows = app.webview_windows(); + let labels = sorted_labels(windows.keys().map(String::as_str).filter(|l| !is_settings_window(l))); + labels.into_iter().find(|l| state.project(l).is_ok_and(|p| p.light)).and_then(|l| windows.get(&l).cloned()) +} + +/// Hands `files` to the already loaded `window` (its frontend takes them on [`LAUNCH_EVENT`]) and focuses it. +fn send_files(app: &AppHandle, window: &WebviewWindow, files: Vec) { + let launch = Launch { files: files.into_iter().map(Into::into).collect(), ..Default::default() }; + app.state::().set_launch(window.label(), launch); + let _ = app.emit_to(window.label(), LAUNCH_EVENT, ()); + focus(window); +} + fn has_project(app: &AppHandle, label: &str) -> bool { app.try_state::().is_some_and(|s| s.project(label).is_ok() || s.remote_dev.get(label).is_some()) } @@ -282,8 +301,15 @@ pub fn create_main_window(app: &AppHandle) -> IdeResult { /// Creates a new project window; `project` is opened by its frontend on load. pub fn create_window(app: &AppHandle, project: Option) -> IdeResult { - let title = project.as_deref().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned()); - create_window_for(app, title, Launch { project: project.map(Into::into), ..Default::default() }) + create_window_with(app, project, Vec::new()) +} + +/// [`create_window`] that also opens `files` (without `project`: in LightEdit). +fn create_window_with(app: &AppHandle, project: Option, files: Vec) -> IdeResult { + let title = project.as_deref().or(files.first().map(PathBuf::as_path)).and_then(Path::file_name); + let title = title.map(|n| n.to_string_lossy().into_owned()); + let files = files.into_iter().map(Into::into).collect(); + create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() }) } fn create_window_for(app: &AppHandle, title: Option, launch: Launch) -> IdeResult { @@ -313,9 +339,13 @@ fn create_window_for(app: &AppHandle, title: Option, launch: Launch) -> /// `create_window` off the event-loop thread (building a window from a /// main-thread callback can deadlock on Windows). pub fn spawn_window(app: &AppHandle, project: Option) { + spawn_window_with(app, project, Vec::new()); +} + +fn spawn_window_with(app: &AppHandle, project: Option, files: Vec) { let app = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(e) = create_window(&app, project) { + if let Err(e) = create_window_with(&app, project, files) { tracing::warn!("cannot open window: {}", e.message); } }); @@ -333,17 +363,43 @@ pub enum OpenOutcome { Current, } -pub fn open_project_window(app: &AppHandle, path: &Path, new_window: bool) -> IdeResult { +/// Opens folder `path` as a project and then `files` in its editor; see [`OpenOutcome`] +/// (`Current`: the caller opens both itself). +pub fn open_project_window( + app: &AppHandle, + path: &Path, + new_window: bool, + files: Vec, +) -> IdeResult { let root = dunce::canonicalize(path).map_err(|e| IdeError::not_found(format!("{}: {e}", path.display())))?; tracing::info!("open_project_window {} new_window={new_window}", root.display()); if let Some(w) = find_project_window(app, &root) { - focus(&w); + if files.is_empty() { + focus(&w); + } else { + send_files(app, &w, files); + } return Ok(OpenOutcome::Focused { label: w.label().to_owned() }); } if !new_window { return Ok(OpenOutcome::Current); } - let window = create_window(app, Some(root))?; + let window = create_window_with(app, Some(root), files)?; + Ok(OpenOutcome::Opened { label: window.label().to_owned() }) +} + +/// Loose local `files` asked for by a window without a local project: they join +/// the LightEdit window when another one has it open; a window busy with a (remote) +/// project gets a new LightEdit window; else the caller opens them itself (`Current`). +pub fn open_files_window(app: &AppHandle, caller: &str, files: Vec) -> IdeResult { + if let Some(w) = find_light_window(app).filter(|w| w.label() != caller) { + send_files(app, &w, files); + return Ok(OpenOutcome::Focused { label: w.label().to_owned() }); + } + if !has_project(app, caller) { + return Ok(OpenOutcome::Current); + } + let window = create_window_with(app, None, files)?; Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } @@ -366,35 +422,52 @@ pub fn open_remote_window(app: &AppHandle, ssh_config: &str, path: &str, new_win Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } -/// Opens `path` from the command line / a second instance: focuses the window -/// that has it, else reuses a window without a project, else makes a new one. +/// Opens `path` from the command line / a second instance: a folder focuses the +/// window that has it, a file joins the LightEdit window; else a window without +/// a project is reused, else a new one is made. pub fn open_path(app: &AppHandle, path: &Path) { - let root = match dunce::canonicalize(path) { - Ok(r) if r.is_dir() => r, - Ok(r) => r.parent().map(Path::to_path_buf).unwrap_or(r), + let path = match dunce::canonicalize(path) { + Ok(p) => p, Err(e) => { tracing::warn!("cannot open {}: {e}", path.display()); return; } }; - if let Some(w) = find_project_window(app, &root) { + let is_dir = path.is_dir(); + if is_dir && let Some(w) = find_project_window(app, &path) { focus(&w); return; } + if !is_dir && let Some(w) = find_light_window(app) { + send_files(app, &w, vec![path]); + return; + } let free = target_window(app) .filter(|w| !has_project(app, w.label())) .filter(|w| !is_settings_window(w.label())) .or_else(|| { app.webview_windows().into_values().find(|w| !is_settings_window(w.label()) && !has_project(app, w.label())) }); + let launch = path_launch(path); match free { Some(w) => { - app.state::() - .set_launch(w.label(), Launch { project: Some(root.into()), ..Default::default() }); + app.state::().set_launch(w.label(), launch); let _ = app.emit_to(w.label(), LAUNCH_EVENT, ()); focus(&w); } - None => spawn_window(app, Some(root)), + None => { + let files = launch.files.into_iter().map(ide_core::IpcPath::into_path_buf).collect(); + spawn_window_with(app, launch.project.map(ide_core::IpcPath::into_path_buf), files) + } + } +} + +/// What a window opens for a command-line path: a folder as its project, a file in LightEdit. +pub fn path_launch(path: PathBuf) -> Launch { + if path.is_dir() { + Launch { project: Some(path.into()), ..Default::default() } + } else { + Launch { files: vec![path.into()], ..Default::default() } } } @@ -568,6 +641,15 @@ mod tests { assert_eq!(cli_path(&s(&["openidea", "-psn_0_123"]), cwd), None); } + #[test] + fn command_line_folders_open_as_projects_and_files_in_light_edit() { + let dir = std::env::temp_dir(); + assert_eq!(path_launch(dir.clone()).project, Some(dir.into())); + let file = std::env::current_exe().unwrap(); + let launch = path_launch(file.clone()); + assert_eq!((launch.project, launch.files), (None, vec![file.into()])); + } + #[test] fn launches_are_taken_once() { let st = WindowsState::default(); diff --git a/src/bindings.ts b/src/bindings.ts index adf9b052..3915a0fc 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -11,6 +11,11 @@ export const commands = { recentProjects: () => __TAURI_INVOKE("recent_projects"), removeRecentProject: (path: string) => typedError(__TAURI_INVOKE("remove_recent_project", { path })), openProject: (path: string) => typedError(__TAURI_INVOKE("open_project", { path })), + /** + * Edits the loose file `path` in this window without a project (LightEdit); + * a window already in LightEdit keeps its files. Returns the effective (global) settings. + */ + lightEditOpen: (path: string) => typedError(__TAURI_INVOKE("light_edit_open", { path })), /** The project open in this window (a settings window: its parent's), if any. */ projectCurrent: () => __TAURI_INVOKE<{ root: IpcPath, @@ -578,8 +583,15 @@ export const commands = { /** * Opens `path` in a new window (`new_window`) or tells the caller to open it * itself (`current`). A project already open in some window is focused instead. + * `files` then open in that project's editor. */ - windowOpenProject: (path: string, newWindow: boolean) => typedError(__TAURI_INVOKE("window_open_project", { path, newWindow })), + windowOpenProject: (path: string, newWindow: boolean, files: string[]) => typedError(__TAURI_INVOKE("window_open_project", { path, newWindow, files })), + /** + * Loose `files` opened from a window without a local project: handed to the LightEdit + * window when another one exists (`focused`), opened in a new one from a remote project + * window (`opened`), else `current` (the caller opens them in LightEdit itself). + */ + windowOpenFiles: (files: string[]) => typedError(__TAURI_INVOKE("window_open_files", { files })), /** * [`window_open_project`] for folder `path` on the host of SSH configuration * `ssh_config` (Remote Development); a new window connects itself. @@ -600,6 +612,11 @@ export const commands = { windowTakeLaunch: () => __TAURI_INVOKE<{ /** Project folder to open. */ project: IpcPath | null, + /** + * Files to open in the editor: in `project` once it is open, else in this + * window's project or (without one) in LightEdit. + */ + files: IpcPath[], /** Remote project to open (Remote Development). */ remote: RemoteLaunch | null, /** Settings windows: the page to show. */ @@ -3562,6 +3579,11 @@ export type LanguagesSettings = { export type Launch = { /** Project folder to open. */ project: IpcPath | null, + /** + * Files to open in the editor: in `project` once it is open, else in this + * window's project or (without one) in LightEdit. + */ + files: IpcPath[], /** Remote project to open (Remote Development). */ remote: RemoteLaunch | null, /** Settings windows: the page to show. */ diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 552e5f26..a5f5efe2 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -217,6 +217,7 @@ export class EditorController implements SlotHost { tabOptions.activateOnClose = editorPrefs().tabs.activateOnClose as typeof tabOptions.activateOnClose; let prevSettings = useProject.getState().settings; let prevRoot = useProject.getState().project?.root ?? null; + let prevLight = useProject.getState().lightEdit; this.disposers.push( useProject.subscribe((s) => { if (s.settings !== prevSettings) { @@ -226,8 +227,10 @@ export class EditorController implements SlotHost { this.manager.applySettings(); } const root = s.project?.root ?? null; - if (root !== prevRoot) { + // Entering or leaving LightEdit replaces the backend's documents too. + if (root !== prevRoot || s.lightEdit !== prevLight) { prevRoot = root; + prevLight = s.lightEdit; this.saveSession(); this.sessionRoot = null; this.closeEverything(); diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index e5bd81e4..a83a1843 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -75,6 +75,7 @@ export function activate(): void | (() => void) { priority: 20, component: CaretStatus, title: "Line:Column Number", + lightEdit: true, }), registerStatusItem({ id: "editor.lineEnding", @@ -82,6 +83,7 @@ export function activate(): void | (() => void) { priority: 30, component: LineEndingStatus, title: "Line Separator", + lightEdit: true, }), registerStatusItem({ id: "editor.encoding", @@ -89,6 +91,7 @@ export function activate(): void | (() => void) { priority: 40, component: EncodingStatus, title: "File Encoding", + lightEdit: true, }), registerStatusItem({ id: "editor.indent", @@ -96,6 +99,7 @@ export function activate(): void | (() => void) { priority: 50, component: IndentStatus, title: "Indentation", + lightEdit: true, }), registerStatusItem({ id: "editor.readOnly", @@ -103,6 +107,7 @@ export function activate(): void | (() => void) { priority: 55, component: ReadOnlyStatus, title: "Read-only Attribute", + lightEdit: true, }), ]; return () => { diff --git a/src/features/app/LightEditStatus.tsx b/src/features/app/LightEditStatus.tsx new file mode 100644 index 00000000..658a849b --- /dev/null +++ b/src/features/app/LightEditStatus.tsx @@ -0,0 +1,26 @@ +/** LightEdit status bar entry: opens the current file's folder as a full project. */ +import { useEditorRegistry } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { useT } from "@/platform/i18n"; +import { dirname, useProject } from "@/platform/project"; +import { isVirtualUri } from "@/platform/virtualDocs"; +import { BarButton, Icon } from "@/ui"; + +export const LIGHT_EDIT_OPEN_PROJECT = "lightEdit.openProject"; + +export function LightEditStatus() { + const t = useT(); + const lightEdit = useProject((s) => s.lightEdit); + const path = useEditorRegistry((s) => s.active?.ctx.path); + if (!lightEdit || !path || isVirtualUri(path)) return null; + return ( + void executeAction(LIGHT_EDIT_OPEN_PROJECT)} + > + + {t("app.lightEdit.openProject")} + + ); +} diff --git a/src/features/app/__tests__/windows.test.ts b/src/features/app/__tests__/windows.test.ts index 4d449694..94de7749 100644 --- a/src/features/app/__tests__/windows.test.ts +++ b/src/features/app/__tests__/windows.test.ts @@ -1,8 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const windowOpenProject = vi.fn(); +const windowOpenFiles = vi.fn(); const windowTakeLaunch = vi.fn(); -const openProject = vi.fn(async (path: string) => ({ root: path, name: path, isGit: false, settings: {} })); +// The backend answers with the global settings, here: whatever the test set up. +const lightEditOpen = vi.fn(async (_path: string) => ({ status: "ok", data: useProject.getState().settings ?? {} })); +const fsReadDir = vi.fn(async (_path: string) => ({ status: "ok", data: [] })); +const openProject = vi.fn(async (path: string) => { + const project = { root: path, name: path, isGit: false, settings: {} }; + useProject.setState({ project, lightEdit: false } as never); + return project; +}); vi.mock("@/platform/ipc", async (orig) => { const real = await orig(); @@ -12,7 +20,10 @@ vi.mock("@/platform/ipc", async (orig) => { commands: { ...real.commands, windowOpenProject: (...a: unknown[]) => windowOpenProject(...a), + windowOpenFiles: (...a: unknown[]) => windowOpenFiles(...a), windowTakeLaunch: () => windowTakeLaunch(), + lightEditOpen: (p: string) => lightEditOpen(p), + fsReadDir: (p: string) => fsReadDir(p), }, }; }); @@ -21,45 +32,179 @@ vi.mock("@/platform/project", async (orig) => { return { ...real, openProject: (p: string) => openProject(p) }; }); -const { openProjectInWindow, consumeLaunch } = await import("../windows"); +const { openProjectInWindow, consumeLaunch, openFiles, openPaths, openLightEditFileAsProject, windowActions } = + await import("../windows"); const { useProject } = await import("@/platform/project"); +const { provideEditorService } = await import("@/workbench/contributions"); + +const opened: string[] = []; +let activePath: string | null = null; +provideEditorService({ + openFile: async (path, options) => void opened.push(`${path}${options?.focus ? " (focus)" : ""}`), + openInput: async () => {}, + activePath: () => activePath, + activeCursor: () => null, + saveAll: async () => {}, +}); + +const ok = (data: unknown) => ({ status: "ok", data }); +const projectState = (root: string) => ({ project: { root, name: root, isGit: false, settings: {} } }); beforeEach(() => { - windowOpenProject.mockReset(); - windowTakeLaunch.mockReset(); - openProject.mockClear(); - useProject.setState({ project: null }); + for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch]) f.mockReset(); + for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear(); + opened.length = 0; + activePath = null; + useProject.setState({ project: null, lightEdit: false, settings: null } as never); }); describe("openProjectInWindow", () => { it("opens in this window when the backend says `current`", async () => { - windowOpenProject.mockResolvedValue({ status: "ok", data: { kind: "current" } }); + windowOpenProject.mockResolvedValue(ok({ kind: "current" })); await openProjectInWindow("/p/a", false); - expect(windowOpenProject).toHaveBeenCalledWith("/p/a", false); + expect(windowOpenProject).toHaveBeenCalledWith("/p/a", false, []); expect(openProject).toHaveBeenCalledWith("/p/a"); }); it("asks for a new window when this one already has a project", async () => { - useProject.setState({ project: { root: "/p/b", name: "b", isGit: false, settings: {} } }); - windowOpenProject.mockResolvedValue({ status: "ok", data: { kind: "opened", label: "project-1" } }); + useProject.setState(projectState("/p/b") as never); + windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" })); await openProjectInWindow("/p/a", false); - expect(windowOpenProject).toHaveBeenCalledWith("/p/a", true); + expect(windowOpenProject).toHaveBeenCalledWith("/p/a", true, []); expect(openProject).not.toHaveBeenCalled(); }); + it("asks for a new window from a LightEdit window", async () => { + useProject.setState({ lightEdit: true }); + windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" })); + await openProjectInWindow("/p/a", false); + expect(windowOpenProject).toHaveBeenCalledWith("/p/a", true, []); + }); + it("does nothing locally when another window was focused", async () => { - windowOpenProject.mockResolvedValue({ status: "ok", data: { kind: "focused", label: "main" } }); + windowOpenProject.mockResolvedValue(ok({ kind: "focused", label: "main" })); await openProjectInWindow("/p/a", true); expect(openProject).not.toHaveBeenCalled(); }); }); +describe("loose files (LightEdit)", () => { + it("turns a window without a project into LightEdit when no LightEdit window exists", async () => { + windowOpenFiles.mockResolvedValue(ok({ kind: "current" })); + await openFiles(["/x/a.txt", "/y/b.md"]); + expect(windowOpenFiles).toHaveBeenCalledWith(["/x/a.txt", "/y/b.md"]); + expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt"); + expect(useProject.getState()).toMatchObject({ project: null, lightEdit: true }); + expect(opened).toEqual(["/x/a.txt", "/y/b.md (focus)"]); + }); + + it("leaves files to the existing LightEdit window", async () => { + windowOpenFiles.mockResolvedValue(ok({ kind: "focused", label: "project-2" })); + await openFiles(["/x/a.txt"]); + expect(lightEditOpen).not.toHaveBeenCalled(); + expect(opened).toEqual([]); + expect(useProject.getState().lightEdit).toBe(false); + }); + + it("adds tabs to this LightEdit window", async () => { + useProject.setState({ lightEdit: true }); + await openFiles(["/x/c.txt"]); + expect(windowOpenFiles).not.toHaveBeenCalled(); + expect(lightEditOpen).not.toHaveBeenCalled(); + expect(opened).toEqual(["/x/c.txt (focus)"]); + }); + + it("opens files in the project's editor in a project window", async () => { + useProject.setState(projectState("/p") as never); + await openFiles(["/elsewhere/a.txt"]); + expect(windowOpenFiles).not.toHaveBeenCalled(); + expect(opened).toEqual(["/elsewhere/a.txt (focus)"]); + expect(useProject.getState().lightEdit).toBe(false); + }); + + it("routes dropped files to LightEdit and the dropped folder to a project", async () => { + windowOpenFiles.mockResolvedValue(ok({ kind: "current" })); + windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" })); + useProject.setState({ settings: { general: { openProjectIn: "new" } } } as never); + await openPaths(["/x/a.txt", "/work/app"], async (p) => p === "/work/app"); + expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt"); + expect(windowOpenProject).toHaveBeenCalledWith("/work/app", true, []); + }); + + it("opens a dropped folder alone exactly as before", async () => { + windowOpenProject.mockResolvedValue(ok({ kind: "current" })); + await openPaths(["/work/app"], async () => true); + expect(windowOpenFiles).not.toHaveBeenCalled(); + expect(lightEditOpen).not.toHaveBeenCalled(); + expect(openProject).toHaveBeenCalledWith("/work/app"); + }); + + it("classifies paths through the file system by default", async () => { + fsReadDir.mockResolvedValueOnce({ status: "error", error: { kind: "notFound", message: "not a dir" } } as never); + windowOpenFiles.mockResolvedValue(ok({ kind: "current" })); + await openPaths(["/x/a.txt"]); + expect(fsReadDir).toHaveBeenCalledWith("/x/a.txt"); + expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt"); + }); +}); + +describe("Open Containing Folder as Project", () => { + it("is an action available in LightEdit only", () => { + const action = windowActions().find((a) => a.id === "lightEdit.openProject"); + expect(action?.when).toBe("lightEdit"); + expect(windowActions().some((a) => a.id === "file.openFile")).toBe(true); + }); + + it("opens the folder in a new window with the file, per 'Open project in'", async () => { + useProject.setState({ lightEdit: true, settings: { general: { openProjectIn: "new" } } } as never); + activePath = "/x/notes/a.txt"; + windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" })); + await openLightEditFileAsProject(); + expect(windowOpenProject).toHaveBeenCalledWith("/x/notes", true, ["/x/notes/a.txt"]); + expect(openProject).not.toHaveBeenCalled(); + }); + + it("or turns this window into the project and reopens the file there", async () => { + useProject.setState({ lightEdit: true, settings: { general: { openProjectIn: "same" } } } as never); + activePath = "/x/notes/a.txt"; + windowOpenProject.mockResolvedValue(ok({ kind: "current" })); + await openLightEditFileAsProject(); + expect(windowOpenProject).toHaveBeenCalledWith("/x/notes", false, ["/x/notes/a.txt"]); + expect(openProject).toHaveBeenCalledWith("/x/notes"); + expect(useProject.getState()).toMatchObject({ lightEdit: false, project: { root: "/x/notes" } }); + expect(opened).toEqual(["/x/notes/a.txt (focus)"]); + }); + + it("does nothing outside LightEdit", async () => { + useProject.setState(projectState("/p") as never); + activePath = "/p/a.txt"; + await openLightEditFileAsProject(); + expect(windowOpenProject).not.toHaveBeenCalled(); + }); +}); + describe("launch", () => { it("opens the pending project once", async () => { - windowTakeLaunch.mockResolvedValueOnce({ project: "/p/launch" }).mockResolvedValueOnce(null); + windowTakeLaunch.mockResolvedValueOnce({ project: "/p/launch", files: [] }).mockResolvedValueOnce(null); await consumeLaunch(); await consumeLaunch(); expect(openProject).toHaveBeenCalledTimes(1); expect(openProject).toHaveBeenCalledWith("/p/launch"); }); + + it("opens launched files in LightEdit without a project", async () => { + windowTakeLaunch.mockResolvedValueOnce({ project: null, files: ["/x/a.txt"] }); + await consumeLaunch(); + expect(windowOpenFiles).not.toHaveBeenCalled(); + expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt"); + expect(opened).toEqual(["/x/a.txt (focus)"]); + }); + + it("opens launched files in the launched project", async () => { + windowTakeLaunch.mockResolvedValueOnce({ project: "/x/notes", files: ["/x/notes/a.txt"] }); + await consumeLaunch(); + expect(openProject).toHaveBeenCalledWith("/x/notes"); + expect(lightEditOpen).not.toHaveBeenCalled(); + expect(opened).toEqual(["/x/notes/a.txt (focus)"]); + }); }); diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 45ed7bf6..b2257fc1 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -99,9 +99,9 @@ async function onCloseRequested(deps: CloseDeps): Promise { try { const ctx = await commands.windowCloseContext(); const s = useProject.getState(); - // Closing the last project window closes its project and shows the welcome screen - // (to open another one); closing the welcome screen, or quitting, closes the window. - if (!ctx.quitting && ctx.windows <= 1 && s.project) { + // Closing the last project (or LightEdit) window closes its project and shows the welcome + // screen (to open another one); closing the welcome screen, or quitting, closes the window. + if (!ctx.quitting && ctx.windows <= 1 && (s.project || s.lightEdit)) { if (await confirmLeave(deps)) await closeProject(); return false; } @@ -148,7 +148,7 @@ export function installCloseGuard(deps: CloseDeps = defaultDeps): () => void { else dirtyDocs.delete(e.doc); }), useProject.subscribe((s, prev) => { - if (s.project?.root !== prev.project?.root) dirtyDocs.clear(); + if (s.project?.root !== prev.project?.root || s.lightEdit !== prev.lightEdit) dirtyDocs.clear(); }), provideLeaveProjectGuard(() => confirmLeave(deps)), ]; diff --git a/src/features/app/index.ts b/src/features/app/index.ts index e40f925d..687d3d63 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -2,13 +2,14 @@ * Desktop shell integration: native menu bridge, multi-window, window title, * close / quit guards, About, Keymap Reference, logs, update check and i18n wiring. */ -import { registerOverlay } from "@/workbench/contributions"; +import { registerOverlay, registerStatusItem } from "@/workbench/contributions"; import { AboutOverlay } from "./AboutDialog"; import { appActions, checkForUpdatesOnStartup, updateActions } from "./appActions"; import { installCloseGuard } from "./closeGuard"; import { InvalidateCachesOverlay, registerInvalidateCachesActions } from "./InvalidateCaches"; import { buildInfo } from "./host"; import { KeymapReferenceOverlay } from "./KeymapReference"; +import { LightEditStatus } from "./LightEditStatus"; import { installLocaleSync, registerLocalizedActions } from "./locale"; import { installMenuBridge } from "./menuBridge"; import { NewProjectOverlay } from "./NewProject"; @@ -27,6 +28,13 @@ export function activate(): () => void { registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }), registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }), registerOverlay({ id: "app.newProject", component: NewProjectOverlay }), + registerStatusItem({ + id: "app.lightEdit", + align: "left", + priority: 20, + component: LightEditStatus, + lightEdit: true, + }), registerLocalizedActions(projectActions), registerPowerSaveBanner(), registerInvalidateCachesActions(), diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 3a65b3c1..6d720694 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -2,14 +2,26 @@ * Multi-window: one project per window. New windows are created by the * backend (`window_open_project` / `window_new`) and told what to open via a * pending launch, taken here on startup and on `openidea://launch`. + * + * Loose files opened without a project (dropped on the Welcome screen, File › + * Open File…, a file path on the command line) open in LightEdit: a window with + * editor tabs only. One LightEdit window collects them all. */ import type { Action } from "@/platform/actions"; import { t } from "@/platform/i18n"; import { call, commands, isTauri } from "@/platform/ipc"; -import { basename, confirmLeaveProject, openProject, provideStartupLaunch, useProject } from "@/platform/project"; -import { connectRemote } from "@/platform/remote"; +import { + basename, + confirmLeaveProject, + dirname, + openLightEdit, + openProject, + provideStartupLaunch, + useProject, +} from "@/platform/project"; +import { connectRemote, isRemoteWindow } from "@/platform/remote"; import { holdSplash } from "@/platform/splash"; -import { notifications } from "@/workbench/contributions"; +import { editorService, maybeEditorService, notifications } from "@/workbench/contributions"; import { chooseProjectWindow } from "@/workbench/projectWindow"; export const LAUNCH_EVENT = "openidea://launch"; @@ -24,59 +36,126 @@ function desktopOnly(): boolean { return true; } -async function openHere(path: string) { - if (useProject.getState().project?.root === path) return; - if (!(await confirmLeaveProject())) return; +function openFailed(path: string, e: unknown) { + notifications().notify({ + level: "error", + title: t("app.window.openFailed", { name: basename(path) }), + body: errorText(e), + key: `app.open:${path}`, + }); +} + +/** Opens project `path` in this window; true once it is this window's project. */ +async function openHere(path: string): Promise { + if (useProject.getState().project?.root === path) return true; + if (!(await confirmLeaveProject())) return false; try { await openProject(path); + return true; } catch (e) { - notifications().notify({ - level: "error", - title: t("app.window.openFailed", { name: basename(path) }), - body: errorText(e), - key: `app.open:${path}`, - }); + openFailed(path, e); + return false; } } +/** Opens `files` as editor tabs of this window, focusing the last. */ +async function openInEditor(files: readonly string[]): Promise { + for (const [i, path] of files.entries()) + await editorService() + .openFile(path, { focus: i === files.length - 1 }) + .catch((e) => console.warn(`could not open ${path}`, e)); +} + +/** Opens `files` in this window: in its project's editor or, without one, in LightEdit. */ +async function openFilesHere(files: readonly string[]): Promise { + const s = useProject.getState(); + if (!s.project && !s.lightEdit) { + try { + await openLightEdit(files[0]!); + } catch (e) { + return openFailed(files[0]!, e); + } + } + await openInEditor(files); +} + +/** + * Opens loose `files`: in this window's local project or LightEdit session, else + * in the LightEdit window if there is one, else this window turns into LightEdit + * (a remote project window gets a new one). + */ +export async function openFiles(files: readonly string[]): Promise { + if (!files.length) return; + const s = useProject.getState(); + if (s.lightEdit || (s.project && !isRemoteWindow())) return openInEditor(files); + if (desktopOnly()) return; + try { + const outcome = await call(commands.windowOpenFiles([...files])); + if (outcome.kind === "current") await openFilesHere(files); + } catch (e) { + openFailed(files[0]!, e); + } +} + +const pathIsDir = (path: string) => call(commands.fsReadDir(path)).then(() => true); + +/** Opens dropped paths: files as loose files ({@link openFiles}), the first folder as a project. */ +export async function openPaths( + paths: readonly string[], + isDir: (path: string) => Promise = pathIsDir, +): Promise { + const kinds = await Promise.all(paths.map((p) => isDir(p).catch(() => false))); + const files = paths.filter((_, i) => !kinds[i]); + const dir = paths.find((_, i) => kinds[i]); + await openFiles(files); + if (dir) await openProjectWithPolicy(dir); +} + /** * Opens `path`, focusing the window that already has it. `newWindow`: always a - * new window; otherwise this window unless it already has a project. + * new window; otherwise this window unless it already has a project or LightEdit + * session. `files` then open in the project's editor. */ -export async function openProjectInWindow(path: string, newWindow: boolean): Promise { +export async function openProjectInWindow(path: string, newWindow: boolean, files: readonly string[] = []) { if (desktopOnly()) return; - const inNew = newWindow || useProject.getState().project !== null; + const s = useProject.getState(); + const inNew = newWindow || s.project !== null || s.lightEdit; try { - const outcome = await call(commands.windowOpenProject(path, inNew)); - if (outcome.kind === "current") await openHere(path); + const outcome = await call(commands.windowOpenProject(path, inNew, [...files])); + if (outcome.kind === "current" && (await openHere(path))) await openInEditor(files); } catch (e) { - notifications().notify({ - level: "error", - title: t("app.window.openFailed", { name: basename(path) }), - body: errorText(e), - key: `app.open:${path}`, - }); + openFailed(path, e); } } -/** Opens `path` following "Open project in" when this window already has a project. */ -export async function openProjectWithPolicy(path: string): Promise { +/** + * Opens `path` following "Open project in" when this window already has a + * project or LightEdit session, then `files` in its editor. + */ +export async function openProjectWithPolicy(path: string, files: readonly string[] = []): Promise { if (desktopOnly()) return; - const current = useProject.getState().project; - if (!current) return openProjectInWindow(path, false); - if (current.root === path) return; + const { project: current, lightEdit } = useProject.getState(); + if (!current && !lightEdit) return openProjectInWindow(path, false, files); + if (current?.root === path) return openInEditor(files); const mode = await chooseProjectWindow(basename(path)); if (!mode) return; - if (mode === "new") return openProjectInWindow(path, true); + if (mode === "new") return openProjectInWindow(path, true, files); try { // A window that already has the project is focused instead. - const outcome = await call(commands.windowOpenProject(path, false)); - if (outcome.kind === "current") await openHere(path); + const outcome = await call(commands.windowOpenProject(path, false, [...files])); + if (outcome.kind === "current" && (await openHere(path))) await openInEditor(files); } catch { - await openHere(path); + if (await openHere(path)) await openInEditor(files); } } +/** LightEdit: opens the current file's folder as a project ("Open project in" applies) and the file there. */ +export async function openLightEditFileAsProject(): Promise { + if (!useProject.getState().lightEdit) return; + const path = maybeEditorService()?.activePath(); + if (path) await openProjectWithPolicy(dirname(path), [path]); +} + /** System Settings › Project › "Default directory" for folder pickers. */ export function defaultProjectDirectory(): string | undefined { return useProject.getState().settings?.general?.defaultProjectDirectory?.trim() || undefined; @@ -89,11 +168,20 @@ async function pickFolder(title: string): Promise { return path ?? null; } -/** Takes this window's pending launch (if any) and opens its project. */ +async function pickFiles(title: string): Promise { + const { open } = await import("@tauri-apps/plugin-dialog"); + const picked = await open({ directory: false, multiple: true, title, defaultPath: defaultProjectDirectory() }); + return picked === null ? [] : Array.isArray(picked) ? picked : [picked]; +} + +/** Takes this window's pending launch (if any) and opens its project and files. */ export async function consumeLaunch(): Promise { if (!isTauri()) return; const launch = await commands.windowTakeLaunch().catch(() => null); - if (launch?.project) await openHere(launch.project); + const files = launch?.files ?? []; + if (launch?.project) { + if (await openHere(launch.project)) await openInEditor(files); + } else if (files.length) await openFilesHere(files); else if (launch?.remote) { const { sshConfig, path } = launch.remote; await connectRemote(sshConfig, path).catch((e) => @@ -121,6 +209,36 @@ export function windowActions(): Action[] { if (path) await openProjectInWindow(path, true); }, }, + { + id: "file.openFile", + title: t("app.action.openFile"), + category: t("app.category.file"), + icon: "file", + keywords: ["file", "light edit"], + run: async () => { + if (desktopOnly()) return; + await openFiles(await pickFiles(t("app.window.pickFileTitle"))); + }, + }, + { + // Files and folders dropped on the Welcome screen. + id: "file.openPaths", + title: "Open Paths", + category: t("app.category.file"), + hidden: true, + run: async (c) => { + if (Array.isArray(c.arg)) await openPaths(c.arg.filter((p): p is string => typeof p === "string")); + }, + }, + { + id: "lightEdit.openProject", + title: t("app.action.lightEditOpenProject"), + category: t("app.category.file"), + icon: "folder-open", + keywords: ["light edit", "project", "folder"], + when: "lightEdit", + run: openLightEditFileAsProject, + }, { id: "file.openRecentProject", title: t("app.action.openRecentProject"), diff --git a/src/features/vim/index.ts b/src/features/vim/index.ts index 9fbcef2d..7ccdba17 100644 --- a/src/features/vim/index.ts +++ b/src/features/vim/index.ts @@ -77,7 +77,14 @@ export function activate(): () => void { syncVimSettingsFromStorage(), registerKeyClaim(vimKeyClaim(() => useVimSettings.getState())), activateSettingsPages(), - registerStatusItem({ id: "vim.mode", align: "right", priority: 45, component: VimStatus, title: "Vim" }), + registerStatusItem({ + id: "vim.mode", + align: "right", + priority: 45, + component: VimStatus, + title: "Vim", + lightEdit: true, + }), registerActions([ { id: "vim.toggle", diff --git a/src/features/welcome/ProjectPopup.tsx b/src/features/welcome/ProjectPopup.tsx index 6aa42ca9..0dead4f7 100644 --- a/src/features/welcome/ProjectPopup.tsx +++ b/src/features/welcome/ProjectPopup.tsx @@ -72,7 +72,7 @@ export function ProjectPopup({ anchor, onClose }: ProjectPopupProps) { if (!e) return; onClose(); if (e.kind === "action") void executeAction(e.id); - else if (e.open) void call(commands.windowOpenProject(e.m.project.path, false)).catch(() => undefined); + else if (e.open) void call(commands.windowOpenProject(e.m.project.path, false, [])).catch(() => undefined); else void openRecent(e.m.project); }; diff --git a/src/features/welcome/WelcomeView.tsx b/src/features/welcome/WelcomeView.tsx index a9f276c9..8fc8307b 100644 --- a/src/features/welcome/WelcomeView.tsx +++ b/src/features/welcome/WelcomeView.tsx @@ -84,7 +84,10 @@ function NavItem({ ); } -/** Folders dropped from the OS open as a project (Tauri drag-drop events carry real paths). */ +/** + * Paths dropped from the OS (Tauri drag-drop events carry real paths): a folder opens + * as a project, files open in LightEdit. + */ function useFolderDrop(): boolean { const [over, setOver] = useState(false); useEffect(() => { @@ -97,8 +100,9 @@ function useFolderDrop(): boolean { const p = event.payload; if (p.type === "enter" || p.type === "over") return setOver(true); setOver(false); - if (p.type === "drop" && p.paths[0] && !useProject.getState().project) - void executeAction("file.openProjectPath", p.paths[0]); + const { project, lightEdit } = useProject.getState(); + if (p.type === "drop" && p.paths.length && !project && !lightEdit) + void executeAction("file.openPaths", p.paths); }) .then((u) => { if (disposed) u(); @@ -194,7 +198,7 @@ export function WelcomeView() { {dropping && (
- Drop a folder to open it as a project + Drop a folder to open it as a project, or files to edit them
)} diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 7471f4ea..e1712572 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -17,8 +17,10 @@ "app.action.copy": "Copy", "app.action.cut": "Cut", "app.action.keymapReference": "Keymap Reference", + "app.action.lightEditOpenProject": "Open Containing Folder as Project", "app.action.newWindow": "New Window", "app.action.nextWindow": "Next Project Window", + "app.action.openFile": "Open File…", "app.action.openInNewWindow": "Open in New Window…", "app.action.openRecentProject": "Open Recent Project", "app.action.paste": "Paste", @@ -44,6 +46,8 @@ "app.keymap.other": "Other", "app.keymap.preset": "Keymap: {preset}", "app.keymap.title": "Keymap Reference", + "app.lightEdit.openProject": "Open as Project", + "app.lightEdit.openProjectTooltip": "Open {folder} as a project", "app.log.failed": "Cannot show the log", "app.title.default": "OpenIDEA", "app.unsaved.body": "{count, plural, one {# file has} other {# files have}} unsaved changes in {project}. Save before closing?", @@ -60,6 +64,7 @@ "app.update.none": "OpenIDEA {version} is up to date", "app.window.desktopOnly": "Multiple windows require the desktop app", "app.window.openFailed": "Cannot open {name}", + "app.window.pickFileTitle": "Open File", "app.window.pickTitle": "Open in New Window", "app.window.askTitle": "Open Project", "app.window.askBody": "Projects can either be opened in a new window or replace the project in the current window.\nHow would you like to open \"{name}\"?", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e69a2ec3..b6ca4bed 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -571,7 +571,7 @@ "Drop Commit": "丢弃提交", "Drop Commits": "丢弃提交", "Drop Stash": "丢弃储藏", - "Drop a folder to open it as a project": "拖放文件夹以将其作为项目打开", + "Drop a folder to open it as a project, or files to edit them": "拖放文件夹以将其作为项目打开,或拖放文件进行编辑", "Drop files here to open them": "将文件拖放到此处以打开", "Drop syntax trees and EditorConfig caches, compact the index": "丢弃语法树和 EditorConfig 缓存,压缩索引", "Drop…": "删除…", @@ -993,6 +993,7 @@ "Leave a comment (required)": "留下评论(必填)", "Left Bottom": "左下", "Left Top": "左上", + "LightEdit": "轻量编辑", "Limit body line": "限制正文行", "Limit history to": "历史记录限制为", "Limit subject line": "限制主题行", @@ -1253,6 +1254,7 @@ "Open Folder": "打开文件夹", "Open In": "打开方式", "Open In Right Split": "在右侧拆分中打开", + "Open Paths": "打开路径", "Open Project Path": "打开项目路径", "Open Projects": "打开的项目", "Open Recent…": "打开最近的项目…", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 8a1625ce..2a56b704 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -17,8 +17,10 @@ "app.action.copy": "复制", "app.action.cut": "剪切", "app.action.keymapReference": "快捷键参考", + "app.action.lightEditOpenProject": "将所在文件夹作为项目打开", "app.action.newWindow": "新建窗口", "app.action.nextWindow": "下一个项目窗口", + "app.action.openFile": "打开文件…", "app.action.openInNewWindow": "在新窗口中打开…", "app.action.openRecentProject": "打开最近的项目", "app.action.paste": "粘贴", @@ -44,6 +46,8 @@ "app.keymap.other": "其他", "app.keymap.preset": "键位映射:{preset}", "app.keymap.title": "快捷键参考", + "app.lightEdit.openProject": "作为项目打开", + "app.lightEdit.openProjectTooltip": "将 {folder} 作为项目打开", "app.log.failed": "无法显示日志", "app.title.default": "OpenIDEA", "app.unsaved.body": "{project} 中有 {count} 个文件尚未保存。关闭前是否保存?", @@ -60,6 +64,7 @@ "app.update.none": "OpenIDEA {version} 已是最新版本", "app.window.desktopOnly": "多窗口需要桌面应用", "app.window.openFailed": "无法打开 {name}", + "app.window.pickFileTitle": "打开文件", "app.window.pickTitle": "在新窗口中打开", "app.window.askTitle": "打开项目", "app.window.askBody": "项目可以在新窗口中打开,也可以替换当前窗口中的项目。\n要如何打开“{name}”?", diff --git a/src/platform/project.ts b/src/platform/project.ts index e5888bd6..6430bb37 100644 --- a/src/platform/project.ts +++ b/src/platform/project.ts @@ -8,9 +8,16 @@ interface ProjectState { /** Effective settings (project-layered when a project is open). */ settings: Settings | null; opening: boolean; + /** LightEdit: this window edits loose files without a project (`project` stays null). */ + lightEdit: boolean; } -export const useProject = create(() => ({ project: null, settings: null, opening: false })); +export const useProject = create(() => ({ + project: null, + settings: null, + opening: false, + lightEdit: false, +})); let leaveGuard: (() => Promise) | null = null; @@ -24,7 +31,8 @@ export function provideLeaveProjectGuard(guard: () => Promise): () => v /** False when the user chose to stay (another project in place, Close Project). */ export function confirmLeaveProject(): Promise { - return useProject.getState().project && leaveGuard ? leaveGuard() : Promise.resolve(true); + const s = useProject.getState(); + return (s.project || s.lightEdit) && leaveGuard ? leaveGuard() : Promise.resolve(true); } let startupLaunch: Promise | null = null; @@ -43,16 +51,22 @@ export async function openProject(path: string): Promise { useProject.setState({ opening: true }); try { const project = await call(commands.openProject(path)); - useProject.setState({ project, settings: project.settings }); + useProject.setState({ project, settings: project.settings, lightEdit: false }); return project; } finally { useProject.setState({ opening: false }); } } +/** Switches this window to LightEdit for the loose file `path` (kept when already in LightEdit). */ +export async function openLightEdit(path: string): Promise { + const settings = await call(commands.lightEditOpen(path)); + useProject.setState({ project: null, settings, lightEdit: true }); +} + export async function closeProject() { await commands.closeProject(); - useProject.setState({ project: null }); + useProject.setState({ project: null, lightEdit: false }); } export async function loadSettings() { diff --git a/src/workbench/StatusBar.tsx b/src/workbench/StatusBar.tsx index bc737643..02ff97f1 100644 --- a/src/workbench/StatusBar.tsx +++ b/src/workbench/StatusBar.tsx @@ -23,10 +23,14 @@ export function orderStatusItems(items: Iterable) { return { left, right }; } -export function StatusBar() { +/** `lightEdit`: a LightEdit window's bar, with the items that apply there only. */ +export function StatusBar({ lightEdit = false }: { lightEdit?: boolean }) { const map = useContributions((s) => s.statusItems); const overrides = useStatusWidgetOverrides(); - const { left, right } = useMemo(() => orderStatusItems(map.values()), [map]); + const { left, right } = useMemo( + () => orderStatusItems([...map.values()].filter((i) => !lightEdit || i.lightEdit)), + [map, lightEdit], + ); const visible = (i: StatusItemContribution) => isStatusItemVisible(i, overrides); const render = (item: StatusItemContribution) => { const C = item.component; diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index eb77cee5..17306b2b 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -258,16 +258,19 @@ function ToolbarEntry({ id, items }: { id: string; items: ReadonlyMap s.overlayTitleBar); @@ -290,12 +293,13 @@ export function TitleBar({ ); const entry = (id: string) => ; + const toolbar = !minimal && !lightEdit; return (
{ - if (minimal || e.defaultPrevented) return; + if (!toolbar || e.defaultPrevented) return; e.preventDefault(); showContextMenu(e, menu); }} @@ -306,11 +310,11 @@ export function TitleBar({ {typeof title === "string" ? {title} : title} )} - {!minimal && !menuExpanded && shown.left.map(entry)} + {toolbar && !menuExpanded && shown.left.map(entry)}
{!minimal && ( <> - {shown.right.map(entry)} + {toolbar && shown.right.map(entry)}
diff --git a/src/workbench/Workbench.module.css b/src/workbench/Workbench.module.css index 4fac29a0..48723829 100644 --- a/src/workbench/Workbench.module.css +++ b/src/workbench/Workbench.module.css @@ -17,6 +17,28 @@ var(--bg-frame); } +/* LightEdit: the editor as the only island, inset like the project frame's. */ +.lightEditFrame { + background: var(--bg-frame); +} +.lightEdit { + flex: 1; + display: flex; + min-width: 0; + min-height: 0; + padding: 0 var(--island-gap); +} +.lightEditIsland { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--bg-island); + border-radius: var(--island-radius); +} + /* Settings › Appearance › "Use project colors in main toolbar" off. */ :global(:root[data-project-colors="off"]) .framed { background: var(--bg-frame); diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index 1d11b3e3..988ae147 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -1,10 +1,11 @@ /** * The IDEA-style shell (Islands): title bar, tool window stripes and island * panels around the editor area on the frame background, status bar, and the - * overlay layer. Without an open project the - * welcome view replaces the frame. + * overlay layer. Without an open project the welcome view replaces the frame, + * or, editing loose files (LightEdit), a frame with the editor only. */ -import { useProject } from "@/platform/project"; +import { useEditorRegistry } from "@/editor/api"; +import { basename, useProject } from "@/platform/project"; import { useDistractionFree } from "./distractionFree"; import { EditorArea } from "./EditorArea"; import { OverlayHost } from "./OverlayHost"; @@ -16,15 +17,34 @@ import { ToolWindowLayout } from "./toolwindows/ToolWindows"; import { Welcome } from "./Welcome"; import styles from "./Workbench.module.css"; +/** LightEdit: editor tabs only; no tool windows, toolbar widgets or project status items. */ +function LightEditFrame() { + const path = useEditorRegistry((s) => s.active?.ctx.path); + const distractionFree = useDistractionFree((s) => s.active); + return ( + <> + +
+
+ +
+
+ {!distractionFree && } + + ); +} + export function Workbench() { useApplyTheme(); const project = useProject((s) => s.project); + const lightEdit = useProject((s) => s.lightEdit); const root = project?.root ?? null; const distractionFree = useDistractionFree((s) => s.active); + const frame = project ? styles.framed : lightEdit ? styles.lightEditFrame : undefined; return (
{project ? ( @@ -34,6 +54,8 @@ export function Workbench() { } /> {!distractionFree && } + ) : lightEdit ? ( + ) : ( )} diff --git a/src/workbench/activate.ts b/src/workbench/activate.ts index 2c109e83..61563359 100644 --- a/src/workbench/activate.ts +++ b/src/workbench/activate.ts @@ -43,11 +43,12 @@ export function activateWorkbench(): () => void { // Synchronous so the frame's first render after opening already has the project's layout. const onProject = (root: string | null) => { setContext("projectOpen", root !== null); + setContext("lightEdit", useProject.getState().lightEdit); if (root !== null) loadToolWindowLayout(root); }; onProject(useProject.getState().project?.root ?? null); const unsubProject = useProject.subscribe((s, prev) => { - if (s.project?.root === prev.project?.root) return; + if (s.project?.root === prev.project?.root && s.lightEdit === prev.lightEdit) return; // The previous project's notifications (and their actions) no longer apply. if (prev.project) clearNotifications(); onProject(s.project?.root ?? null); @@ -65,8 +66,8 @@ export function activateWorkbench(): () => void { installBrowserGuards(), installFileDrop(), registerOverlay({ id: "workbench.customizeToolbar", component: MainToolbarDialogHost }), - registerStatusItem({ id: "workbench.tasks", align: "right", priority: 0, component: TaskStatus }), - registerStatusItem({ id: "workbench.info", align: "left", priority: 5, component: StatusInfo }), + registerStatusItem({ id: "workbench.tasks", align: "right", priority: 0, component: TaskStatus, lightEdit: true }), + registerStatusItem({ id: "workbench.info", align: "left", priority: 5, component: StatusInfo, lightEdit: true }), registerToolWindow({ id: NOTIFICATIONS_TOOL_WINDOW, title: "Notifications", diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 509f6715..7053b177 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -86,6 +86,8 @@ export interface StatusItemContribution { title?: string; /** Shown until the user decides otherwise (default true). */ defaultVisible?: boolean; + /** Also shown in a LightEdit window (editor tabs only), whose status bar has only such items. */ + lightEdit?: boolean; } /** Title bar widget (VCS branch, run configuration, run/debug buttons…). */ diff --git a/src/workbench/fileDrop.test.ts b/src/workbench/fileDrop.test.ts index 5059827b..a572ad01 100644 --- a/src/workbench/fileDrop.test.ts +++ b/src/workbench/fileDrop.test.ts @@ -91,4 +91,12 @@ describe("files dropped from the OS", () => { expect(handleFileDrop(["/a.ts"], 1, 1, d)).toBeNull(); expect(handleFileDrop([], 1, 1, d)).toBeNull(); }); + + it("opens drops on a LightEdit window as more tabs there; a folder still opens as a project", async () => { + const { d, calls } = deps(["/work/app"]); + useProject.setState({ project: null, lightEdit: true } as never); + await handleFileDrop(["/x/b.txt", "/work/app"], 1, 1, d); + expect(calls).toEqual(["file /x/b.txt (focus)", "project /work/app"]); + useProject.setState({ lightEdit: false } as never); + }); }); diff --git a/src/workbench/fileDrop.ts b/src/workbench/fileDrop.ts index 58c20f3a..b3d44656 100644 --- a/src/workbench/fileDrop.ts +++ b/src/workbench/fileDrop.ts @@ -1,7 +1,7 @@ /** - * Files dropped from the OS onto the project window open in the editor, one - * tab each; a dropped folder opens as a project (the "Open project in" policy - * applies). Regions marked with `OWNS_FILE_DROP` (Project view, prompt box) + * Files dropped from the OS onto the project (or LightEdit) window open in the + * editor, one tab each; a dropped folder opens as a project (the "Open project in" + * policy applies). Regions marked with `OWNS_FILE_DROP` (Project view, prompt box) * handle their drops themselves. */ import { executeAction } from "@/platform/actions"; @@ -39,14 +39,15 @@ const defaultDeps: FileDropDeps = { openProject: async (path) => void (await executeAction("file.openProjectPath", path)), }; -/** Handles one drop; null when it is left to another handler (no local project: the Welcome screen). */ +/** Handles one drop; null when it is left to another handler (no local project or LightEdit: the Welcome screen). */ export function handleFileDrop( paths: readonly string[], x: number, y: number, deps: FileDropDeps = defaultDeps, ): Promise | null { - if (!paths.length || !useProject.getState().project || isRemoteWindow() || dropOwnedAt(x, y)) return null; + const { project, lightEdit } = useProject.getState(); + if (!paths.length || !(project || lightEdit) || isRemoteWindow() || dropOwnedAt(x, y)) return null; return openDropped(paths, deps); } diff --git a/src/workbench/lightEdit.test.tsx b/src/workbench/lightEdit.test.tsx new file mode 100644 index 00000000..fffebdc1 --- /dev/null +++ b/src/workbench/lightEdit.test.tsx @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { useEditorRegistry } from "@/editor/api"; +import { evaluateWhen } from "@/platform/context"; +import { useProject } from "@/platform/project"; +import { activateWorkbench } from "./activate"; +import { registerStatusItem, registerTitleItem, registerToolWindow } from "./contributions"; +import { chooseProjectWindow } from "./projectWindow"; +import { Workbench } from "./Workbench"; + +/** A LightEdit window (loose files, no project) with project features registered as in a full window. */ +describe("LightEdit window", () => { + const disposers: Array<() => void> = []; + beforeEach(() => { + useProject.setState({ project: null, lightEdit: true, settings: null }); + useEditorRegistry.setState({ active: { ctx: { path: "/x/notes/todo.md" } } } as never); + disposers.push( + registerToolWindow({ + id: "project", + title: "Project", + icon: "folder", + anchor: "left", + order: 0, + component: () =>
project tree
, + }), + registerTitleItem({ id: "vcs.branch", align: "left", priority: 0, component: () => branch widget }), + registerStatusItem({ id: "vcs.status", align: "right", priority: 1, component: () => git status }), + registerStatusItem({ + id: "editor.caret", + align: "right", + priority: 2, + component: () => 1:1, + lightEdit: true, + }), + ); + }); + afterEach(() => { + disposers.splice(0).forEach((d) => d()); + useProject.setState({ project: null, lightEdit: false }); + useEditorRegistry.setState({ active: null }); + }); + + it("shows the editor with the file name as title, without project features", () => { + render(); + expect(document.querySelector("[data-light-edit]")).not.toBeNull(); + expect(screen.getByText("todo.md")).toBeInTheDocument(); + expect(document.querySelector("[data-stripe-side]")).toBeNull(); + expect(screen.queryByText("branch widget")).toBeNull(); + const items = [...document.querySelectorAll("[data-status-item]")].map((el) => el.getAttribute("data-status-item")); + expect(items).toEqual(["editor.caret"]); + expect(screen.queryByText("Welcome to OpenIDEA")).toBeNull(); + }); + + it("sets the context keys that keep project actions off", () => { + const dispose = activateWorkbench(); + expect(evaluateWhen("lightEdit")).toBe(true); + expect(evaluateWhen("projectOpen")).toBe(false); + useProject.setState({ lightEdit: false }); + expect(evaluateWhen("lightEdit")).toBe(false); + dispose(); + }); + + it("applies 'Open project in' when opening a project from it", async () => { + useProject.setState({ settings: { general: { openProjectIn: "new" } } } as never); + expect(await chooseProjectWindow("notes")).toBe("new"); + useProject.setState({ lightEdit: false }); + expect(await chooseProjectWindow("notes")).toBe("same"); + }); +}); diff --git a/src/workbench/projectWindow.ts b/src/workbench/projectWindow.ts index d36cd283..2d83c114 100644 --- a/src/workbench/projectWindow.ts +++ b/src/workbench/projectWindow.ts @@ -12,12 +12,13 @@ export function openPolicy(): "ask" | "new" | "same" { } /** - * The window to open project `name` in: this one when it has no project, else + * The window to open project `name` in: this one when it has no project (or LightEdit session), else * per "Open project in", asking when set to (and remembering "Don't ask again"). * `null`: cancelled. */ export async function chooseProjectWindow(name: string): Promise<"new" | "same" | null> { - if (!useProject.getState().project) return "same"; + const { project, lightEdit } = useProject.getState(); + if (!project && !lightEdit) return "same"; const mode = openPolicy(); if (mode !== "ask") return mode; const r = await ask({