feat(workbench): Edit single files without opening a project (#145)

Files can now be opened without a project. A file dropped on the Welcome screen, picked with File › Open File…, or passed on the command line or to a second instance opens in a lightweight window: editor tabs only, with no project tree, indexing, language servers, VCS or project tool windows. Syntax highlighting, find, save, undo and Local History still work. More files opened later join the same window as new tabs. Open as Project in the status bar opens the file's directory as a full project, following the open-project policy, and reopens the file there.

Backend: a light session reuses Project with an unindexed index, no LSP or VCS, and a flat watch on each opened file's directory. It is never reused as a project for the same root. Previously a file passed at launch opened its parent directory as a project.
This commit is contained in:
l0ng-ai
2026-10-28 22:06:35 +08:00
committed by GitHub
parent eb67595e72
commit 2d7726cf53
40 changed files with 1042 additions and 140 deletions
+3
View File
@@ -8,6 +8,9 @@ pub async fn open(host: &Host, window: &str, path: PathBuf) -> IdeResult<Documen
host.blocking(move || {
let info = project.documents.open(&path)?;
project.index.mark_recent(&path);
if let Some(dir) = path.parent().filter(|_| project.light) {
project.watch_light_dir(dir);
}
Ok(info)
})
.await
+4 -3
View File
@@ -38,7 +38,7 @@ pub async fn open(host: &Arc<Host>, window: &str, path: &Path) -> IdeResult<Arc<
}
pub fn current(host: &Host, window: &str) -> Option<ProjectInfo> {
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 &current {
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();
+149 -23
View File
@@ -50,6 +50,9 @@ pub struct Project {
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
pub(crate) _scratch_watcher: Option<Watcher>,
/// 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<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
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<Arc<Project>> {
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<Documents>,
history: &Arc<LocalHistory>,
) -> IdeResult<Watcher> {
let (documents, history) = (documents.clone(), history.clone());
Watcher::start_with(
dir,
Vec::new(),
sink.clone(),
Box::new(move |batch| {
let changed: Vec<PathBuf> = 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);
+78
View File
@@ -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<Host> {
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");
}
+19 -1
View File
@@ -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<LanguageRegistry>,
syntax: Arc<Syntax>,
sink: SharedSink,
) -> IdeResult<Arc<Self>> {
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<Shared>, root: PathBuf, scan: bool) -> IdeResult<Arc<Self>> {
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 }))
+22
View File
@@ -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");
}
+8 -3
View File
@@ -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;
+33 -5
View File
@@ -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<Inner> {
// 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<Mutex<Vec<FsBatch>>> = 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")));
}
}
+17 -1
View File
@@ -61,6 +61,22 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
Ok(info)
}
/// 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.
#[tauri::command]
#[specta::specta]
pub async fn light_edit_open(window: Window, path: PathBuf) -> IdeResult<Settings> {
let app = window.app_handle().clone();
let label = window.label().to_owned();
if app.state::<AppState>().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::<AppState>().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<id
#[tauri::command]
#[specta::specta]
pub fn project_current(window: Window, state: State<'_, AppState>) -> Option<ProjectInfo> {
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]
+2
View File
@@ -59,6 +59,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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<tauri::Wry> {
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,
+18 -3
View File
@@ -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<OpenOutcome> {
windows::open_project_window(&app, &path, new_window)
pub async fn window_open_project(
app: AppHandle,
path: PathBuf,
new_window: bool,
files: Vec<PathBuf>,
) -> IdeResult<OpenOutcome> {
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<PathBuf>) -> IdeResult<OpenOutcome> {
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(),
+1 -1
View File
@@ -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::<Vec<_>>(), &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());
+2
View File
@@ -207,6 +207,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
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",
+6
View File
@@ -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<Arc<Project>> {
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);
+98 -16
View File
@@ -38,6 +38,9 @@ const SETTINGS_MIN_SIZE: (f64, f64) = (640.0, 420.0);
pub struct Launch {
/// Project folder to open.
pub project: Option<ide_core::IpcPath>,
/// 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<ide_core::IpcPath>,
/// Remote project to open (Remote Development).
pub remote: Option<RemoteLaunch>,
/// Settings windows: the page to show.
@@ -189,10 +192,26 @@ pub fn find_project_window(app: &AppHandle, root: &Path) -> Option<WebviewWindow
let state = app.try_state::<AppState>()?;
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<WebviewWindow> {
let state = app.try_state::<AppState>()?;
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<PathBuf>) {
let launch = Launch { files: files.into_iter().map(Into::into).collect(), ..Default::default() };
app.state::<WindowsState>().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::<AppState>().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<WebviewWindow> {
/// Creates a new project window; `project` is opened by its frontend on load.
pub fn create_window(app: &AppHandle, project: Option<PathBuf>) -> IdeResult<WebviewWindow> {
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<PathBuf>, files: Vec<PathBuf>) -> IdeResult<WebviewWindow> {
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<String>, launch: Launch) -> IdeResult<WebviewWindow> {
@@ -313,9 +339,13 @@ fn create_window_for(app: &AppHandle, title: Option<String>, 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<PathBuf>) {
spawn_window_with(app, project, Vec::new());
}
fn spawn_window_with(app: &AppHandle, project: Option<PathBuf>, files: Vec<PathBuf>) {
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<OpenOutcome> {
/// 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<PathBuf>,
) -> IdeResult<OpenOutcome> {
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<PathBuf>) -> IdeResult<OpenOutcome> {
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::<WindowsState>()
.set_launch(w.label(), Launch { project: Some(root.into()), ..Default::default() });
app.state::<WindowsState>().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();
+23 -1
View File
@@ -11,6 +11,11 @@ export const commands = {
recentProjects: () => __TAURI_INVOKE<RecentProject[]>("recent_projects"),
removeRecentProject: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remove_recent_project", { path })),
openProject: (path: string) => typedError<ProjectInfo_Serialize, IdeError>(__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<Settings_Serialize, IdeError>(__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<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_project", { path, newWindow })),
windowOpenProject: (path: string, newWindow: boolean, files: string[]) => typedError<OpenOutcome, IdeError>(__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<OpenOutcome, IdeError>(__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. */
+4 -1
View File
@@ -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();
+5
View File
@@ -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 () => {
+26
View File
@@ -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 (
<BarButton
emphasis
tooltip={t("app.lightEdit.openProjectTooltip", { folder: dirname(path) })}
onClick={() => void executeAction(LIGHT_EDIT_OPEN_PROJECT)}
>
<Icon name="folder-open" size={14} />
{t("app.lightEdit.openProject")}
</BarButton>
);
}
+158 -13
View File
@@ -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<typeof import("@/platform/ipc")>();
@@ -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)"]);
});
});
+4 -4
View File
@@ -99,9 +99,9 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
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)),
];
+9 -1
View File
@@ -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(),
+152 -34
View File
@@ -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<boolean> {
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<void> {
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<void> {
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<void> {
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<boolean> = pathIsDir,
): Promise<void> {
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<void> {
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<void> {
/**
* 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<void> {
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<void> {
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<string | null> {
return path ?? null;
}
/** Takes this window's pending launch (if any) and opens its project. */
async function pickFiles(title: string): Promise<string[]> {
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<void> {
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"),
+8 -1
View File
@@ -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",
+1 -1
View File
@@ -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);
};
+8 -4
View File
@@ -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 && (
<div className={styles.dropOverlay} aria-hidden>
<Icon name="folder-open" size={20} />
<T>Drop a folder to open it as a project</T>
<T>Drop a folder to open it as a project, or files to edit them</T>
</div>
)}
</section>
+5
View File
@@ -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}\"?",
+3 -1
View File
@@ -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…": "打开最近的项目…",
+5
View File
@@ -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}”?",
+18 -4
View File
@@ -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<ProjectState>(() => ({ project: null, settings: null, opening: false }));
export const useProject = create<ProjectState>(() => ({
project: null,
settings: null,
opening: false,
lightEdit: false,
}));
let leaveGuard: (() => Promise<boolean>) | null = null;
@@ -24,7 +31,8 @@ export function provideLeaveProjectGuard(guard: () => Promise<boolean>): () => v
/** False when the user chose to stay (another project in place, Close Project). */
export function confirmLeaveProject(): Promise<boolean> {
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<void> | null = null;
@@ -43,16 +51,22 @@ export async function openProject(path: string): Promise<ProjectInfo> {
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<void> {
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() {
+6 -2
View File
@@ -23,10 +23,14 @@ export function orderStatusItems(items: Iterable<StatusItemContribution>) {
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;
+7 -3
View File
@@ -258,16 +258,19 @@ function ToolbarEntry({ id, items }: { id: string; items: ReadonlyMap<string, Ti
* 40px main toolbar. macOS: native traffic lights overlay its left edge.
* Windows: the window is frameless, so the bar carries IDEA's main menu (☰) and
* the caption buttons. `dialog`: a dialog window's bar (Settings), close button only.
* `lightEdit`: a LightEdit window's bar, its title and the menu but no toolbar widgets.
*/
export function TitleBar({
minimal = false,
bare = false,
dialog = false,
lightEdit = false,
title,
}: {
minimal?: boolean;
bare?: boolean;
dialog?: boolean;
lightEdit?: boolean;
title?: string;
}) {
const overlay = usePlatform((s) => s.overlayTitleBar);
@@ -290,12 +293,13 @@ export function TitleBar({
</header>
);
const entry = (id: string) => <ToolbarEntry key={id} id={id} items={map} />;
const toolbar = !minimal && !lightEdit;
return (
<header
className={clsx(className, minimal && styles.minimal, dialog && styles.dialogBar)}
data-tauri-drag-region
onContextMenu={(e) => {
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" ? <T>{title}</T> : title}
</span>
)}
{!minimal && !menuExpanded && shown.left.map(entry)}
{toolbar && !menuExpanded && shown.left.map(entry)}
<div className={styles.drag} data-tauri-drag-region />
{!minimal && (
<>
{shown.right.map(entry)}
{toolbar && shown.right.map(entry)}
<div className={styles.tools}>
<GearButton customize={menu} />
</div>
+22
View File
@@ -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);
+26 -4
View File
@@ -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 (
<>
<TitleBar lightEdit bare={distractionFree} title={path ? basename(path) : "LightEdit"} />
<main className={styles.lightEdit} data-light-edit>
<div className={styles.lightEditIsland}>
<EditorArea />
</div>
</main>
{!distractionFree && <StatusBar lightEdit />}
</>
);
}
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 (
<div
className={project ? `${styles.workbench} ${styles.framed}` : styles.workbench}
className={frame ? `${styles.workbench} ${frame}` : styles.workbench}
style={project ? projectColorVars(project.name) : undefined}
>
{project ? (
@@ -34,6 +54,8 @@ export function Workbench() {
<ToolWindowLayout key={root} editor={<EditorArea />} />
{!distractionFree && <StatusBar />}
</>
) : lightEdit ? (
<LightEditFrame />
) : (
<Welcome />
)}
+4 -3
View File
@@ -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",
+2
View File
@@ -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…). */
+8
View File
@@ -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);
});
});
+6 -5
View File
@@ -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<void> | 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);
}
+69
View File
@@ -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: () => <div>project tree</div>,
}),
registerTitleItem({ id: "vcs.branch", align: "left", priority: 0, component: () => <span>branch widget</span> }),
registerStatusItem({ id: "vcs.status", align: "right", priority: 1, component: () => <span>git status</span> }),
registerStatusItem({
id: "editor.caret",
align: "right",
priority: 2,
component: () => <span>1:1</span>,
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(<Workbench />);
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");
});
});
+3 -2
View File
@@ -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({