дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 14:15:39 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
c380c4cc1f |
No files matched your search
@@ -17,30 +17,11 @@ const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else
|
||||
|
||||
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
|
||||
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Discovered, String> {
|
||||
let prefer_stored = crate::provider::prefer_stored_copilot_auth();
|
||||
match run_once(shared, spec, prefer_stored).await {
|
||||
Err(err)
|
||||
if !prefer_stored
|
||||
&& crate::provider::is_copilot_provider(&spec.id)
|
||||
&& crate::provider::has_unconfigured_copilot_ambient_token(&spec.env)
|
||||
&& super::auth_required(0, &err) =>
|
||||
{
|
||||
let retry = run_once(shared, spec, true).await;
|
||||
if retry.is_ok() {
|
||||
crate::provider::mark_prefer_stored_copilot_auth();
|
||||
}
|
||||
retry
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_once(shared: &Arc<Shared>, spec: &ProviderSpec, strip_ambient: bool) -> Result<Discovered, String> {
|
||||
let root = shared.paths.project_root.clone();
|
||||
let launch = spec.clone();
|
||||
let launch = tokio::task::spawn_blocking({
|
||||
let root = root.clone();
|
||||
move || launch.resolve_for_launch_with_copilot_env(&root, strip_ambient)
|
||||
move || launch.resolve_for_launch(&root)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -120,18 +120,10 @@ impl ProviderSpec {
|
||||
}
|
||||
|
||||
/// Resolve once before connecting, so authentication guidance names that same install.
|
||||
pub(crate) fn resolve_for_launch(self, cwd: &Path) -> Self {
|
||||
let strip = prefer_stored_copilot_auth();
|
||||
self.resolve_for_launch_with_copilot_env(cwd, strip)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_for_launch_with_copilot_env(mut self, cwd: &Path, strip_ambient_tokens: bool) -> Self {
|
||||
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
|
||||
if self.driver != Driver::Acp {
|
||||
self.command = crate::native::newest_install(&self.command);
|
||||
}
|
||||
if is_copilot_provider(&self.id) {
|
||||
self.env = sanitized_copilot_env(&self.env, strip_ambient_tokens);
|
||||
}
|
||||
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
|
||||
self.command =
|
||||
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
|
||||
@@ -141,69 +133,6 @@ impl ProviderSpec {
|
||||
}
|
||||
}
|
||||
|
||||
const AMBIENT_COPILOT_AUTH_VARS: [&str; 3] = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
|
||||
static PREFER_STORED_COPILOT_AUTH: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
pub(crate) fn is_copilot_provider(id: &str) -> bool {
|
||||
id == "copilot" || id.starts_with("copilot-")
|
||||
}
|
||||
|
||||
pub(crate) fn prefer_stored_copilot_auth() -> bool {
|
||||
PREFER_STORED_COPILOT_AUTH.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_prefer_stored_copilot_auth() {
|
||||
PREFER_STORED_COPILOT_AUTH.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
pub(crate) fn has_unconfigured_copilot_ambient_token(spec_env: &[(String, String)]) -> bool {
|
||||
AMBIENT_COPILOT_AUTH_VARS.iter().any(|&key| {
|
||||
!spec_env.iter().any(|(k, v)| k == key && !v.is_empty())
|
||||
&& ambient_copilot_env_value(key).is_some_and(|v| !v.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn ambient_copilot_env_value(key: &str) -> Option<String> {
|
||||
ide_core::process::shell_env()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| v.to_string_lossy().into_owned())
|
||||
.or_else(|| ide_core::process::host_var_os(key).map(|v| v.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Builds child environment overrides for `copilot`:
|
||||
/// - Always clears ambient classic PATs (`ghp_…`), which Copilot CLI unconditionally rejects
|
||||
/// (`"Classic PATs are not supported"`) instead of falling back to Keychain / `gh` login.
|
||||
/// - When `strip_ambient_tokens` is true, clears unconfigured ambient `GITHUB_TOKEN` / `GH_TOKEN` /
|
||||
/// `COPILOT_GITHUB_TOKEN` so Copilot uses its stored login (`~/.copilot` or `gh auth` keyring),
|
||||
/// while preserving any token explicitly configured in OpenIDEA's agent `spec_env`.
|
||||
pub(crate) fn sanitized_copilot_env(
|
||||
spec_env: &[(String, String)],
|
||||
strip_ambient_tokens: bool,
|
||||
) -> Vec<(String, String)> {
|
||||
sanitize_copilot_env_with(spec_env, strip_ambient_tokens, ambient_copilot_env_value)
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_copilot_env_with(
|
||||
spec_env: &[(String, String)],
|
||||
strip_ambient_tokens: bool,
|
||||
lookup: impl Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut out = Vec::with_capacity(spec_env.len() + AMBIENT_COPILOT_AUTH_VARS.len());
|
||||
for key in AMBIENT_COPILOT_AUTH_VARS {
|
||||
let explicit = spec_env.iter().any(|(k, v)| k == key && !v.is_empty());
|
||||
if explicit {
|
||||
continue;
|
||||
}
|
||||
if let Some(val) = lookup(key).filter(|v| !v.is_empty())
|
||||
&& (strip_ambient_tokens || val.trim().starts_with("ghp_"))
|
||||
{
|
||||
out.push((key.to_owned(), String::new()));
|
||||
}
|
||||
}
|
||||
out.extend(spec_env.iter().cloned());
|
||||
out
|
||||
}
|
||||
|
||||
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
|
||||
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
|
||||
let prefix = format!("Run `{}", builtin.command);
|
||||
@@ -1172,23 +1101,4 @@ mod tests {
|
||||
import_acp(dir.path(), None, "not json").unwrap_err();
|
||||
assert!(import_acp(dir.path(), None, "{}").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_classic_pats_and_ambient_tokens_while_preserving_explicit_agent_env() {
|
||||
let lookup = |k: &str| match k {
|
||||
"GITHUB_TOKEN" => Some("ghp_classic_pat_123".into()),
|
||||
"GH_TOKEN" => Some("gho_oauth_token_456".into()),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(sanitize_copilot_env_with(&[], false, lookup), vec![("GITHUB_TOKEN".into(), String::new())]);
|
||||
assert_eq!(
|
||||
sanitize_copilot_env_with(&[], true, lookup),
|
||||
vec![("GH_TOKEN".into(), String::new()), ("GITHUB_TOKEN".into(), String::new())]
|
||||
);
|
||||
let explicit = vec![("GH_TOKEN".into(), "github_pat_custom".into())];
|
||||
assert_eq!(
|
||||
sanitize_copilot_env_with(&explicit, true, lookup),
|
||||
vec![("GITHUB_TOKEN".into(), String::new()), ("GH_TOKEN".into(), "github_pat_custom".into())]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -883,7 +883,6 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: ide_core::input::InputWriter,
|
||||
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pid: Option<u32>,
|
||||
/// Set once the program has exited and been reaped.
|
||||
exited: Arc<AtomicBool>,
|
||||
@@ -896,7 +895,6 @@ impl ProgramTerminal {
|
||||
}
|
||||
|
||||
/// The program's process id while it runs.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) fn process_id(&self) -> Option<u32> {
|
||||
if self.exited.load(Ordering::Acquire) { None } else { self.pid }
|
||||
}
|
||||
|
||||
@@ -21,28 +21,6 @@ pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens an editable untitled buffer (a new-file tab); it reaches the disk
|
||||
/// through [`save_as`], which turns it into a regular file document. `name`
|
||||
/// re-references an already minted buffer instead of creating one.
|
||||
pub async fn open_untitled(host: &Host, window: &str, name: Option<String>) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.open_untitled(name.as_deref())).await
|
||||
}
|
||||
|
||||
/// Save As for an untitled buffer ([`Documents::save_as`]); LightEdit windows
|
||||
/// start watching the saved file's folder.
|
||||
pub async fn save_as(host: &Host, window: &str, doc: DocId, path: PathBuf) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let info = project.documents.save_as(doc, &path)?;
|
||||
if let Some(dir) = path.parent().filter(|_| project.light) {
|
||||
project.watch_light_dir(dir);
|
||||
}
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
|
||||
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
|
||||
host.project(window)?.documents.edit(doc, base_version, changes)
|
||||
|
||||
@@ -50,10 +50,7 @@ pub async fn doc_set_encoding(
|
||||
reload: bool,
|
||||
) -> IdeResult<DocumentEncoding> {
|
||||
let project = host.project(window)?;
|
||||
if reload
|
||||
&& !project.documents.is_untitled(doc)?
|
||||
&& let Some(uri) = project.documents.virtual_uri(doc)?
|
||||
{
|
||||
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
|
||||
let vd = if uri.starts_with("git-revision:") {
|
||||
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
|
||||
} else {
|
||||
|
||||
@@ -88,9 +88,6 @@ impl Host {
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Untitled buffers belong to the window, not a project: they follow it
|
||||
// into the project's fresh document registry.
|
||||
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
@@ -624,9 +621,6 @@ impl Host {
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
for (key, text, dirty) in &carried {
|
||||
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
|
||||
}
|
||||
project.lsp.prewarm_java_configuration();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
@@ -709,29 +703,12 @@ impl Host {
|
||||
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"))?;
|
||||
let watch = dir.clone();
|
||||
self.open_light_in(window, dir, Some(&watch))
|
||||
}
|
||||
|
||||
/// LightEdit without a file (a welcome window's File › New File): rooted
|
||||
/// at the user's home folder, watching nothing until a buffer is saved
|
||||
/// into a real folder ([`Project::watch_light_dir`]).
|
||||
pub fn open_light_empty(&self, window: &str) -> IdeResult<Arc<Project>> {
|
||||
let dir = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
|
||||
self.open_light_in(window, dir, None)
|
||||
}
|
||||
|
||||
fn open_light_in(&self, window: &str, dir: PathBuf, watch_dir: Option<&Path>) -> IdeResult<Arc<Project>> {
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Untitled buffers belong to the window: they follow it out of a project.
|
||||
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.light
|
||||
{
|
||||
if let Some(dir) = watch_dir {
|
||||
current.watch_light_dir(dir);
|
||||
}
|
||||
current.watch_light_dir(&dir);
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
@@ -750,16 +727,7 @@ impl Host {
|
||||
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 = match watch_dir {
|
||||
Some(dir) => light_watcher(dir, &sink, &documents, &history)?,
|
||||
// A quiet private folder stands in until the first save brings a
|
||||
// real folder in.
|
||||
None => {
|
||||
let idle = self.local_data_dir.join("light-edit");
|
||||
std::fs::create_dir_all(&idle)?;
|
||||
light_watcher(&idle, &sink, &documents, &history)?
|
||||
}
|
||||
};
|
||||
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, &settings, &sink);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
@@ -805,11 +773,8 @@ impl Host {
|
||||
light: true,
|
||||
root: dir,
|
||||
});
|
||||
tracing::info!("window {window} edits {} without a project", project.root.display());
|
||||
tracing::info!("window {window} edits {} without a project", file.display());
|
||||
self.insert_project(window, project.clone());
|
||||
for (key, text, dirty) in &carried {
|
||||
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
|
||||
}
|
||||
Ok(project)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,13 +218,6 @@ impl Dispatcher {
|
||||
})
|
||||
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open_untitled", |c| async move {
|
||||
ops::doc::open_untitled(&c.host, &c.window, c.arg::<Option<String>>("name")?).await
|
||||
})
|
||||
.add(
|
||||
"doc_save_as",
|
||||
|c| async move { ops::doc::save_as(&c.host, &c.window, c.arg("doc")?, c.arg("path")?).await },
|
||||
)
|
||||
.add("doc_close", |c| async move {
|
||||
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
|
||||
})
|
||||
|
||||
@@ -401,7 +401,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "web_unresolved_references"
|
||||
| "project_rename"
|
||||
| "light_edit_open"
|
||||
| "light_edit_open_untitled"
|
||||
| "workspace_create"
|
||||
| "workspace_discover_projects"
|
||||
| "workspace_update"
|
||||
|
||||
@@ -179,73 +179,3 @@ async fn light_notebooks_open_without_interpreter_detection_or_execution() {
|
||||
assert!(!bin.join("python.probed").exists());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn untitled_buffers_reach_the_disk_through_save_as() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let dir = tmp_root.join("notes");
|
||||
let host = host(&tmp_root.join("app"));
|
||||
|
||||
// A window with no file (a welcome screen's File › New File): rooted at the
|
||||
// home folder, no watcher yet, doc commands work.
|
||||
let empty = host.open_light_empty("main").unwrap();
|
||||
assert!(empty.light);
|
||||
let untitled = ops::doc::open_untitled(&host, "main", None).await.unwrap();
|
||||
assert_eq!(untitled.path, std::path::PathBuf::from("untitled:Untitled-1"));
|
||||
assert_eq!(untitled.language.as_str(), "plaintext");
|
||||
let version = ops::doc::edit(&host, "main", untitled.id, untitled.version, untitled_edit("# notes\n")).unwrap();
|
||||
assert!(ops::doc::save(&host, "main", untitled.id).await.is_ok(), "saving nowhere is a no-op");
|
||||
assert!(host.project("main").unwrap().documents.is_dirty(untitled.id).unwrap());
|
||||
|
||||
// Save As: the buffer lands on the disk and turns into a file document.
|
||||
let target = dir.join("deep/notes.md");
|
||||
let saved = ops::doc::save_as(&host, "main", untitled.id, target.clone()).await.unwrap();
|
||||
assert_eq!(saved.path, target);
|
||||
assert_eq!(saved.language.as_str(), "markdown");
|
||||
assert!(!saved.dirty);
|
||||
// The platform's default line separator applies to new buffers.
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "# notes\n");
|
||||
ops::doc::edit(&host, "main", untitled.id, version, untitled_edit("x\n")).unwrap();
|
||||
ops::doc::save(&host, "main", untitled.id).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "x\n# notes\n");
|
||||
|
||||
// The saved file's folder is followed for external changes now.
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
fn untitled_edit(insert: &str) -> ChangeSet {
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: insert.into() }] }
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn untitled_buffers_follow_the_window_into_a_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = host(&tmp_root.join("app"));
|
||||
|
||||
// A LightEdit window with an edited untitled buffer (and a clean one).
|
||||
host.open_light_empty("main").unwrap();
|
||||
let draft = ops::doc::open_untitled(&host, "main", None).await.unwrap();
|
||||
ops::doc::edit(&host, "main", draft.id, draft.version, untitled_edit("{\"draft\": true}")).unwrap();
|
||||
let clean = ops::doc::open_untitled(&host, "main", None).await.unwrap();
|
||||
|
||||
// The window switches to a project: the buffers follow, contents and dirty flags kept.
|
||||
let project_root = tmp_root.join("proj");
|
||||
std::fs::create_dir_all(&project_root).unwrap();
|
||||
let project = host.open_project("main", &project_root).unwrap();
|
||||
assert!(!project.light);
|
||||
|
||||
let carried =
|
||||
ops::doc::open_untitled(&host, "main", Some(draft.path.to_string_lossy().into_owned())).await.unwrap();
|
||||
assert_eq!(carried.path, draft.path, "same key, fresh registry: a new doc id carries the content");
|
||||
let snap = ops::doc::snapshot(&host, "main", carried.id).await.unwrap();
|
||||
assert_eq!(snap.text, "{\"draft\": true}");
|
||||
assert!(host.project("main").unwrap().documents.is_dirty(carried.id).unwrap());
|
||||
assert_eq!(snap.language.as_str(), "plaintext");
|
||||
|
||||
let clean_carried =
|
||||
ops::doc::open_untitled(&host, "main", Some(clean.path.to_string_lossy().into_owned())).await.unwrap();
|
||||
assert!(!host.project("main").unwrap().documents.is_dirty(clean_carried.id).unwrap());
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -80,47 +80,18 @@ pub fn trim_line_end(line: &[u8]) -> &[u8] {
|
||||
&line[..end]
|
||||
}
|
||||
|
||||
/// The longest line kept in full; the rest of a longer one is dropped (a server that writes a
|
||||
/// progress bar with `\r` only, or never a newline, must not grow memory without bound).
|
||||
pub const MAX_LINE: usize = 8 * 1024;
|
||||
|
||||
/// Reads `stream` to its end, calling `on_line` with each line decoded by [`decode`].
|
||||
/// Unlike `AsyncBufReadExt::lines`, a line that is not UTF-8 does not end the stream.
|
||||
/// A line longer than [`MAX_LINE`] bytes is cut there and ends with `…`.
|
||||
pub async fn read_lines<R: AsyncRead + Unpin>(stream: R, mut on_line: impl FnMut(String)) {
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = Vec::new();
|
||||
let mut cut = false;
|
||||
loop {
|
||||
let (used, complete) = match reader.fill_buf().await {
|
||||
Ok([]) | Err(_) => break,
|
||||
Ok(buf) => {
|
||||
let end = buf.iter().position(|&b| b == b'\n');
|
||||
let piece = &buf[..end.unwrap_or(buf.len())];
|
||||
let room = MAX_LINE.saturating_sub(line.len());
|
||||
cut |= piece.len() > room;
|
||||
line.extend_from_slice(&piece[..piece.len().min(room)]);
|
||||
(end.map_or(buf.len(), |i| i + 1), end.is_some())
|
||||
}
|
||||
};
|
||||
reader.consume(used);
|
||||
if complete {
|
||||
emit(&mut line, &mut cut, &mut on_line);
|
||||
line.clear();
|
||||
match reader.read_until(b'\n', &mut line).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => on_line(decode(trim_line_end(&line))),
|
||||
}
|
||||
}
|
||||
if !line.is_empty() || cut {
|
||||
emit(&mut line, &mut cut, &mut on_line);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(line: &mut Vec<u8>, cut: &mut bool, on_line: &mut impl FnMut(String)) {
|
||||
let mut text = decode(trim_line_end(line));
|
||||
if *cut {
|
||||
text.push('…');
|
||||
}
|
||||
line.clear();
|
||||
*cut = false;
|
||||
on_line(text);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -162,19 +133,4 @@ mod tests {
|
||||
assert!(lines[1].ends_with(" broken"));
|
||||
assert_eq!(lines[2], "last");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_very_long_line_is_cut_and_the_next_one_is_intact() {
|
||||
let mut input = vec![b'x'; MAX_LINE * 10 + 3];
|
||||
input.extend_from_slice(b"\nnext\r\n");
|
||||
// No newline at all after this one: still bounded.
|
||||
input.extend_from_slice(&vec![b'y'; MAX_LINE * 3]);
|
||||
let mut lines = Vec::new();
|
||||
read_lines(&input[..], |l| lines.push(l)).await;
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0].chars().count(), MAX_LINE + 1);
|
||||
assert!(lines[0].ends_with('…') && lines[0].starts_with("xxx"));
|
||||
assert_eq!(lines[1], "next");
|
||||
assert_eq!(lines[2].chars().count(), MAX_LINE + 1);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
; Shell scripts: drawn over the stream mode, which knows only fixed word lists.
|
||||
; The last matching pattern wins.
|
||||
|
||||
[
|
||||
(string)
|
||||
(raw_string)
|
||||
(ansi_c_string)
|
||||
(translated_string)
|
||||
(heredoc_body)
|
||||
(heredoc_start)
|
||||
] @string
|
||||
|
||||
[
|
||||
(command_substitution)
|
||||
(process_substitution)
|
||||
(expansion)
|
||||
(simple_expansion)
|
||||
] @embedded
|
||||
|
||||
(comment) @comment
|
||||
|
||||
[
|
||||
"case"
|
||||
"do"
|
||||
"done"
|
||||
"elif"
|
||||
"else"
|
||||
"esac"
|
||||
"fi"
|
||||
"for"
|
||||
"function"
|
||||
"if"
|
||||
"in"
|
||||
"select"
|
||||
"then"
|
||||
"until"
|
||||
"while"
|
||||
"declare"
|
||||
"export"
|
||||
"local"
|
||||
"readonly"
|
||||
"typeset"
|
||||
"unset"
|
||||
"unsetenv"
|
||||
] @keyword
|
||||
|
||||
(command_name) @function.command
|
||||
(function_definition name: (word) @function.definition)
|
||||
|
||||
(variable_name) @variable
|
||||
(special_variable_name) @variable
|
||||
|
||||
[
|
||||
(number)
|
||||
(file_descriptor)
|
||||
] @number
|
||||
@@ -314,7 +314,7 @@ pub(crate) static SPECS: &[Spec] = &[
|
||||
name: "bash",
|
||||
language: tree_sitter_bash::LANGUAGE,
|
||||
outline: outline!("bash"),
|
||||
highlights: Some(Highlights { query: highlights!("bash"), first_wins: false }),
|
||||
highlights: None,
|
||||
fold_nodes: &["do_group", "if_statement", "case_statement", "heredoc_body"],
|
||||
fold_bodies: NONE,
|
||||
imports: NONE,
|
||||
|
||||
@@ -43,13 +43,11 @@ pub enum SyntaxHighlightKind {
|
||||
Operator,
|
||||
Punctuation,
|
||||
Parameter,
|
||||
/// A shell command name (`pgrep`, a script's own function).
|
||||
Command,
|
||||
}
|
||||
|
||||
impl SyntaxHighlightKind {
|
||||
/// Every kind, in `as u32` order: the legend for [`SyntaxHighlights::data`].
|
||||
pub const ALL: [Self; 26] = [
|
||||
pub const ALL: [Self; 25] = [
|
||||
Self::Keyword,
|
||||
Self::String,
|
||||
Self::StringSpecial,
|
||||
@@ -75,7 +73,6 @@ impl SyntaxHighlightKind {
|
||||
Self::Operator,
|
||||
Self::Punctuation,
|
||||
Self::Parameter,
|
||||
Self::Command,
|
||||
];
|
||||
|
||||
/// The kebab-case name plugins use (`doc-comment`, `function-call`, …).
|
||||
@@ -156,7 +153,6 @@ const CAPTURES: &[(&str, Paint)] = {
|
||||
("function.builtin", Kind(K::Builtin)),
|
||||
("function.macro", Kind(K::Macro)),
|
||||
("function.definition", Kind(K::Function)),
|
||||
("function.command", Kind(K::Command)),
|
||||
("function", Kind(K::FunctionCall)),
|
||||
("method", Kind(K::FunctionCall)),
|
||||
("constructor", Kind(K::Type)),
|
||||
@@ -412,30 +408,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_scripts_highlight_declarations_commands_and_function_names() {
|
||||
use SyntaxHighlightKind as K;
|
||||
let src = "this::check_process() {\n local process\n process=\"${1:?}\"\n if [ -x \"$(command -v pgrep)\" ]; then\n if pgrep -f -q \"${process}\"; then\n functions::exit 1 \"'${process}' is running\"\n fi\n fi\n}\n# done\n";
|
||||
let got = spans("shellscript", src);
|
||||
for (text, expected) in [
|
||||
("this::check_process", K::Function),
|
||||
("local", K::Keyword),
|
||||
("if", K::Keyword),
|
||||
("command", K::Command),
|
||||
("pgrep", K::Command),
|
||||
("functions::exit", K::Command),
|
||||
("process", K::Variable),
|
||||
("# done", K::Comment),
|
||||
] {
|
||||
assert!(
|
||||
got.iter().any(|(word, kind)| word == text && *kind == expected),
|
||||
"missing {text}: {expected:?} in {got:?}"
|
||||
);
|
||||
}
|
||||
// An expansion inside a string is not string-coloured.
|
||||
assert!(!got.iter().any(|(word, kind)| word.contains("${process}") && *kind == K::String), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_kotlin_parameters_keep_variable_highlighting() {
|
||||
for (lang, src) in [
|
||||
|
||||
@@ -41,44 +41,20 @@ pub struct ClientHandler {
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// The diagnostics in `items` (a JSON array) that deserialize. A server's malformed one is
|
||||
/// skipped with a warning, so the rest of the file's problems still show.
|
||||
pub(crate) fn lenient_diagnostics(server: &str, items: Value) -> Vec<lsp::Diagnostic> {
|
||||
let Value::Array(items) = items else { return Vec::new() };
|
||||
let mut skipped = 0usize;
|
||||
let parsed: Vec<lsp::Diagnostic> = items
|
||||
.into_iter()
|
||||
.filter_map(|mut item| {
|
||||
crate::virtual_doc::normalize_uris(&mut item);
|
||||
match serde_json::from_value(item) {
|
||||
Ok(diagnostic) => Some(diagnostic),
|
||||
Err(_) => {
|
||||
skipped += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if skipped > 0 {
|
||||
tracing::warn!("{server}: skipped {skipped} malformed diagnostic(s)");
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
impl ClientHandler {
|
||||
pub fn new(mgr: Weak<LspManager>, server: Arc<Server>, generation: u64) -> Self {
|
||||
Self { mgr, server, generation }
|
||||
}
|
||||
|
||||
fn encoding(&self) -> Encoding {
|
||||
self.server.initialized_gen(self.generation).map(|r| r.caps.read().encoding).unwrap_or_default()
|
||||
self.server.running_gen(self.generation).map(|r| r.caps.read().encoding).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Cancelling task `task` asks this server instance to stop the work of progress `token`.
|
||||
fn cancel_handle(&self, task: TaskId, token: &Value) -> ide_core::task::CancelHandle {
|
||||
let (server, generation, token) = (self.server.clone(), self.generation, token.clone());
|
||||
ide_core::task::on_cancel(task, move || {
|
||||
if let Some(run) = server.initialized_gen(generation)
|
||||
if let Some(run) = server.running_gen(generation)
|
||||
&& let Err(e) = run.peer.notify("window/workDoneProgress/cancel", json!({ "token": token }))
|
||||
{
|
||||
tracing::debug!("{}: cannot cancel progress: {e}", server.name);
|
||||
@@ -200,7 +176,7 @@ impl ClientHandler {
|
||||
loop {
|
||||
tokio::time::sleep(PROGRESS_SWEEP).await;
|
||||
let Some(mgr) = weak.upgrade() else { return };
|
||||
if !server.is_live(generation) || !server.has_progress(&token) {
|
||||
if server.running_gen(generation).is_none() || !server.has_progress(&token) {
|
||||
return; // the instance is gone, or the task ended normally
|
||||
}
|
||||
if server.progress_silent(&token, PROGRESS_STALE) {
|
||||
@@ -275,24 +251,18 @@ impl ClientHandler {
|
||||
|
||||
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
|
||||
// Problems come only from servers used for `diagnostics`.
|
||||
if self.server.initialized_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
return;
|
||||
}
|
||||
let mut params = params;
|
||||
crate::virtual_doc::normalize_uris(&mut params);
|
||||
// One malformed diagnostic must not take the others of the file with it.
|
||||
let raw = params.get_mut("diagnostics").map(Value::take);
|
||||
if let Some(fields) = params.as_object_mut() {
|
||||
fields.insert("diagnostics".into(), json!([]));
|
||||
}
|
||||
let mut params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
|
||||
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::debug!("{}: bad publishDiagnostics: {e}", self.server.name);
|
||||
return;
|
||||
}
|
||||
};
|
||||
params.diagnostics = lenient_diagnostics(&self.server.name, raw.unwrap_or(Value::Null));
|
||||
let Some(path) = uri_to_path(params.uri.as_str())
|
||||
.or_else(|| crate::notebook::is_cell_uri(params.uri.as_str()).then(|| PathBuf::from(params.uri.as_str())))
|
||||
else {
|
||||
@@ -510,7 +480,7 @@ impl Handler for ClientHandler {
|
||||
crate::kotlin_lsp::record_import_state(&self.server, self.generation, ¶ms)
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
if self.server.initialized_gen(self.generation).is_some() {
|
||||
if self.server.running_gen(self.generation).is_some() {
|
||||
self.server.set_java_ready();
|
||||
}
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
|
||||
@@ -103,7 +103,7 @@ impl Launcher for ProcessLauncher {
|
||||
// A plugin's server: the approved program, unchanged since.
|
||||
Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()),
|
||||
None => ide_core::tool_path::find_command(&config.command, &search, root)
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b, root))
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b))
|
||||
.and_then(|b| reject_dead_shim(b, &search, root)),
|
||||
};
|
||||
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
|
||||
@@ -208,16 +208,34 @@ fn reject_dead_shim(binary: PathBuf, search: &OsStr, root: &Path) -> Option<Path
|
||||
/// toolchain lacks the component (common for rust-analyzer). In that case use
|
||||
/// the newest installed toolchain that ships the binary; with none, the tool is
|
||||
/// missing (an install hint, not a server that crashes on every restart).
|
||||
/// The probe runs in the project's root, where a `rust-toolchain.toml` picks the toolchain.
|
||||
fn resolve_rustup_proxy(command: &str, binary: PathBuf, root: &Path) -> Option<PathBuf> {
|
||||
fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option<PathBuf> {
|
||||
let is_proxy = binary.parent().is_some_and(|d| d.ends_with(".cargo/bin"));
|
||||
if !is_proxy || command.contains('/') {
|
||||
return Some(binary);
|
||||
}
|
||||
if proxy_works(&binary, root) {
|
||||
let works = ide_core::process::prepare(&mut std::process::Command::new(&binary))
|
||||
.arg("--version")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if works {
|
||||
return Some(binary);
|
||||
}
|
||||
let mut candidates = rustup_home().map(|h| toolchain_binaries(&h.join("toolchains"), command)).unwrap_or_default();
|
||||
let toolchains = std::env::var_os("RUSTUP_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".rustup")))
|
||||
.map(|h| h.join("toolchains"));
|
||||
let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = toolchains
|
||||
.and_then(|dir| std::fs::read_dir(dir).ok())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path().join("bin").join(command))
|
||||
.filter(|p| p.is_file())
|
||||
.filter_map(|p| Some((p.metadata().ok()?.modified().ok()?, p)))
|
||||
.collect();
|
||||
// Prefer stable-looking toolchains, then the most recently installed.
|
||||
candidates.sort_by_key(|(mtime, p)| (!p.to_string_lossy().contains("nightly"), *mtime));
|
||||
match candidates.pop() {
|
||||
@@ -232,57 +250,6 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf, root: &Path) -> Option<P
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the proxy runs for the toolchain active in `root`. A probe that hangs or can't
|
||||
/// be waited for counts as working: the real start decides.
|
||||
fn proxy_works(binary: &Path, root: &Path) -> bool {
|
||||
let Ok(mut child) = ide_core::process::prepare(&mut std::process::Command::new(binary))
|
||||
.arg("--version")
|
||||
.current_dir(root)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return status.success(),
|
||||
Ok(None) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20))
|
||||
}
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where rustup keeps its toolchains: `RUSTUP_HOME`, else `.rustup` in the user's home
|
||||
/// (`HOME`, which a GUI app on Windows doesn't have, or `USERPROFILE`).
|
||||
fn rustup_home() -> Option<PathBuf> {
|
||||
std::env::var_os("RUSTUP_HOME").map(PathBuf::from).or_else(|| {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
|
||||
Some(PathBuf::from(home).join(".rustup"))
|
||||
})
|
||||
}
|
||||
|
||||
/// The `command` binary (with the platform's executable suffix) of every toolchain in
|
||||
/// `toolchains`, with its modification time.
|
||||
fn toolchain_binaries(toolchains: &Path, command: &str) -> Vec<(std::time::SystemTime, PathBuf)> {
|
||||
let file = format!("{command}{}", std::env::consts::EXE_SUFFIX);
|
||||
std::fs::read_dir(toolchains)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path().join("bin").join(&file))
|
||||
.filter(|p| p.is_file())
|
||||
.filter_map(|p| Some((p.metadata().ok()?.modified().ok()?, p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Workspace root for `file`: the outermost ancestor within `project_root`
|
||||
/// containing a root marker (so a Cargo/npm workspace gets one server, not one
|
||||
/// per member), else `project_root`. `None` when `file` is outside the project.
|
||||
@@ -396,24 +363,7 @@ mod proxy_tests {
|
||||
#[test]
|
||||
fn non_proxy_binaries_are_kept() {
|
||||
let p = PathBuf::from("/usr/bin/true");
|
||||
assert_eq!(resolve_rustup_proxy("true", p.clone(), Path::new(".")), Some(p));
|
||||
}
|
||||
|
||||
/// The binary is `rust-analyzer.exe` on Windows: a lookup of the bare name found nothing there.
|
||||
#[test]
|
||||
fn toolchain_binaries_carry_the_platform_executable_suffix() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = format!("rust-analyzer{}", std::env::consts::EXE_SUFFIX);
|
||||
for toolchain in ["stable-x86_64", "nightly-x86_64"] {
|
||||
std::fs::create_dir_all(tmp.path().join(toolchain).join("bin")).unwrap();
|
||||
}
|
||||
std::fs::write(tmp.path().join("stable-x86_64/bin").join(&file), "").unwrap();
|
||||
// Another tool, and a bare name without the suffix where one is required.
|
||||
std::fs::write(tmp.path().join("nightly-x86_64/bin").join("rustfmt"), "").unwrap();
|
||||
let found = toolchain_binaries(tmp.path(), "rust-analyzer");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].1, tmp.path().join("stable-x86_64/bin").join(&file));
|
||||
assert!(toolchain_binaries(&tmp.path().join("missing"), "rust-analyzer").is_empty());
|
||||
assert_eq!(resolve_rustup_proxy("true", p.clone()), Some(p));
|
||||
}
|
||||
|
||||
/// Exercises the real machine setup when a broken rustup proxy is present.
|
||||
@@ -422,7 +372,7 @@ mod proxy_tests {
|
||||
fn broken_rustup_proxy_falls_back_to_a_toolchain() {
|
||||
let home = PathBuf::from(std::env::var_os("HOME").unwrap());
|
||||
let proxy = home.join(".cargo/bin/rust-analyzer");
|
||||
let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone(), Path::new(".")).unwrap();
|
||||
let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone()).unwrap();
|
||||
eprintln!("resolved: {}", resolved.display());
|
||||
assert_ne!(resolved, proxy);
|
||||
}
|
||||
|
||||
@@ -343,10 +343,6 @@ impl<T> Outcome<T> {
|
||||
/// How often a rename the server withdrew (`ContentModified`) is asked, and the pause between.
|
||||
const RENAME_ATTEMPTS: u32 = 5;
|
||||
const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
/// How often a semantic tokens, inlay hints or code lens request the server withdrew
|
||||
/// (`ContentModified`) is asked again, and the pause between.
|
||||
const STALE_RETRIES: u32 = 2;
|
||||
const STALE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
|
||||
/// Implementation requests of one line-marker pass in flight at once.
|
||||
const IMPLEMENTATION_CONCURRENCY: usize = 4;
|
||||
@@ -658,9 +654,8 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// The servers serving `path`: every regular one, and each `fallback` one
|
||||
/// only while all servers listed before it have given up (missing, not
|
||||
/// launchable, or crashed for good). One that requires a root marker serves
|
||||
/// only files below one, and stands in for no other.
|
||||
/// only while all servers listed before it are missing. One that requires a
|
||||
/// root marker serves only files below one, and stands in for no other.
|
||||
fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec<Arc<Server>> {
|
||||
if let Some(servers) = self.arduino_servers(language, path) {
|
||||
return servers;
|
||||
@@ -677,7 +672,7 @@ impl LspManager {
|
||||
continue;
|
||||
}
|
||||
let server = self.server_for(sc, language, path);
|
||||
available_before |= !sc.require_root_marker && !server.gave_up();
|
||||
available_before |= !sc.require_root_marker && server.status() != ide_core::event::ServerStatus::Missing;
|
||||
out.push(server);
|
||||
}
|
||||
out
|
||||
@@ -753,14 +748,9 @@ impl LspManager {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
// A server dropped meanwhile (disabled in settings, shadowed by another) must not come
|
||||
// back as a process nothing lists, serves or stops: a crash backoff can outlive it.
|
||||
if !self.servers.lock().iter().any(|s| s.id == server.id) {
|
||||
return;
|
||||
}
|
||||
let workspace = is_local_jdtls(&server.config).then(|| self.jdtls_workspace_state());
|
||||
if let Some(state) = &workspace {
|
||||
if state.transitioning {
|
||||
if state.transitioning || !self.servers.lock().iter().any(|s| s.id == server.id) {
|
||||
return;
|
||||
}
|
||||
let (shared, roots) = self.desired_jdtls_workspace();
|
||||
@@ -871,28 +861,24 @@ impl LspManager {
|
||||
}
|
||||
Err(LaunchError::Config(message)) => {
|
||||
tracing::warn!("{}: {message}", server.name);
|
||||
if server.fail_start(
|
||||
server.fail_start(
|
||||
generation,
|
||||
Conn::Failed,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Misconfigured,
|
||||
Some(message),
|
||||
) {
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(LaunchError::Failed(message)) => {
|
||||
tracing::warn!("{}: {message}", server.name);
|
||||
if server.fail_start(
|
||||
server.fail_start(
|
||||
generation,
|
||||
Conn::Failed,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Crashed,
|
||||
Some(message),
|
||||
) {
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -934,18 +920,9 @@ impl LspManager {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("{}: initialize failed: {e}", server.name);
|
||||
match e {
|
||||
if matches!(e, RpcError::Closed) {
|
||||
// The server is exiting: let it, so the crash report has its exit code and last output.
|
||||
RpcError::Closed => {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
|
||||
}
|
||||
RpcError::Timeout => {
|
||||
server.set_start_error(
|
||||
generation,
|
||||
format!("it did not answer initialize within {} s", INITIALIZE_TIMEOUT.as_secs()),
|
||||
);
|
||||
}
|
||||
e => server.set_start_error(generation, format!("initialize failed ({e})")),
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
|
||||
}
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
// Treated like a crash so transient failures retry with backoff.
|
||||
@@ -1031,15 +1008,11 @@ impl LspManager {
|
||||
self.forget(std::slice::from_ref(&server));
|
||||
return;
|
||||
}
|
||||
match delay {
|
||||
Some(delay) => {
|
||||
tokio::time::sleep(delay).await;
|
||||
if server.is_idle_gen(generation) {
|
||||
self.ensure_started(&server);
|
||||
}
|
||||
if let Some(delay) = delay {
|
||||
tokio::time::sleep(delay).await;
|
||||
if server.is_idle_gen(generation) {
|
||||
self.ensure_started(&server);
|
||||
}
|
||||
// Gave up (crashed repeatedly, or its build expired): the fallbacks it hid take over.
|
||||
None => self.attach_fallbacks(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1485,28 +1458,6 @@ impl LspManager {
|
||||
result
|
||||
}
|
||||
|
||||
/// [`Self::call`], asked again when the server withdrew the answer because the text changed
|
||||
/// (`ContentModified`, which arrives as a cancellation as no cancel token is passed).
|
||||
/// The client advertises `retryOnContentModified` for the methods that use it.
|
||||
async fn call_retrying<P: Serialize + Clone, R: DeserializeOwned>(
|
||||
&self,
|
||||
t: &Target,
|
||||
method: &str,
|
||||
params: P,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<R> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
match self.call(t, method, params.clone(), None, timeout).await {
|
||||
Err(e) if e.kind == ErrorKind::Cancelled && attempt < STALE_RETRIES => {
|
||||
attempt += 1;
|
||||
tokio::time::sleep(STALE_RETRY_DELAY).await;
|
||||
}
|
||||
result => return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- features ----
|
||||
pub async fn completion(
|
||||
&self,
|
||||
@@ -2941,10 +2892,11 @@ impl LspManager {
|
||||
async move {
|
||||
(
|
||||
t,
|
||||
self.call_retrying::<_, Option<Vec<lsp::InlayHint>>>(
|
||||
self.call::<_, Option<Vec<lsp::InlayHint>>>(
|
||||
t,
|
||||
"textDocument/inlayHint",
|
||||
params,
|
||||
None,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await,
|
||||
@@ -2975,8 +2927,7 @@ impl LspManager {
|
||||
self.ready_targets(&snap.servers, |c| c.semantic_tokens_full() && c.semantic_legend.is_some()).await;
|
||||
let Some(t) = targets.first() else { return Ok(None) };
|
||||
let params = json!({ "textDocument": snap.ident() });
|
||||
let r: Option<Value> =
|
||||
self.call_retrying(t, "textDocument/semanticTokens/full", params, DEFAULT_TIMEOUT).await?;
|
||||
let r: Option<Value> = self.call(t, "textDocument/semanticTokens/full", params, None, DEFAULT_TIMEOUT).await?;
|
||||
let Some(data) = r.as_ref().and_then(|v| v.get("data")).and_then(Value::as_array) else { return Ok(None) };
|
||||
let data: Vec<i64> = data.iter().map(|n| n.as_i64().unwrap_or(0)).collect();
|
||||
let (enc, legend) = {
|
||||
@@ -2997,7 +2948,7 @@ impl LspManager {
|
||||
let params = json!({ "textDocument": snap.ident() });
|
||||
async move {
|
||||
let lenses = self
|
||||
.call_retrying::<_, Option<Vec<lsp::CodeLens>>>(t, "textDocument/codeLens", params, DEFAULT_TIMEOUT)
|
||||
.call::<_, Option<Vec<lsp::CodeLens>>>(t, "textDocument/codeLens", params, None, DEFAULT_TIMEOUT)
|
||||
.await?;
|
||||
let lenses = lenses.unwrap_or_default();
|
||||
if !t.caps().has_sub("codeLensProvider", "resolveProvider") {
|
||||
@@ -3925,35 +3876,26 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub async fn restart(&self, server: ide_core::ServerId) -> IdeResult<()> {
|
||||
let s = {
|
||||
// Only the restart itself holds the lock: a start can take minutes (a Gradle or
|
||||
// Maven import), and `reconfigure` and `shutdown_all` wait for it.
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let s = self
|
||||
.servers
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|s| s.id == server)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("unknown language server {server}")))?;
|
||||
if !s.root.is_dir() {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the workspace root of {} is gone: {}",
|
||||
s.name,
|
||||
s.root.display()
|
||||
)));
|
||||
}
|
||||
self.stop_instance(&s, false).await;
|
||||
s.reset_for_restart();
|
||||
self.ensure_started(&s);
|
||||
// Started again: it shadows the fallbacks that took over while it was down.
|
||||
self.detach_shadowed_fallbacks().await;
|
||||
s
|
||||
};
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let s = self
|
||||
.servers
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|s| s.id == server)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("unknown language server {server}")))?;
|
||||
if !s.root.is_dir() {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the workspace root of {} is gone: {}",
|
||||
s.name,
|
||||
s.root.display()
|
||||
)));
|
||||
}
|
||||
self.stop_instance(&s, false).await;
|
||||
s.reset_for_restart();
|
||||
self.ensure_started(&s);
|
||||
match s.wait_ready(Duration::from_secs(60)).await {
|
||||
Some(_) => Ok(()),
|
||||
// Still importing the project: the restart worked, the server is just not ready yet.
|
||||
None if s.is_starting() => Ok(()),
|
||||
None => {
|
||||
Err(IdeError::unavailable(s.status_message().unwrap_or_else(|| format!("{} failed to start", s.name))))
|
||||
}
|
||||
|
||||
@@ -136,8 +136,7 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
|
||||
return Some((entry.clone(), text));
|
||||
}
|
||||
}
|
||||
// A facade renamed with `@file:JvmName("…")`, or a class declared in a file named otherwise
|
||||
// (`UserDto` in `Models.kt`).
|
||||
// A facade renamed with `@file:JvmName("…")`.
|
||||
let kotlin_files: Vec<String> = zip
|
||||
.file_names()
|
||||
.filter_map(|n| n.ok().map(|n| n.into_owned()))
|
||||
@@ -146,7 +145,7 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
|
||||
let jvm_name = format!("@file:JvmName(\"{outer}\")");
|
||||
for entry in kotlin_files {
|
||||
if let Some(text) = read(&entry)
|
||||
&& (text.text.contains(&jvm_name) || declares_type(&text.text, outer))
|
||||
&& text.text.contains(&jvm_name)
|
||||
{
|
||||
return Some((entry, text));
|
||||
}
|
||||
@@ -154,26 +153,6 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `text` declares a type called `name` (`class`, `interface`, `object`, `typealias`):
|
||||
/// how a Kotlin file that is not named after its class is recognised.
|
||||
fn declares_type(text: &str, name: &str) -> bool {
|
||||
const KINDS: [&str; 4] = ["class", "interface", "object", "typealias"];
|
||||
text.lines().any(|line| {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with("//") || line.starts_with("/*") || line.starts_with('*') {
|
||||
return false;
|
||||
}
|
||||
let mut words =
|
||||
line.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$' || c == '`')).filter(|w| !w.is_empty());
|
||||
let mut previous = words.next();
|
||||
words.any(|word| {
|
||||
let hit = previous.is_some_and(|p| KINDS.contains(&p)) && word.trim_matches('`') == name;
|
||||
previous = Some(word);
|
||||
hit
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Where `name` is declared in a Kotlin or Java source `text` (the start of the
|
||||
/// name), else the start of the file.
|
||||
pub(crate) fn declaration_range(text: &str, name: &str) -> Range {
|
||||
@@ -266,22 +245,6 @@ pub(crate) fn folder_class(uri: &str) -> Option<(String, String)> {
|
||||
Some((package.replace('.', "/"), name.to_owned()))
|
||||
}
|
||||
|
||||
/// Kotlin sources larger than this are not searched for a class declared in a file named otherwise.
|
||||
const MAX_SOURCE: u64 = 1 << 20;
|
||||
|
||||
/// The files of the project's source trees: build output and ignored files are skipped.
|
||||
fn walk_sources(root: &Path) -> impl Iterator<Item = ignore::DirEntry> {
|
||||
ignore::WalkBuilder::new(root)
|
||||
.filter_entry(|e| {
|
||||
let output = matches!(e.file_name().to_str(), Some("target" | "build" | "out" | "node_modules"));
|
||||
// A package may be named `build`: only outside source trees is it output.
|
||||
!output || e.path().components().any(|c| c.as_os_str() == "src")
|
||||
})
|
||||
.build()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
|
||||
}
|
||||
|
||||
/// The project's source of `class` (`Name`, `Outer$Inner`, `FileKt`) in `package`
|
||||
/// (`/`-separated): a `.kt` / `.java` file of that name declaring the package. Build
|
||||
/// output and ignored files are skipped.
|
||||
@@ -292,24 +255,19 @@ pub(crate) fn project_class_source(root: &Path, package: &str, class: &str) -> O
|
||||
names.push(format!("{file}.kt"));
|
||||
}
|
||||
let package = package.replace('/', ".");
|
||||
let mut found: Vec<PathBuf> = walk_sources(root)
|
||||
let mut found: Vec<PathBuf> = ignore::WalkBuilder::new(root)
|
||||
.filter_entry(|e| {
|
||||
let output = matches!(e.file_name().to_str(), Some("target" | "build" | "out" | "node_modules"));
|
||||
// A package may be named `build`: only outside source trees is it output.
|
||||
!output || e.path().components().any(|c| c.as_os_str() == "src")
|
||||
})
|
||||
.build()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
|
||||
.filter(|e| e.file_name().to_str().is_some_and(|n| names.iter().any(|name| name == n)))
|
||||
.map(|e| e.into_path())
|
||||
.filter(|p| std::fs::read_to_string(p).is_ok_and(|text| declared_package(&text) == package))
|
||||
.collect();
|
||||
// A class declared in a file named otherwise (`UserDto` in `Models.kt`).
|
||||
if found.is_empty() {
|
||||
found = walk_sources(root)
|
||||
.filter(|e| {
|
||||
e.path().extension().is_some_and(|x| x == "kt") && e.metadata().is_ok_and(|m| m.len() <= MAX_SOURCE)
|
||||
})
|
||||
.map(|e| e.into_path())
|
||||
.filter(|p| {
|
||||
std::fs::read_to_string(p)
|
||||
.is_ok_and(|text| declared_package(&text) == package && declares_type(&text, outer))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// The directory matching the package first, then by name order.
|
||||
found.sort_by_key(|p| {
|
||||
let dir = p.parent().map(|d| d.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
@@ -400,10 +358,6 @@ mod tests {
|
||||
"@file:JvmName(\"BootUtils\")\npackage org.springframework.boot\nfun util() {}",
|
||||
),
|
||||
("org/springframework/boot/Builder.kt", "class Builder"),
|
||||
(
|
||||
"org/springframework/boot/Models.kt",
|
||||
"package org.springframework.boot\n\n// class NotMe\ndata class UserDto(val id: Int)\nenum class Role { A }\ninterface Repo\nobject Registry\ntypealias Ids = List<Int>\n",
|
||||
),
|
||||
("Root.java", "class Root {}"),
|
||||
];
|
||||
jar(&m2.join("spring-boot-3.5.16.jar"), &[]);
|
||||
@@ -419,11 +373,6 @@ mod tests {
|
||||
("org/springframework/boot/SpringApplication$Running.class", "SpringApplication.java"),
|
||||
("org/springframework/boot/BootUtils.class", "Renamed.kt"),
|
||||
("org/springframework/boot/Builder.class", "Builder.kt"),
|
||||
// Declared in a file that is not named after them.
|
||||
("org/springframework/boot/UserDto.class", "Models.kt"),
|
||||
("org/springframework/boot/Role$A.class", "Models.kt"),
|
||||
("org/springframework/boot/Repo.class", "Models.kt"),
|
||||
("org/springframework/boot/Registry.class", "Models.kt"),
|
||||
("Root.class", "Root.java"),
|
||||
] {
|
||||
let (source_uri, text) =
|
||||
@@ -436,7 +385,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
assert!(class_source(&uri("org/springframework/boot/Missing.class"), None).is_none());
|
||||
assert!(class_source(&uri("org/springframework/boot/NotMe.class"), None).is_none(), "only in a comment");
|
||||
}
|
||||
// No sources jar: nothing to show from here.
|
||||
let lone = tmp.path().join("lone/x-1.jar");
|
||||
@@ -565,11 +513,6 @@ mod tests {
|
||||
write("src/main/kotlin/com/x/build/Tool.kt", "package com.x.build\n\nclass Tool\n");
|
||||
write("src/main/kotlin/flat/Elsewhere.kt", "");
|
||||
write("src/main/kotlin/Loose.kt", "package flat\n\nobject Loose\n");
|
||||
write(
|
||||
"src/main/kotlin/test001/Models.kt",
|
||||
"package test001\n\n// class NotMe\ndata class UserDto(val id: Int)\n",
|
||||
);
|
||||
write("src/main/kotlin/other/Models.kt", "package other\n\ndata class UserDto(val name: String)\n");
|
||||
let source = |package: &str, class: &str| {
|
||||
project_class_source(root, package, class).map(|p| p.strip_prefix(root).unwrap().to_owned())
|
||||
};
|
||||
@@ -580,10 +523,6 @@ mod tests {
|
||||
assert_eq!(source("com/x/build", "Tool"), Some(PathBuf::from("src/main/kotlin/com/x/build/Tool.kt")));
|
||||
assert_eq!(source("flat", "Loose"), Some(PathBuf::from("src/main/kotlin/Loose.kt")), "any directory");
|
||||
assert_eq!(source("test001", "Missing"), None);
|
||||
// Declared in a file named otherwise: the one of the package, not another package's.
|
||||
assert_eq!(source("test001", "UserDto"), Some(PathBuf::from("src/main/kotlin/test001/Models.kt")));
|
||||
assert_eq!(source("other", "UserDto"), Some(PathBuf::from("src/main/kotlin/other/Models.kt")));
|
||||
assert_eq!(source("test001", "NotMe"), None, "only in a comment");
|
||||
assert_eq!(declared_package("package a.b.c;\n"), "a.b.c");
|
||||
assert_eq!(declared_package("class A"), "");
|
||||
}
|
||||
|
||||
@@ -166,10 +166,13 @@ impl LspManager {
|
||||
let result_id = report.get("resultId").and_then(Value::as_str).map(str::to_owned);
|
||||
let items = match report.get("kind").and_then(Value::as_str) {
|
||||
Some("unchanged") => None,
|
||||
_ => Some(crate::handler::lenient_diagnostics(
|
||||
&t.server.name,
|
||||
report.get("items").cloned().unwrap_or(json!([])),
|
||||
)),
|
||||
_ => match serde_json::from_value(report.get("items").cloned().unwrap_or(json!([]))) {
|
||||
Ok(items) => Some(items),
|
||||
Err(e) => {
|
||||
tracing::debug!("{}: bad textDocument/diagnostic report: {e}", t.server.name);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
let shown = t.server.set_pulled(t.path.clone(), result_id, items);
|
||||
let enc = t.run.caps.read().encoding;
|
||||
|
||||
@@ -112,11 +112,8 @@ pub struct Server {
|
||||
status: Mutex<(ServerStatus, Option<String>)>,
|
||||
/// Consecutive crashes of short-lived instances.
|
||||
crashes: Mutex<usize>,
|
||||
/// When the current instance became ready: only time spent running counts as uptime,
|
||||
/// not the time spent starting (an `initialize` that hangs for minutes is not a stable run).
|
||||
/// When the current instance was launched.
|
||||
started: Mutex<Option<Instant>>,
|
||||
/// Why the current instance's `initialize` failed, for the crash report.
|
||||
start_error: Mutex<Option<(u64, String)>>,
|
||||
stderr: Mutex<VecDeque<String>>,
|
||||
pub progress: Mutex<HashMap<String, Progress>>,
|
||||
/// What the editor shows per path: the pushed and the pulled diagnostics together.
|
||||
@@ -180,7 +177,6 @@ impl Server {
|
||||
status: Mutex::new((ServerStatus::Stopped, None)),
|
||||
crashes: Mutex::new(0),
|
||||
started: Mutex::new(None),
|
||||
start_error: Mutex::new(None),
|
||||
stderr: Mutex::new(VecDeque::new()),
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
diagnostics: Mutex::new(HashMap::new()),
|
||||
@@ -261,8 +257,7 @@ impl Server {
|
||||
st.conn = Conn::Starting;
|
||||
st.restarting = false;
|
||||
*self.start_cancel.lock() = CancellationToken::new();
|
||||
*self.started.lock() = None;
|
||||
*self.start_error.lock() = None;
|
||||
*self.started.lock() = Some(Instant::now());
|
||||
// A crash report quotes only this instance's output, not its predecessors'.
|
||||
self.stderr.lock().clear();
|
||||
self.import_state.send_replace(None);
|
||||
@@ -310,15 +305,9 @@ impl Server {
|
||||
}
|
||||
st.conn = Conn::Ready(run);
|
||||
*self.initializing.lock() = None;
|
||||
*self.started.lock() = Some(Instant::now());
|
||||
true
|
||||
}
|
||||
|
||||
/// Records why instance `generation` failed to initialize, so its crash report says so.
|
||||
pub fn set_start_error(&self, generation: u64, reason: String) {
|
||||
*self.start_error.lock() = Some((generation, reason));
|
||||
}
|
||||
|
||||
/// Wakes requests waiting for the instance (after status and documents are in place).
|
||||
pub fn announce_ready(&self, generation: u64) {
|
||||
let st = self.state.lock();
|
||||
@@ -338,13 +327,6 @@ impl Server {
|
||||
matches!(self.state.lock().conn, Conn::Starting)
|
||||
}
|
||||
|
||||
/// The server will not run until something changes: its binary is missing, its
|
||||
/// configuration can't launch it, or it crashed too often (or its build expired).
|
||||
/// Servers that crashed and are about to restart are not given up on.
|
||||
pub fn gave_up(&self) -> bool {
|
||||
matches!(self.state.lock().conn, Conn::Missing | Conn::Failed)
|
||||
}
|
||||
|
||||
/// The instance told `initialized`, ready or not.
|
||||
pub fn instance(&self) -> Option<Running> {
|
||||
self.running().or_else(|| self.initializing.lock().clone())
|
||||
@@ -369,13 +351,6 @@ impl Server {
|
||||
self.running().filter(|r| r.generation == generation)
|
||||
}
|
||||
|
||||
/// Instance `generation` is starting or running: not stopped, crashed or replaced.
|
||||
/// Work it reports before it is ready (a Gradle import answers `initialize` late) still counts.
|
||||
pub fn is_live(&self, generation: u64) -> bool {
|
||||
let st = self.state.lock();
|
||||
st.generation == generation && matches!(st.conn, Conn::Starting | Conn::Ready(_))
|
||||
}
|
||||
|
||||
/// Waits through startup or a planned restart; returns the instance once ready.
|
||||
pub async fn wait_ready(&self, timeout: Duration) -> Option<Running> {
|
||||
let mut rx = self.phase.subscribe();
|
||||
@@ -466,12 +441,10 @@ impl Server {
|
||||
}
|
||||
let tail = self.stderr_tail(5);
|
||||
let cfg = self.effective_config();
|
||||
let start_error = self.start_error.lock().take().filter(|(g, _)| *g == generation).map(|(_, r)| r);
|
||||
let mut message = match (&start_error, code, &cfg.connect) {
|
||||
(Some(reason), _, _) => format!("{} failed to start: {reason}", self.name),
|
||||
(None, Some(c), _) => format!("{} exited with code {c}", self.name),
|
||||
(None, None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", self.name),
|
||||
(None, None, None) => format!("{} terminated unexpectedly", self.name),
|
||||
let mut message = match (code, &cfg.connect) {
|
||||
(Some(c), _) => format!("{} exited with code {c}", self.name),
|
||||
(None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", self.name),
|
||||
(None, None) => format!("{} terminated unexpectedly", self.name),
|
||||
};
|
||||
if !tail.is_empty() {
|
||||
message.push_str(":\n");
|
||||
@@ -504,7 +477,6 @@ impl Server {
|
||||
/// Records a crash; returns the backoff before restarting, or `None` once
|
||||
/// [`MAX_RESTARTS`] instances in a row died before running [`STABLE_UPTIME`]
|
||||
/// (a server that crashes on every start, however slowly, is not restarted forever).
|
||||
/// An instance that never became ready has run for no time at all.
|
||||
pub fn record_crash(&self) -> Option<Duration> {
|
||||
let uptime = self.started.lock().map(|t| t.elapsed()).unwrap_or_default();
|
||||
let mut crashes = self.crashes.lock();
|
||||
@@ -1067,38 +1039,6 @@ mod tests {
|
||||
assert!(s.is_busy(), "a reporting task keeps the server busy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_time_spent_ready_counts_as_uptime() {
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let s = server();
|
||||
for n in 1..=MAX_RESTARTS + 1 {
|
||||
// Never ready: crashes after a slow start count however long the start took.
|
||||
let generation = s.begin_start().unwrap();
|
||||
assert!(s.started.lock().is_none());
|
||||
s.set_start_error(generation, "it did not answer initialize within 300 s".into());
|
||||
let delay = s.on_closed(generation, &sink, None).unwrap();
|
||||
assert_eq!(delay.is_some(), n <= MAX_RESTARTS, "crash {n}");
|
||||
let message = s.status_message().unwrap();
|
||||
assert!(
|
||||
message.starts_with(&format!("{} failed to start: it did not answer initialize", s.name)),
|
||||
"{message}"
|
||||
);
|
||||
s.state.lock().conn = Conn::Idle;
|
||||
}
|
||||
assert!(matches!(s.state.lock().conn, Conn::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_is_live_from_its_start_until_it_is_retired() {
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
assert!(s.is_live(generation), "still starting: its progress (an import) is being watched");
|
||||
assert!(!s.is_live(generation + 1));
|
||||
assert!(s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, None));
|
||||
assert!(!s.is_live(generation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashes_of_short_lived_instances_exhaust_the_budget_whatever_the_spacing() {
|
||||
let s = server();
|
||||
|
||||
@@ -32,7 +32,7 @@ pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
|
||||
if matches!(key.as_str(), "data" | "arguments") {
|
||||
continue;
|
||||
}
|
||||
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri" | "target")
|
||||
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri")
|
||||
&& let Some(uri) = value.as_str().and_then(escaped_uri)
|
||||
{
|
||||
*value = uri.into();
|
||||
@@ -247,22 +247,6 @@ mod tests {
|
||||
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
|
||||
}
|
||||
|
||||
/// `DocumentLink.target` is a URI too: one unescaped link fails the whole `documentLink` response.
|
||||
#[test]
|
||||
fn document_link_targets_are_escaped() {
|
||||
let range = serde_json::json!({ "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } });
|
||||
let mut links = serde_json::json!([
|
||||
{ "range": range, "target": "file:///D:/My Game/项目/a.xsd" },
|
||||
{ "range": range, "target": "https://example.com/a b" }
|
||||
]);
|
||||
assert!(serde_json::from_value::<Vec<lsp_types::DocumentLink>>(links.clone()).is_err());
|
||||
normalize_uris(&mut links);
|
||||
assert_eq!(links[0]["target"], "file:///D:/My%20Game/%E9%A1%B9%E7%9B%AE/a.xsd");
|
||||
assert_eq!(links[1]["target"], "https://example.com/a b", "other schemes are not touched");
|
||||
links[1]["target"] = "https://example.com/ab".into();
|
||||
assert_eq!(serde_json::from_value::<Vec<lsp_types::DocumentLink>>(links).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// #3089: jdtls's Introduce Variable edit for a project under a Chinese path names the file
|
||||
/// with the characters unescaped, and the resolved code action failed to deserialize.
|
||||
#[test]
|
||||
|
||||
@@ -146,9 +146,6 @@ impl Handler for MockHandler {
|
||||
if let Some(gate) = gate {
|
||||
gate.hold();
|
||||
}
|
||||
if let Some(error) = s.errors.lock().get("initialize").cloned() {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } }))
|
||||
}
|
||||
"shutdown" => Ok(Value::Null),
|
||||
@@ -2076,29 +2073,6 @@ async fn crash_restarts_and_reopens_documents() {
|
||||
assert_eq!(hover.contents.value, "hi");
|
||||
}
|
||||
|
||||
/// A restart during a long start (a project import) returns when the instance is up and
|
||||
/// running, and does not hold up `reconfigure` and `shutdown_all` meanwhile.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn restart_does_not_hold_the_reconfigure_lock_while_the_server_starts() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*s.initialize_gate.lock() = Some(gate.clone());
|
||||
let id = h.mgr.servers()[0].id;
|
||||
let restart = tokio::spawn({
|
||||
let mgr = h.mgr.clone();
|
||||
async move { mgr.restart(id).await }
|
||||
});
|
||||
eventually(|| gate.entered()).await;
|
||||
let reconfigured = tokio::time::timeout(Duration::from_secs(2), h.mgr.reconfigure()).await;
|
||||
gate.open();
|
||||
reconfigured.expect("a restart still waiting for the server to start must not block reconfigure");
|
||||
restart.await.unwrap().unwrap();
|
||||
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -2126,118 +2100,6 @@ async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
|
||||
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
|
||||
}
|
||||
|
||||
/// One malformed diagnostic costs only itself, not the file's other problems.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_malformed_diagnostic_does_not_drop_the_others() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (_, path) = h.open("a.mk", "one");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let uri = uri_of(&path);
|
||||
let range = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":1}});
|
||||
h.launcher
|
||||
.state("mock")
|
||||
.peer()
|
||||
.notify(
|
||||
"textDocument/publishDiagnostics",
|
||||
json!({ "uri": uri, "diagnostics": [
|
||||
{ "range": range, "message": "first" },
|
||||
{ "message": "no range" },
|
||||
{ "range": range, "message": 7 },
|
||||
{ "range": range, "message": "last" }] }),
|
||||
)
|
||||
.unwrap();
|
||||
eventually(|| {
|
||||
h.events.lock().iter().any(|e| match e {
|
||||
IdeEvent::Diagnostics { items, .. } => {
|
||||
items.iter().map(|d| d.message.as_str()).collect::<Vec<_>>() == ["first", "last"]
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The `initialize` error is the crash report's reason, not "terminated unexpectedly", and
|
||||
/// a server that never gets past `initialize` gives up like any other crash loop.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_initialize_is_reported_with_its_reason_and_gives_up() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let s = h.launcher.state("mock");
|
||||
s.errors.lock().insert("initialize".into(), (-32603, "no workspace".into()));
|
||||
h.open("a.mk", "x");
|
||||
let count = |status| h.statuses("mock").iter().filter(|s| **s == status).count();
|
||||
eventually(|| count(ServerStatus::Crashed) == 4).await;
|
||||
let messages: Vec<String> = h
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
messages[0].contains("failed to start: initialize failed (server error -32603: no workspace)"),
|
||||
"{messages:?}"
|
||||
);
|
||||
assert!(messages[0].ends_with("Restarting…"));
|
||||
assert!(messages[3].ends_with("restart it manually."), "{messages:?}");
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 4, "initial start + 3 restarts");
|
||||
}
|
||||
|
||||
/// The client tells servers it retries semantic tokens, inlay hints and code lens that they
|
||||
/// withdrew with `ContentModified` (`staleRequestSupport`), so it must.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn withdrawn_editor_decoration_requests_are_asked_again() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, _) = h.open("a.mk", "let x = 1");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let range = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":1}});
|
||||
let cases = [
|
||||
("textDocument/semanticTokens/full", json!({ "data": [0, 0, 1, 0, 0] })),
|
||||
("textDocument/inlayHint", json!([{ "position": {"line":0,"character":0}, "label": "x" }])),
|
||||
("textDocument/codeLens", json!([{ "range": range, "command": { "title": "t", "command": "c" } }])),
|
||||
];
|
||||
for (method, answer) in cases {
|
||||
s.errors.lock().insert(method.into(), (-32801, "content modified".into()));
|
||||
let mgr = h.mgr.clone();
|
||||
let request = tokio::spawn(async move {
|
||||
match method {
|
||||
"textDocument/semanticTokens/full" => mgr.semantic_tokens(doc).await.map(|r| r.is_some()),
|
||||
"textDocument/inlayHint" => {
|
||||
mgr.inlay_hints(doc, Range::new(Pos::new(0, 0), Pos::new(0, 9))).await.map(|v| v.len() == 1)
|
||||
}
|
||||
_ => mgr.code_lens(doc).await.map(|v| v.len() == 1),
|
||||
}
|
||||
});
|
||||
eventually(|| !s.requested(method).is_empty()).await;
|
||||
s.errors.lock().remove(method);
|
||||
s.respond(method, answer);
|
||||
let answered = tokio::time::timeout(Duration::from_secs(5), request).await.unwrap().unwrap();
|
||||
assert!(answered.unwrap_or(false), "{method} was not asked again");
|
||||
assert_eq!(s.requested(method).len(), 2, "{method}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A server disabled in settings while its crash backoff is still sleeping stays gone: the
|
||||
/// pending restart must not bring it back as a process nothing lists or stops.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_server_disabled_during_its_crash_backoff_does_not_restart() {
|
||||
let h = harness(&[("mock", incremental_caps()), ("other", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
s.peer().kill();
|
||||
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
|
||||
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "other").cloned().collect()));
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| h.removed("mock")).await;
|
||||
tokio::time::sleep(Duration::from_millis(1200)).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1, "no restart after the server was dropped");
|
||||
}
|
||||
|
||||
/// The whole workspace root deleted: the server can never start again
|
||||
/// (its cwd is gone), so the crash drops the entry instead of restarting.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -3136,49 +2998,6 @@ async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
}
|
||||
|
||||
/// A server that is installed but cannot run (crashed for good, or its configuration
|
||||
/// can't launch it) hides its fallbacks no longer than one that is missing.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fallback_servers_take_over_from_a_preferred_one_that_crashed_for_good() {
|
||||
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
|
||||
let (doc, path) = h.open("a.mk", "x");
|
||||
let main = h.launcher.state("main");
|
||||
let count = |status| h.statuses("main").iter().filter(|s| **s == status).count();
|
||||
for n in 1..=4 {
|
||||
eventually(|| count(ServerStatus::Ready) == n).await;
|
||||
// Restarting after a crash is not giving up: the fallback stays away.
|
||||
assert!(h.statuses("alt").is_empty(), "alt started after crash {}", n - 1);
|
||||
main.peer().kill();
|
||||
eventually(|| count(ServerStatus::Crashed) == n).await;
|
||||
}
|
||||
h.wait_status("alt", ServerStatus::Ready).await;
|
||||
let uri = uri_of(&path);
|
||||
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
|
||||
// Restarting the preferred one by hand gives the documents back to it.
|
||||
let id = h.mgr.servers().into_iter().find(|s| s.name == "main").unwrap().id;
|
||||
h.mgr.restart(id).await.unwrap();
|
||||
eventually(|| h.removed("alt")).await;
|
||||
assert_eq!(h.mgr.servers().iter().filter(|s| s.name == "alt").count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fallback_servers_take_over_from_a_preferred_one_that_cannot_be_launched() {
|
||||
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| {
|
||||
if cfg.name == "main" {
|
||||
cfg.launch_error = Some("needs JDK 21".into());
|
||||
}
|
||||
}));
|
||||
let (doc, path) = h.open("a.mk", "x");
|
||||
h.wait_status("main", ServerStatus::Misconfigured).await;
|
||||
h.wait_status("alt", ServerStatus::Ready).await;
|
||||
let uri = uri_of(&path);
|
||||
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_server_requiring_a_root_marker_serves_only_files_below_one() {
|
||||
// `framework` next to `main` (one instance for the whole project), like the
|
||||
|
||||
@@ -193,12 +193,6 @@ fn read_only_error(path: &Path) -> IdeError {
|
||||
IdeError::new(ErrorKind::PermissionDenied, format!("{} is read-only", path.display()))
|
||||
}
|
||||
|
||||
/// The synthetic key of an unsaved new-file buffer (`untitled:Untitled-1`);
|
||||
/// minted by [`Documents::open_untitled`], replaced by a real path on save.
|
||||
fn is_untitled(path: &Path) -> bool {
|
||||
path.to_str().is_some_and(|p| p.starts_with("untitled:"))
|
||||
}
|
||||
|
||||
impl Documents {
|
||||
pub fn new(sink: SharedSink, detect_language: Box<DetectLanguage>) -> Self {
|
||||
Self {
|
||||
@@ -535,75 +529,6 @@ impl Documents {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Opens (or re-references) an editable buffer without a file behind it:
|
||||
/// a new-file tab ("Untitled-1"). Keyed by an `untitled:` URI like virtual
|
||||
/// documents, so it never touches the disk until [`Documents::save_as`]
|
||||
/// gives it a path (which also re-detects the language). Encoding, BOM and
|
||||
/// line ending come from the file defaults. `name` re-references an
|
||||
/// existing buffer (`Some("untitled:Untitled-1")`); `None` mints the
|
||||
/// first free one.
|
||||
pub fn open_untitled(&self, name: Option<&str>) -> IdeResult<DocumentInfo> {
|
||||
let _operation = self.operations.lock();
|
||||
let key = name.map(PathBuf::from).unwrap_or_else(|| self.next_untitled_key());
|
||||
if let Some(info) = self.reopen(&key) {
|
||||
return Ok(info);
|
||||
}
|
||||
let defaults = self.file_defaults();
|
||||
let mut doc = Document::new("");
|
||||
doc.set_line_ending(defaults.line_ending);
|
||||
let state = DocState {
|
||||
path: key.clone(),
|
||||
doc,
|
||||
language: LanguageId::plain_text(),
|
||||
ui_mode: None,
|
||||
encoding: defaults.encoding.clone(),
|
||||
has_bom: defaults.encoding == "utf-8" && defaults.utf8_bom,
|
||||
native_to_ascii: false,
|
||||
read_only: false,
|
||||
large: false,
|
||||
editorconfig: None,
|
||||
observed: true,
|
||||
binary: false,
|
||||
dirty: false,
|
||||
can_reload_encoding: false,
|
||||
encoding_chosen: false,
|
||||
disk_mtime: 0.0,
|
||||
disk_len: 0,
|
||||
closed: false,
|
||||
uri: Some(key.to_string_lossy().into_owned()),
|
||||
};
|
||||
let slot: Slot = Arc::new(Mutex::new(state));
|
||||
let st = slot.lock();
|
||||
let id = {
|
||||
let mut reg = self.registry.lock();
|
||||
if reg.by_key.contains_key(&key) {
|
||||
// Lost a race with a concurrent open of the same untitled buffer.
|
||||
drop(reg);
|
||||
drop(st);
|
||||
return self.reopen(&key).ok_or_else(|| IdeError::internal("document vanished while opening"));
|
||||
}
|
||||
let id = DocId::next();
|
||||
reg.by_key.insert(key.clone(), id);
|
||||
reg.docs.insert(id, RegEntry { slot: slot.clone(), refs: 1, keys: vec![key] });
|
||||
id
|
||||
};
|
||||
let info = Self::info(id, &st);
|
||||
for o in self.observers() {
|
||||
o.opened(&info);
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// The first free `untitled:Untitled-N` key.
|
||||
fn next_untitled_key(&self) -> PathBuf {
|
||||
let reg = self.registry.lock();
|
||||
let mut n = 1;
|
||||
while reg.by_key.contains_key(&PathBuf::from(format!("untitled:Untitled-{n}"))) {
|
||||
n += 1;
|
||||
}
|
||||
PathBuf::from(format!("untitled:Untitled-{n}"))
|
||||
}
|
||||
|
||||
fn reopen(&self, path: &Path) -> Option<DocumentInfo> {
|
||||
let slot = {
|
||||
let mut reg = self.registry.lock();
|
||||
@@ -815,90 +740,6 @@ impl Documents {
|
||||
})
|
||||
}
|
||||
|
||||
/// Save As for an untitled buffer: writes it to `target` (creating missing
|
||||
/// parent folders) and turns it into a regular file document — the buffer
|
||||
/// keeps its identity (undo history, version), the registry is re-keyed and
|
||||
/// the language is re-detected from the new path. The target path must not
|
||||
/// be open as another document.
|
||||
pub fn save_as(&self, doc: DocId, target: &Path) -> IdeResult<DocumentInfo> {
|
||||
let _operation = self.operations.lock();
|
||||
// The caller picks a real file path; an `untitled:` key is never one
|
||||
// (and must not be resolved against the working directory either).
|
||||
if is_untitled(target) {
|
||||
return Err(IdeError::invalid("cannot save over an untitled buffer"));
|
||||
}
|
||||
let target = absolute(target);
|
||||
{
|
||||
let reg = self.registry.lock();
|
||||
if let Some(other) = Self::lookup(®, &target)
|
||||
&& other != doc
|
||||
{
|
||||
return Err(IdeError::invalid(format!("{} is already open", target.display())));
|
||||
}
|
||||
}
|
||||
self.with_doc(doc, |st| {
|
||||
if st.uri.is_none() || !is_untitled(&st.path) {
|
||||
return Err(IdeError::invalid("only an untitled document can be saved to a new path"));
|
||||
}
|
||||
let text = st.doc.text_for_save();
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mtime = if st.native_to_ascii {
|
||||
ide_vfs::save_properties_text(&target, &text)?
|
||||
} else {
|
||||
ide_vfs::save_text(&target, &text, &st.encoding, st.has_bom)?
|
||||
};
|
||||
// Re-key the registry before anyone opens the new path in parallel.
|
||||
let keys = keys_for(&target);
|
||||
{
|
||||
let mut reg = self.registry.lock();
|
||||
let old_keys = match reg.docs.get_mut(&doc) {
|
||||
Some(entry) => std::mem::take(&mut entry.keys),
|
||||
None => return Err(IdeError::not_found("document closed while saving")),
|
||||
};
|
||||
for k in &old_keys {
|
||||
if reg.by_key.get(k) == Some(&doc) {
|
||||
reg.by_key.remove(k);
|
||||
}
|
||||
}
|
||||
for k in &keys {
|
||||
reg.by_key.insert(k.clone(), doc);
|
||||
}
|
||||
if let Some(entry) = reg.docs.get_mut(&doc) {
|
||||
entry.keys = keys;
|
||||
}
|
||||
}
|
||||
let old_path = std::mem::replace(&mut st.path, target.clone());
|
||||
st.uri = None;
|
||||
let defaults = self.file_defaults();
|
||||
let first_line = st.doc.text();
|
||||
let first_line = first_line.lines().next();
|
||||
let language = (self.detect_language)(&st.path, first_line);
|
||||
let ui_mode = (self.detect_ui_mode)(&st.path, &language);
|
||||
let language_changed = language != st.language;
|
||||
st.language = language.clone();
|
||||
st.ui_mode = ui_mode;
|
||||
st.editorconfig = if defaults.editorconfig { self.editorconfig.resolve(&st.path) } else { None };
|
||||
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
|
||||
st.disk_mtime = mtime;
|
||||
st.disk_len = disk_len(&st.path);
|
||||
self.set_dirty(doc, st, false);
|
||||
let info = Self::info(doc, st);
|
||||
if st.observed {
|
||||
for o in self.observers() {
|
||||
o.closed(doc, &old_path);
|
||||
o.opened(&info);
|
||||
o.saved(doc, &st.path, &st.doc.text());
|
||||
}
|
||||
}
|
||||
if language_changed {
|
||||
self.sink.emit(IdeEvent::DocLanguage { doc, language, ui_mode: st.ui_mode.clone() });
|
||||
}
|
||||
Ok(info)
|
||||
})
|
||||
}
|
||||
|
||||
/// Saves every dirty document; reports the first failure after trying all.
|
||||
pub fn save_all(&self) -> IdeResult<()> {
|
||||
let ids: Vec<DocId> = self.registry.lock().docs.keys().copied().collect();
|
||||
@@ -1053,12 +894,9 @@ impl Documents {
|
||||
/// is kept and the next save writes it in the new encoding.
|
||||
pub fn set_encoding(&self, doc: DocId, encoding: &str, reload: bool) -> IdeResult<DocumentEncoding> {
|
||||
self.with_doc(doc, |st| {
|
||||
if st.uri.is_some() && !is_untitled(&st.path) {
|
||||
if st.uri.is_some() {
|
||||
return Err(read_only_error(&st.path));
|
||||
}
|
||||
if reload && st.uri.is_some() {
|
||||
return Err(IdeError::invalid("save the file before reloading it with another encoding"));
|
||||
}
|
||||
ide_vfs::encode("", encoding, false)?;
|
||||
if reload {
|
||||
if st.dirty {
|
||||
@@ -1099,42 +937,6 @@ impl Documents {
|
||||
self.with_doc(doc, |st| Ok(st.uri.clone()))
|
||||
}
|
||||
|
||||
/// Whether this is an untitled (new-file) buffer: editable, no file behind
|
||||
/// it until [`Documents::save_as`].
|
||||
pub fn is_untitled(&self, doc: DocId) -> IdeResult<bool> {
|
||||
self.with_doc(doc, |st| Ok(is_untitled(&st.path)))
|
||||
}
|
||||
|
||||
/// The open untitled buffers: `(key, text, dirty)` — they belong to the
|
||||
/// window rather than a project, and [`Documents::restore_untitled_buffer`]
|
||||
/// carries them into a project's fresh document registry.
|
||||
pub fn untitled_buffers(&self) -> Vec<(String, String, bool)> {
|
||||
let slots: Vec<Slot> = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect();
|
||||
let mut out = Vec::new();
|
||||
for slot in slots {
|
||||
let st = slot.lock();
|
||||
if !st.closed && is_untitled(&st.path) {
|
||||
out.push((st.path.to_string_lossy().into_owned(), st.doc.text(), st.dirty));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Recreates an untitled buffer carried over from the window's previous
|
||||
/// project: same key, same text, same dirty flag.
|
||||
pub fn restore_untitled_buffer(&self, key: &str, text: &str, dirty: bool) -> IdeResult<()> {
|
||||
let info = self.open_untitled(Some(key))?;
|
||||
if info.text != text {
|
||||
self.replace_all(info.id, text)?;
|
||||
}
|
||||
// `replace_all` marks the buffer dirty; the carried flag is authoritative.
|
||||
self.with_doc(info.id, |st| {
|
||||
self.set_dirty(info.id, st, dirty);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-decoded content of a virtual document ("Reload with Encoding"):
|
||||
/// replaces the text and the encoding label, keeping the document
|
||||
/// read-only and clean. The caller re-reads the origin (archive entry,
|
||||
@@ -1165,7 +967,7 @@ impl Documents {
|
||||
/// Adds or removes the BOM on a UTF-8 document; the change is written on save.
|
||||
pub fn set_bom(&self, doc: DocId, has_bom: bool) -> IdeResult<DocumentEncoding> {
|
||||
self.with_doc(doc, |st| {
|
||||
if st.read_only || (st.uri.is_some() && !is_untitled(&st.path)) {
|
||||
if st.uri.is_some() || st.read_only {
|
||||
return Err(read_only_error(&st.path));
|
||||
}
|
||||
if st.encoding != "utf-8" || st.native_to_ascii {
|
||||
|
||||
@@ -1004,104 +1004,3 @@ fn properties_ascii_noop_and_charset_conversion() {
|
||||
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, "a=世界😀");
|
||||
assert!(!f.docs.is_dirty(doc.id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untitled_buffers_are_editable_and_saved_to_a_chosen_path() {
|
||||
let f = fixture();
|
||||
let mut defaults = f.docs.file_defaults();
|
||||
defaults.line_ending = LineEnding::Lf;
|
||||
f.docs.set_file_defaults(defaults);
|
||||
let info = f.docs.open_untitled(None).unwrap();
|
||||
assert_eq!(info.path, PathBuf::from("untitled:Untitled-1"));
|
||||
assert_eq!(info.uri.as_deref(), Some("untitled:Untitled-1"));
|
||||
assert_eq!(info.language, LanguageId::plain_text());
|
||||
assert!(!info.read_only && !info.dirty && !info.large);
|
||||
|
||||
// Editing works; nothing is written anywhere.
|
||||
let version = f.docs.edit(info.id, info.version, change(0, 0, "fn main() {}\n")).unwrap();
|
||||
assert!(f.docs.is_dirty(info.id).unwrap());
|
||||
f.docs.save(info.id).unwrap();
|
||||
assert!(f.docs.is_dirty(info.id).unwrap(), "an untitled buffer has no file to save to");
|
||||
|
||||
// Save As: the buffer reaches the disk and becomes a regular file document
|
||||
// (same id, re-keyed, language re-detected, missing parents created).
|
||||
let target = f.root.join("new/nested/main.rs");
|
||||
let saved = f.docs.save_as(info.id, &target).unwrap();
|
||||
assert_eq!(saved.id, info.id);
|
||||
assert_eq!(saved.path, target);
|
||||
assert_eq!(saved.uri, None);
|
||||
assert_eq!(saved.language, LanguageId::new("rust"));
|
||||
assert!(!saved.dirty);
|
||||
assert_eq!(fs::read_to_string(&target).unwrap(), "fn main() {}\n");
|
||||
assert_eq!(f.docs.by_path(&target), Some(info.id));
|
||||
assert_eq!(f.docs.by_path(Path::new("untitled:Untitled-1")), None);
|
||||
f.docs.edit(info.id, version, change(0, 0, "// hi\n")).unwrap();
|
||||
f.docs.save(info.id).unwrap();
|
||||
assert!(!f.docs.is_dirty(info.id).unwrap());
|
||||
assert_eq!(fs::read_to_string(&target).unwrap(), "// hi\nfn main() {}\n");
|
||||
|
||||
// Re-opening the file references the same buffer (split editors share it).
|
||||
let again = f.docs.open(&target).unwrap();
|
||||
assert_eq!(again.id, info.id);
|
||||
f.docs.close(again.id);
|
||||
assert!(f.docs.snapshot(info.id).is_ok());
|
||||
f.docs.close(info.id);
|
||||
assert!(f.docs.snapshot(info.id).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_rejects_paths_opened_elsewhere_and_reuses_free_names() {
|
||||
let f = fixture();
|
||||
let first = f.root.join("taken.txt");
|
||||
fs::write(&first, "taken").unwrap();
|
||||
let taken = f.docs.open(&first).unwrap();
|
||||
|
||||
let a = f.docs.open_untitled(None).unwrap();
|
||||
let b = f.docs.open_untitled(None).unwrap();
|
||||
assert_eq!(a.path, PathBuf::from("untitled:Untitled-1"));
|
||||
assert_eq!(b.path, PathBuf::from("untitled:Untitled-2"));
|
||||
f.docs.edit(a.id, a.version, change(0, 0, "draft")).unwrap();
|
||||
|
||||
let err = f.docs.save_as(a.id, &first).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
||||
assert!(f.docs.is_dirty(a.id).unwrap(), "nothing was written");
|
||||
assert_eq!(fs::read_to_string(&first).unwrap(), "taken");
|
||||
|
||||
let err = f.docs.save_as(a.id, Path::new("untitled:Untitled-2")).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
||||
|
||||
// The smallest free name is minted again after a buffer closes.
|
||||
f.docs.close(a.id);
|
||||
let c = f.docs.open_untitled(None).unwrap();
|
||||
assert_eq!(c.path, PathBuf::from("untitled:Untitled-1"));
|
||||
|
||||
// Non-untitled documents cannot be re-keyed.
|
||||
let err = f.docs.save_as(taken.id, &f.root.join("other.txt")).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untitled_buffers_take_file_defaults_and_refuse_encoding_reloads() {
|
||||
let f = fixture();
|
||||
let mut defaults = f.docs.file_defaults();
|
||||
defaults.line_ending = LineEnding::CrLf;
|
||||
defaults.encoding = "utf-8".into();
|
||||
defaults.utf8_bom = true;
|
||||
f.docs.set_file_defaults(defaults);
|
||||
let info = f.docs.open_untitled(None).unwrap();
|
||||
assert_eq!(info.line_ending, LineEnding::CrLf);
|
||||
assert!(info.has_bom);
|
||||
assert!(!info.can_reload_encoding, "there is no file to re-read yet");
|
||||
|
||||
// Converting the encoding of the unsaved text works; re-decoding does not.
|
||||
let encoding = f.docs.set_encoding(info.id, "utf-16le", false).unwrap();
|
||||
assert_eq!(encoding.encoding, "utf-16le");
|
||||
let err = f.docs.set_encoding(info.id, "utf-8", true).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
||||
|
||||
// Disk syncs never touch an untitled buffer.
|
||||
f.docs.edit(info.id, info.version, change(0, 0, "draft")).unwrap();
|
||||
f.docs.sync_after_disk_change();
|
||||
assert!(f.docs.is_dirty(info.id).unwrap());
|
||||
assert_eq!(f.docs.snapshot(info.id).unwrap().text, "draft");
|
||||
}
|
||||
@@ -88,8 +88,6 @@ Once an agent has run a task, new tasks start in its mode that approves everythi
|
||||
|
||||
Click **Show changes** in the task header to list the files the task changed. Click a file to open a diff between the task's starting point and now. For a worktree task, the changes live on its `agent/…` branch: open a terminal there with **Open in Terminal**, and merge or cherry-pick the branch with the Git tools (see [Version control](/en/guide/vcs)) when you are happy with it.
|
||||
|
||||
Drag the pane's left edge to resize it. The width applies to every task and is remembered across restarts.
|
||||
|
||||
### Context from the editor
|
||||
|
||||
- **Send to Agent** in the editor and Project view context menus adds the selection or the selected files as `@` references to the message you typed last. It has no default shortcut; assign one in **Settings › Keymap**.
|
||||
|
||||
@@ -88,8 +88,6 @@ agent 运行过一个任务后,新任务会以它自动批准所有操作的
|
||||
|
||||
在任务标题栏中单击 **显示更改**,列出任务改动过的文件;单击文件可以打开它从任务开始到现在的 diff。worktree 任务的改动在它的 `agent/…` 分支上:用 **在终端中打开** 在那里打开终端,满意后用 Git 工具合并或 cherry-pick 这个分支(见 [版本控制](/zh/guide/vcs))。
|
||||
|
||||
拖动改动面板的左边缘可以调整宽度。宽度对所有任务生效,重启后保留。
|
||||
|
||||
### 从编辑器提供上下文
|
||||
|
||||
- 编辑器和项目视图右键菜单中的 **发送给 Agent** 会把选区或选中的文件作为 `@` 引用加到你最近输入的消息中。它默认没有快捷键,可以在 **设置 › 按键映射** 中分配。
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { AgentTask } from "../src/bindings";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
const task: AgentTask = {
|
||||
id: "composer-click",
|
||||
title: "Composer click task",
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
async function openTask(page: Page) {
|
||||
await page.evaluate(async (task) => {
|
||||
// These modules must load in the browser's Vite realm, not Playwright's Node process.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const ipcPath = "/src/platform/ipc.ts";
|
||||
const store = await import(storePath);
|
||||
const { commands } = await import(ipcPath);
|
||||
commands.agentChanges = async () => ({ status: "ok", data: [] });
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, [{ type: "agent", text: "Reply" }]]]),
|
||||
composers: new Map(),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
homeOpen: false,
|
||||
sessionLocation: "editor",
|
||||
});
|
||||
store.openTask(task.id);
|
||||
}, task);
|
||||
}
|
||||
|
||||
// Every row of the text area, down to the toolbar row, puts the caret in the message.
|
||||
test("clicking anywhere in the composer's text area focuses the message", async ({ workbench: page }) => {
|
||||
await openTask(page);
|
||||
const box = page.getByRole("textbox", { name: "Message", exact: true }).filter({ visible: true });
|
||||
await expect(box).toBeVisible();
|
||||
const composer = page.locator("[class*='_composer_']:visible").filter({ has: box });
|
||||
const bar = composer.locator("[class*='_bar_']");
|
||||
const frame = (await composer.boundingBox())!;
|
||||
const barTop = (await bar.boundingBox())!.y;
|
||||
const history = page.locator("[class*='_stream_']:visible");
|
||||
const missed: number[] = [];
|
||||
for (let y = Math.ceil(frame.y) + 1; y < barTop; y++) {
|
||||
await history.click({ position: { x: 12, y: 12 } });
|
||||
await expect(box).not.toBeFocused();
|
||||
await page.mouse.click(frame.x + frame.width / 2, y);
|
||||
if (!(await box.evaluate((element) => element === document.activeElement))) missed.push(y - frame.y);
|
||||
}
|
||||
expect(missed).toEqual([]);
|
||||
await page.keyboard.type("typed");
|
||||
await expect(box).toHaveText("typed");
|
||||
});
|
||||
@@ -88,30 +88,6 @@ async function seedTasks(page: Page, placement: Placement, status: AgentTask["st
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
|
||||
async function seedChanges(page: Page) {
|
||||
await page.evaluate(async (id) => {
|
||||
// Seed the store in the browser realm; a static import would run in Node.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
(await import(storePath)).useAgents.setState({
|
||||
changes: new Map([
|
||||
[
|
||||
id,
|
||||
[
|
||||
{
|
||||
path: "/dev/acme-api/file.ts",
|
||||
relPath: "file.ts",
|
||||
kind: "modified",
|
||||
added: 1,
|
||||
removed: 0,
|
||||
oldPath: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
}, firstTask.id);
|
||||
}
|
||||
|
||||
function messageBox(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message", exact: true }).filter({ visible: true });
|
||||
}
|
||||
@@ -369,7 +345,27 @@ test("docked Changes view returns to the task input without losing its selection
|
||||
await seedTasks(page, "toolWindow");
|
||||
const box = messageBox(page);
|
||||
const expected = await selectDraft(box, true);
|
||||
await seedChanges(page);
|
||||
await page.evaluate(async (id) => {
|
||||
// Seed the store in the browser realm; a static import would run in Node.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
(await import(storePath)).useAgents.setState({
|
||||
changes: new Map([
|
||||
[
|
||||
id,
|
||||
[
|
||||
{
|
||||
path: "/dev/acme-api/file.ts",
|
||||
relPath: "file.ts",
|
||||
kind: "modified",
|
||||
added: 1,
|
||||
removed: 0,
|
||||
oldPath: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
}, firstTask.id);
|
||||
await page.getByRole("button", { name: /1 changed file/ }).click();
|
||||
const changes = page.getByRole("complementary", { name: "Changes", exact: true });
|
||||
await expect(changes).toBeVisible();
|
||||
@@ -381,25 +377,3 @@ test("docked Changes view returns to the task input without losing its selection
|
||||
await expect(box).toHaveText(draft);
|
||||
await expect.poll(() => selection(box)).toEqual(expected);
|
||||
});
|
||||
|
||||
test("dragging the Changes pane's edge resizes it for the next session too", async ({ workbench: page }) => {
|
||||
await seedTasks(page, "editor");
|
||||
await seedChanges(page);
|
||||
const changes = page.getByRole("complementary", { name: "Changes", exact: true });
|
||||
await expect(changes).toBeVisible();
|
||||
const handle = changes.getByRole("separator", { name: "Resize changes", exact: true });
|
||||
const before = (await changes.boundingBox())!.width;
|
||||
const box = (await handle.boundingBox())!;
|
||||
await page.mouse.move(box.x, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 120, box.y + box.height / 2, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => (await changes.boundingBox())!.width).toBeGreaterThan(before + 100);
|
||||
const widened = (await changes.boundingBox())!.width;
|
||||
|
||||
// The next session starts with the dragged width.
|
||||
await page.reload();
|
||||
await seedTasks(page, "editor");
|
||||
await seedChanges(page);
|
||||
await expect.poll(async () => Math.round((await changes.boundingBox())!.width)).toBe(Math.round(widened));
|
||||
});
|
||||
@@ -17,7 +17,7 @@ name = "kotlin-lsp"
|
||||
command = "kotlin-lsp"
|
||||
args = ["--stdio"]
|
||||
root-markers = ["settings.gradle.kts", "settings.gradle", "build.gradle.kts", "build.gradle", "pom.xml"]
|
||||
install-hint = "Install JetBrains Kotlin LSP in Settings › Languages & Frameworks › Language Servers (https://github.com/Kotlin/kotlin-lsp), or with Homebrew: brew install JetBrains/utils/kotlin-lsp"
|
||||
install-hint = "Install JetBrains Kotlin LSP: brew install JetBrains/utils/kotlin-lsp (https://github.com/Kotlin/kotlin-lsp)"
|
||||
|
||||
[[language-servers]]
|
||||
name = "kotlin-language-server"
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"--syn-type": "#8fd4c9",
|
||||
"--syn-function": "#f2d07a",
|
||||
"--syn-function-call": "#d9dfe8",
|
||||
"--syn-shell-command": "#e8a46a",
|
||||
"--syn-comment": "#6b7a8f",
|
||||
"--syn-comment-style": "normal",
|
||||
"--syn-doc-comment": "#74889c",
|
||||
|
||||
@@ -332,7 +332,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
267 themeable tokens: 125 in the UI theme file, 142 in the editor colour scheme file.
|
||||
266 themeable tokens: 125 in the UI theme file, 141 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -429,7 +429,6 @@ a token.
|
||||
| `--syn-type` | `#bcbec4` | `#000000` |
|
||||
| `--syn-function` | `#56a8f5` | `#00627a` |
|
||||
| `--syn-function-call` | `#bcbec4` | `#080808` |
|
||||
| `--syn-shell-command` | `#c57633` | `#0073bf` |
|
||||
| `--syn-comment` | `#7a7e85` | `#8c8c8c` |
|
||||
| `--syn-comment-style` | `normal` | `italic` |
|
||||
| `--syn-doc-comment` | `#5f826b` | `#8c8c8c` |
|
||||
|
||||
@@ -119,33 +119,6 @@ pub async fn light_edit_open(window: Window, path: PathBuf) -> IdeResult<Setting
|
||||
Ok(project.settings.effective())
|
||||
}
|
||||
|
||||
/// LightEdit without a file (File › New File from a welcome window); returns
|
||||
/// the effective (global) settings.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn light_edit_open_untitled(window: Window) -> IdeResult<Settings> {
|
||||
let app = window.app_handle().clone();
|
||||
let label = window.label().to_owned();
|
||||
let log_label = label.clone();
|
||||
let result = async {
|
||||
if app.state::<AppState>().project(&label).is_ok_and(|p| !p.light) {
|
||||
crate::windows::close_auxiliary_windows(&app, &label);
|
||||
crate::window_groups::detach(&app, &label)?;
|
||||
crate::window_groups::persist(&app).await?;
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || app.state::<AppState>().open_light_empty(&label))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
};
|
||||
match result.await {
|
||||
Ok(project) => Ok(project.settings.effective()),
|
||||
Err(e) => {
|
||||
tracing::warn!("light_edit_open_untitled ({log_label}): {e}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn project_info(project: &crate::state::Project) -> ProjectInfo {
|
||||
ide_host::ops::project::info(project)
|
||||
}
|
||||
|
||||
@@ -26,31 +26,6 @@ pub async fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -
|
||||
blocking(move || ide_host::ops::doc::close(&host, &window, doc)).await
|
||||
}
|
||||
|
||||
/// Opens an editable untitled buffer (a new-file tab); it reaches the disk
|
||||
/// through `doc_save_as`, which turns it into a regular file document.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_open_untitled(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
name: Option<String>,
|
||||
) -> IdeResult<DocumentInfo> {
|
||||
ide_host::ops::doc::open_untitled(&state.host, window.label(), name).await
|
||||
}
|
||||
|
||||
/// Save As for an untitled buffer: writes it to `path` and re-keys the buffer
|
||||
/// (undo history and version are kept; the language is re-detected).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_save_as(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
doc: DocId,
|
||||
path: PathBuf,
|
||||
) -> IdeResult<DocumentInfo> {
|
||||
ide_host::ops::doc::save_as(&state.host, window.label(), doc, path).await
|
||||
}
|
||||
|
||||
/// Applies a CodeMirror change set; returns the new version. Rejects with
|
||||
/// `outOfSync` when `base_version` is stale; the UI then calls `doc_snapshot`.
|
||||
#[tauri::command]
|
||||
|
||||
@@ -106,7 +106,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window_group::window_group_reorder,
|
||||
window_group::window_group_restore,
|
||||
app::light_edit_open,
|
||||
app::light_edit_open_untitled,
|
||||
app::project_current,
|
||||
app::close_project,
|
||||
app::languages,
|
||||
@@ -199,11 +198,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
fonts::system_font_families,
|
||||
doc::doc_open,
|
||||
doc::doc_open_virtual,
|
||||
doc::doc_open_untitled,
|
||||
doc::doc_close,
|
||||
doc::doc_edit,
|
||||
doc::doc_save,
|
||||
doc::doc_save_as,
|
||||
doc::doc_save_all,
|
||||
doc::doc_sync_from_disk,
|
||||
doc::doc_snapshot,
|
||||
|
||||
@@ -217,7 +217,6 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
}
|
||||
|
||||
let mut file = vec![
|
||||
act("file.newUntitled", "New Untitled File"),
|
||||
sub(
|
||||
"new",
|
||||
"New",
|
||||
|
||||
@@ -170,12 +170,6 @@ impl AppState {
|
||||
self.host.open_light(window, file)
|
||||
}
|
||||
|
||||
/// LightEdit without a file (File › New File from a welcome window).
|
||||
pub fn open_light_empty(&self, window: &str) -> IdeResult<Arc<Project>> {
|
||||
self.remote_dev.disconnect(window);
|
||||
self.host.open_light_empty(window)
|
||||
}
|
||||
|
||||
/// Called on the event-loop thread (window destruction, the `close_project`
|
||||
/// command), so it must not wait for the project's workers to shut down.
|
||||
pub fn close_project(&self, window: &str) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"title": "OpenIDEA",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 400,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"title": "OpenIDEA",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 400,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"decorations": false,
|
||||
"backgroundColor": "#E9EAEE",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"center": true,
|
||||
"minWidth": 400,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"decorations": false,
|
||||
"backgroundColor": "#E9EAEE",
|
||||
|
||||
+1
-18
@@ -75,11 +75,6 @@ export const commands = {
|
||||
* 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 })),
|
||||
/**
|
||||
* LightEdit without a file (File › New File from a welcome window); returns
|
||||
* the effective (global) settings.
|
||||
*/
|
||||
lightEditOpenUntitled: () => typedError<Settings_Serialize, IdeError>(__TAURI_INVOKE("light_edit_open_untitled")),
|
||||
/** The project open in this window (a settings window: its parent's), if any. */
|
||||
projectCurrent: () => __TAURI_INVOKE<{
|
||||
trusted: boolean,
|
||||
@@ -330,11 +325,6 @@ export const commands = {
|
||||
* editor buffer, so syntax features and language-server requests work in it.
|
||||
*/
|
||||
docOpenVirtual: (uri: string) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_open_virtual", { uri })),
|
||||
/**
|
||||
* Opens an editable untitled buffer (a new-file tab); it reaches the disk
|
||||
* through `doc_save_as`, which turns it into a regular file document.
|
||||
*/
|
||||
docOpenUntitled: (name: string | null) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_open_untitled", { name })),
|
||||
docClose: (doc: DocId) => typedError<null, IdeError>(__TAURI_INVOKE("doc_close", { doc })),
|
||||
/**
|
||||
* Applies a CodeMirror change set; returns the new version. Rejects with
|
||||
@@ -342,11 +332,6 @@ export const commands = {
|
||||
*/
|
||||
docEdit: (doc: DocId, baseVersion: number, changes: ChangeSet) => typedError<number, IdeError>(__TAURI_INVOKE("doc_edit", { doc, baseVersion, changes })),
|
||||
docSave: (doc: DocId) => typedError<null, IdeError>(__TAURI_INVOKE("doc_save", { doc })),
|
||||
/**
|
||||
* Save As for an untitled buffer: writes it to `path` and re-keys the buffer
|
||||
* (undo history and version are kept; the language is re-detected).
|
||||
*/
|
||||
docSaveAs: (doc: DocId, path: string) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_save_as", { doc, path })),
|
||||
docSaveAll: () => typedError<null, IdeError>(__TAURI_INVOKE("doc_save_all")),
|
||||
docSyncFromDisk: () => typedError<null, IdeError>(__TAURI_INVOKE("doc_sync_from_disk")),
|
||||
docSnapshot: (doc: DocId) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_snapshot", { doc })),
|
||||
@@ -10197,9 +10182,7 @@ export type SyntaxErrors = {
|
||||
};
|
||||
|
||||
/** Highlight classes sent to the UI, which maps each onto a Lezer tag. */
|
||||
export type SyntaxHighlightKind = "keyword" | "string" | "stringSpecial" | "escape" | "regexp" | "atom" | "number" | "constant" | "comment" | "docComment" | "function" | "functionCall" | "builtin" | "type" | "namespace" | "variable" | "selfVariable" | "property" | "attribute" | "macro" | "label" | "tag" | "operator" | "punctuation" | "parameter" |
|
||||
/** A shell command name (`pgrep`, a script's own function). */
|
||||
"command";
|
||||
export type SyntaxHighlightKind = "keyword" | "string" | "stringSpecial" | "escape" | "regexp" | "atom" | "number" | "constant" | "comment" | "docComment" | "function" | "functionCall" | "builtin" | "type" | "namespace" | "variable" | "selfVariable" | "property" | "attribute" | "macro" | "label" | "tag" | "operator" | "punctuation" | "parameter";
|
||||
|
||||
/** Highlighted ranges of a whole document, packed like LSP semantic tokens. */
|
||||
export type SyntaxHighlights = {
|
||||
|
||||
@@ -532,15 +532,6 @@ export function _provideNavigationHistory(provider: () => NavigationHistorySnaps
|
||||
navigationHistoryProvider = provider;
|
||||
}
|
||||
|
||||
/** A project↔file-editor switch is clearing and re-filling the tabs. @internal editor/core only. */
|
||||
let editorSwitchingFlag = false;
|
||||
export function _setEditorSwitching(value: boolean) {
|
||||
editorSwitchingFlag = value;
|
||||
}
|
||||
export function editorSwitching(): boolean {
|
||||
return editorSwitchingFlag;
|
||||
}
|
||||
|
||||
// --- editor events ------------------------------------------------------------
|
||||
|
||||
export type EditorEvent =
|
||||
|
||||
@@ -41,10 +41,6 @@ export interface DocBackend {
|
||||
open(path: string): Promise<DocumentInfo>;
|
||||
/** Opens a read-only language-server document (library or disk class contents). */
|
||||
openVirtual(uri: string): Promise<DocumentInfo>;
|
||||
/** Opens (or re-references) an editable untitled buffer (`untitled:Untitled-N`); `null` mints one. */
|
||||
openUntitled(name: string | null): Promise<DocumentInfo>;
|
||||
/** Save As for an untitled buffer: writes it to `path` and re-keys the document. */
|
||||
saveAs(doc: DocId, path: string): Promise<DocumentInfo>;
|
||||
close(doc: DocId): Promise<void>;
|
||||
edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise<number>;
|
||||
save(doc: DocId): Promise<void>;
|
||||
@@ -83,8 +79,6 @@ export interface DocBackend {
|
||||
export const ipcBackend: DocBackend = {
|
||||
open: (path) => call(commands.docOpen(path)),
|
||||
openVirtual: (uri) => call(commands.docOpenVirtual(uri)),
|
||||
openUntitled: (name) => call(commands.docOpenUntitled(name)),
|
||||
saveAs: (doc, path) => call(commands.docSaveAs(doc, path)),
|
||||
close: async (doc) => void (await call(commands.docClose(doc))),
|
||||
edit: (doc, baseVersion, changes) => call(commands.docEdit(doc, baseVersion, { changes })),
|
||||
save: async (doc) => void (await call(commands.docSave(doc))),
|
||||
@@ -390,14 +384,6 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
|
||||
let nextId = 1;
|
||||
const store = new Map(Object.entries(files));
|
||||
const savedBoms = new Map<string, boolean>();
|
||||
const memUntitledCount = () => {
|
||||
let max = 0;
|
||||
for (const key of byPath.keys()) {
|
||||
const m = /^untitled:Untitled-(\d+)$/.exec(key);
|
||||
if (m) max = Math.max(max, Number(m[1]));
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
const info = (d: MemDoc): DocumentInfo => ({
|
||||
id: d.id,
|
||||
@@ -518,47 +504,6 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
|
||||
docs.delete(doc);
|
||||
byPath.delete(d.path);
|
||||
},
|
||||
async openUntitled(name) {
|
||||
const key = name ?? `untitled:Untitled-${memUntitledCount() + 1}`;
|
||||
const existing = byPath.get(key);
|
||||
if (existing !== undefined) {
|
||||
const d = get(existing);
|
||||
d.refs++;
|
||||
return info(d);
|
||||
}
|
||||
const d: MemDoc = {
|
||||
id: nextId++,
|
||||
path: key,
|
||||
text: "",
|
||||
saved: "",
|
||||
savedBom: false,
|
||||
version: 0,
|
||||
refs: 1,
|
||||
lineEnding: "lf",
|
||||
hasBom: false,
|
||||
readOnly: false,
|
||||
language: "plaintext",
|
||||
uri: key,
|
||||
};
|
||||
docs.set(d.id, d);
|
||||
byPath.set(key, d.id);
|
||||
return info(d);
|
||||
},
|
||||
async saveAs(doc, path) {
|
||||
const d = get(doc);
|
||||
const before = d.text !== d.saved || d.hasBom !== d.savedBom;
|
||||
d.saved = d.text;
|
||||
d.savedBom = d.hasBom;
|
||||
store.set(path, d.text);
|
||||
savedBoms.set(path, d.hasBom);
|
||||
byPath.delete(d.path);
|
||||
byPath.set(path, d.id);
|
||||
d.path = path;
|
||||
d.uri = undefined;
|
||||
d.language = EXT_LANG[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "plaintext";
|
||||
setDirty(d, before);
|
||||
return info(d);
|
||||
},
|
||||
edit(doc, baseVersion, changes) {
|
||||
return delay(() => {
|
||||
const d = get(doc);
|
||||
|
||||
@@ -593,10 +593,8 @@ describe("Angular templates", () => {
|
||||
moduleStyle: "es",
|
||||
typeScript: true,
|
||||
});
|
||||
// Generated files are checked in with LF; a Windows checkout may hold CRLF.
|
||||
const lf = (s: string) => s.replace(/\r\n/g, "\n");
|
||||
expect(lf(parser)).toBe(lf(readFileSync(resolve(dir, "parser.ts"), "utf8")));
|
||||
expect(lf(terms)).toBe(lf(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")));
|
||||
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
|
||||
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
|
||||
});
|
||||
|
||||
it("is the Angular Template mode", async () => {
|
||||
|
||||
@@ -153,10 +153,8 @@ describe("Astro components", () => {
|
||||
moduleStyle: "es",
|
||||
typeScript: true,
|
||||
});
|
||||
// Generated files are checked in with LF; a Windows checkout may hold CRLF.
|
||||
const lf = (s: string) => s.replace(/\r\n/g, "\n");
|
||||
expect(lf(parser)).toBe(lf(readFileSync(resolve(dir, "parser.ts"), "utf8")));
|
||||
expect(lf(terms)).toBe(lf(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")));
|
||||
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
|
||||
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
|
||||
});
|
||||
|
||||
it("is the Astro mode", async () => {
|
||||
|
||||
@@ -168,10 +168,8 @@ describe("Django template grammar", () => {
|
||||
moduleStyle: "es",
|
||||
typeScript: true,
|
||||
});
|
||||
// Generated files are checked in with LF; a Windows checkout may hold CRLF.
|
||||
const lf = (s: string) => s.replace(/\r\n/g, "\n");
|
||||
expect(lf(parser)).toBe(lf(readFileSync(resolve(dir, "parser.ts"), "utf8")));
|
||||
expect(lf(terms)).toBe(lf(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")));
|
||||
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
|
||||
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import { Tag, tags as t } from "@lezer/highlight";
|
||||
/** Log warnings: the UI warning colour, since no syntax token reads as one in every scheme. */
|
||||
export const logWarning = Tag.define("logWarning");
|
||||
export const parameter = Tag.define(t.variableName);
|
||||
/** A shell command name: its own colour in JetBrains schemes, else a function call. */
|
||||
export const shellCommand = Tag.define("shellCommand");
|
||||
/** Inside doc comments, as IntelliJ colours them: `@param` / `{@link`, a `@param` name, HTML markup. */
|
||||
export const docCommentTag = Tag.define(t.docComment);
|
||||
export const docCommentTagValue = Tag.define(t.docComment);
|
||||
@@ -62,7 +60,6 @@ export const openideaHighlightStyle = HighlightStyle.define([
|
||||
token([t.typeName, t.className, t.namespace, t.definition(t.typeName)], "type"),
|
||||
token([t.standard(t.typeName), t.standard(t.variableName), t.standard(t.function(t.variableName))], "builtin"),
|
||||
token([t.function(t.variableName), t.function(t.propertyName)], "function-call"),
|
||||
token(shellCommand, "shell-command", { color: "function-call" }),
|
||||
token(
|
||||
[
|
||||
t.function(t.definition(t.variableName)),
|
||||
|
||||
@@ -237,10 +237,8 @@ describe("JSP pages", () => {
|
||||
moduleStyle: "es",
|
||||
typeScript: true,
|
||||
});
|
||||
// Generated files are checked in with LF; a Windows checkout may hold CRLF.
|
||||
const lf = (s: string) => s.replace(/\r\n/g, "\n");
|
||||
expect(lf(parser)).toBe(lf(readFileSync(resolve(dir, "parser.ts"), "utf8")));
|
||||
expect(lf(terms)).toBe(lf(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")));
|
||||
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
|
||||
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
|
||||
});
|
||||
|
||||
it("is the JSP mode", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { highlightingFor, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
import { openideaHighlightStyle, parameter, shellCommand } from "./highlight";
|
||||
import { openideaHighlightStyle, parameter } from "./highlight";
|
||||
import { findLanguageDescription, loadSupport } from "./languages";
|
||||
import { setSyntaxHighlights, syntaxHighlightField } from "./syntaxHighlight";
|
||||
|
||||
@@ -46,14 +46,6 @@ describe("syntaxHighlightField", () => {
|
||||
expect(marks(state).map((m) => m.text)).toEqual(["def", "hi", ":ok"]);
|
||||
});
|
||||
|
||||
it("gives shell command names their own class, apart from function calls", () => {
|
||||
const state = create().update({ effects: setSyntaxHighlights.of({ kinds: ["command"], data: [0, 3, 0] }) }).state;
|
||||
const command = highlightingFor(state, [shellCommand]);
|
||||
expect(command).toBeTruthy();
|
||||
expect(command).not.toBe(highlightingFor(state, [t.function(t.variableName)]));
|
||||
expect(marks(state)).toEqual([{ text: "def", cls: command }]);
|
||||
});
|
||||
|
||||
it("lets Kotlin declaration roles override the stream lexer's generic definition style", async () => {
|
||||
const support = await loadSupport(findLanguageDescription("Kotlin", "demo.kt")!);
|
||||
const view = new EditorView({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Tree-sitter highlighting (`doc_highlights`) for languages without a
|
||||
* CodeMirror mode (Elixir, ...) or a limited stream mode (Kotlin, Shell): backend kinds → Lezer tags, so the colours
|
||||
* CodeMirror mode (Elixir, ...) or a limited stream mode (Kotlin): backend kinds → Lezer tags, so the colours
|
||||
* come from the same highlight style and Color Scheme settings as Lezer
|
||||
* highlighting. Refreshed with folding; in between, marks are mapped through
|
||||
* the changes.
|
||||
@@ -9,7 +9,7 @@ import { Prec, StateEffect, StateField, type EditorState } from "@codemirror/sta
|
||||
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
|
||||
import { highlightingFor } from "@codemirror/language";
|
||||
import { tags as t, type Tag } from "@lezer/highlight";
|
||||
import { parameter, shellCommand } from "./highlight";
|
||||
import { parameter } from "./highlight";
|
||||
import type { SyntaxHighlightKind, SyntaxHighlights } from "@/platform/ipc";
|
||||
|
||||
const TAGS: Record<SyntaxHighlightKind, Tag> = {
|
||||
@@ -38,7 +38,6 @@ const TAGS: Record<SyntaxHighlightKind, Tag> = {
|
||||
tag: t.tagName,
|
||||
operator: t.operator,
|
||||
punctuation: t.punctuation,
|
||||
command: shellCommand,
|
||||
};
|
||||
|
||||
export const setSyntaxHighlights = StateEffect.define<SyntaxHighlights>();
|
||||
|
||||
@@ -11,11 +11,10 @@ import { create } from "zustand";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { isSameOrInside } from "@/platform/paths";
|
||||
import { basename, dirname, useProject } from "@/platform/project";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import {
|
||||
_emitEditorEvent,
|
||||
_setEditorSwitching,
|
||||
_provideNavigationHistory,
|
||||
_provideOpenEditorPaths,
|
||||
_setActiveEditor,
|
||||
@@ -45,10 +44,8 @@ import {
|
||||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { restoreMaximizedToolWindow } from "@/workbench/toolwindows/store";
|
||||
import { ask, confirm } from "@/workbench/messages";
|
||||
import { t, tx } from "@/platform/i18n";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { save as saveFileDialog, type DialogFilter as SaveDialogFilter } from "@/platform/dialog";
|
||||
import { isUntitledUri, untitledTitle } from "@/platform/virtualDocs";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
import { fileIconName, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { canRevealInFileManager, fileManagerName } from "@/platform/fileManager";
|
||||
@@ -135,16 +132,9 @@ export const virtualInput = (
|
||||
data: { title: vd.title, description: vd.description, decompiled: vd.decompiled } satisfies VirtualInputData,
|
||||
});
|
||||
|
||||
/**
|
||||
* An editable new-file buffer (File › New File). Its `path` is the minted
|
||||
* `untitled:` URI, which identifies its document model; Save asks for a real
|
||||
* path and turns the tab into a file one.
|
||||
*/
|
||||
export const untitledInput = (uri: string): EditorInput => ({ kind: "untitled", key: uri, path: uri });
|
||||
|
||||
/** Inputs shown in the text editor: files, virtual documents and untitled buffers. */
|
||||
/** Inputs shown in the text editor: files and virtual documents. */
|
||||
export function isTextInput(input: EditorInput): input is EditorInput & { path: string } {
|
||||
return (input.kind === "file" || input.kind === "virtual" || input.kind === "untitled") && !!input.path;
|
||||
return (input.kind === "file" || input.kind === "virtual") && !!input.path;
|
||||
}
|
||||
|
||||
export const useTabs = create<TabsState>(() => initialTabsState());
|
||||
@@ -169,29 +159,6 @@ export const useEditorStatus = create<{ status: EditorStatus | null }>(() => ({
|
||||
export const useTabErrors = create<Record<string, string>>(() => ({}));
|
||||
/** Per-tab find bar state (open / replace mode), like IDEA's per-editor search session. */
|
||||
export const useFindBars = create<Record<string, { replace: boolean; seq: number } | undefined>>(() => ({}));
|
||||
/** Text file entry shown first in the save dialog's type dropdown. */
|
||||
const TXT_FILTER = { name: "Text files (*.txt)", extensions: ["txt"] };
|
||||
|
||||
/**
|
||||
* The save dialog's type dropdown: every registered language (its name and
|
||||
* extensions), plus plain text and an all-files escape hatch. Picking a type
|
||||
* makes the OS append that extension; the backend re-detects from the path.
|
||||
*/
|
||||
let cachedLanguageFilters: SaveDialogFilter[] | null = null;
|
||||
async function languageFilters(): Promise<SaveDialogFilter[]> {
|
||||
cachedLanguageFilters ??= await commands
|
||||
.languages()
|
||||
.catch(() => [] as { name: string; extensions: string[] }[])
|
||||
.then((languages) => [
|
||||
TXT_FILTER,
|
||||
...languages
|
||||
.map((l) => ({ name: `${l.name} (${l.extensions.map((e) => `*.${e}`).join(", ")})`, extensions: l.extensions }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
{ name: "All files (*.*)", extensions: ["*"] },
|
||||
]);
|
||||
return cachedLanguageFilters ?? [];
|
||||
}
|
||||
|
||||
export const useGotoLine = create<{ open: boolean }>(() => ({ open: false }));
|
||||
|
||||
/** Paths of open file tabs, all panes in visual order, unique. */
|
||||
@@ -209,22 +176,13 @@ export function openFilePaths(s: TabsState): string[] {
|
||||
|
||||
/** Editor Tabs › Sort tabs alphabetically: by file name. */
|
||||
function tabSortKey(input: EditorInput): string {
|
||||
return basename(untitledTitle(input.path ?? input.key));
|
||||
return basename(input.path ?? input.key);
|
||||
}
|
||||
|
||||
function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: string } } {
|
||||
return !!tab && isTextInput(tab.input);
|
||||
}
|
||||
|
||||
/** Keys of untitled buffers whose last tab the transition `before` → `after` closes. */
|
||||
function closedUntitledKeys(before: TabsState, after: TabsState): string[] {
|
||||
const open = openKeys(after);
|
||||
const out = new Set<string>();
|
||||
for (const pane of Object.values(before.panes))
|
||||
for (const tab of pane.tabs) if (tab.input.kind === "untitled" && !open.has(tab.input.key)) out.add(tab.input.key);
|
||||
return [...out];
|
||||
}
|
||||
|
||||
type EditorKinds = ReturnType<typeof useContributions.getState>["editorKinds"];
|
||||
|
||||
interface OpenRequest {
|
||||
@@ -238,7 +196,6 @@ interface OpenRequest {
|
||||
/** Tab title of an editor input: the file name, or what its editor kind calls it. */
|
||||
export function tabTitle(input: EditorInput, kinds: EditorKinds): string {
|
||||
if (input.kind === "file") return basename(input.path ?? input.key);
|
||||
if (input.kind === "untitled") return untitledTitle(input.path ?? input.key);
|
||||
if (input.kind === "virtual")
|
||||
return (input.data as VirtualInputData | undefined)?.title ?? basename(input.path ?? input.key);
|
||||
const kind = kinds.get(input.kind);
|
||||
@@ -251,7 +208,6 @@ export function tabTitle(input: EditorInput, kinds: EditorKinds): string {
|
||||
|
||||
export function tabIcon(input: EditorInput, kinds: EditorKinds): string {
|
||||
if (input.kind === "file") return fileIconName(basename(input.path ?? ""), input.path ?? null);
|
||||
if (input.kind === "untitled") return fileIconName(untitledTitle(input.path ?? ""));
|
||||
if (input.kind === "virtual") return fileIconName(tabTitle(input, kinds));
|
||||
const kind = kinds.get(input.kind);
|
||||
try {
|
||||
@@ -317,7 +273,6 @@ export class EditorController implements SlotHost {
|
||||
{
|
||||
settings: () => useProject.getState().settings ?? useProject.getState().project?.settings,
|
||||
notify: (n) => notifications().notify(n),
|
||||
saveAs: (model) => this.saveUntitled(model),
|
||||
},
|
||||
scheduler,
|
||||
);
|
||||
@@ -375,45 +330,14 @@ export class EditorController implements SlotHost {
|
||||
this.sessionRoot = null;
|
||||
const generation = ++this.projectGeneration;
|
||||
const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds);
|
||||
// Untitled buffers belong to the window, not a project: their tabs
|
||||
// survive the switch (the backend carries them into the new
|
||||
// registry) and their slots rebuild against the carried documents.
|
||||
const carriedUntitled: Tab[] = [];
|
||||
let carriedActive: string | null = null;
|
||||
const cs = useTabs.getState();
|
||||
for (const pane of Object.values(cs.panes)) {
|
||||
for (const tab of pane.tabs) {
|
||||
if (isUntitledUri(tab.input.path ?? "")) {
|
||||
carriedUntitled.push(tab);
|
||||
if (pane.active === tab.id) carriedActive = tab.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The guard decided already. Disposal must never prompt or autosave
|
||||
// custom documents after the backend has replaced their project.
|
||||
this.replacingProject = true;
|
||||
_setEditorSwitching(true);
|
||||
try {
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
if (carriedUntitled.length) {
|
||||
let st = useTabs.getState();
|
||||
const firstPane = paneIds(st)[0]!;
|
||||
for (const tab of carriedUntitled) {
|
||||
st = openTab(st, firstPane, tab.input, { activate: false }).state;
|
||||
}
|
||||
if (carriedActive) {
|
||||
const carriedInPane = st.panes[firstPane]!.tabs.find(
|
||||
(t) => t.input.key === carriedUntitled.find((c) => c.id === carriedActive)!.input.key,
|
||||
);
|
||||
if (carriedInPane) st = activateTab(st, firstPane, carriedInPane.id);
|
||||
}
|
||||
useTabs.setState(st, true);
|
||||
}
|
||||
} finally {
|
||||
this.replacingProject = false;
|
||||
_setEditorSwitching(false);
|
||||
}
|
||||
for (const tab of carriedUntitled) void this.ensureSlot(tab.id);
|
||||
this.closeSession = null;
|
||||
this.closeCommitted = false;
|
||||
this.preparedDocuments.clear();
|
||||
@@ -502,7 +426,6 @@ export class EditorController implements SlotHost {
|
||||
const live = new Set<string>();
|
||||
for (const p of Object.values(next.panes)) for (const t of p.tabs) live.add(t.id);
|
||||
for (const id of this.selectionRequests.keys()) if (!live.has(id)) this.selectionRequests.delete(id);
|
||||
|
||||
for (const [id, slot] of this.slots) {
|
||||
if (!live.has(id)) {
|
||||
this.slots.delete(id);
|
||||
@@ -601,8 +524,6 @@ export class EditorController implements SlotHost {
|
||||
for (const slot of this.slots.values()) {
|
||||
if (slot.disposed) continue;
|
||||
const path = slot.model.path;
|
||||
// Untitled buffers have no file to restore a caret for (and are not saved as tabs).
|
||||
if (isUntitledUri(path)) continue;
|
||||
// The last focused slot of a document wins over other split copies.
|
||||
if (files[path] && slot.model.lastFocused !== slot && !this.restoredViews.has(path)) continue;
|
||||
files[path] = slot.viewState();
|
||||
@@ -1207,86 +1128,6 @@ export class EditorController implements SlotHost {
|
||||
return this.admitInput(input, o, this.openingGeneration(o));
|
||||
}
|
||||
|
||||
/** File › New File: an editable untitled buffer; Save asks for a path. */
|
||||
async openUntitled(o: OpenOptions = {}): Promise<void> {
|
||||
const generation = this.openingGeneration(o);
|
||||
await this.projectTransition;
|
||||
this.checkOpening(generation);
|
||||
const info = await this.manager.backend.openUntitled(null);
|
||||
this.checkOpening(generation);
|
||||
this.manager.adopt(info);
|
||||
await this.admitInput(untitledInput(info.path), o, generation);
|
||||
}
|
||||
|
||||
/**
|
||||
* ManagerHost.saveAs: an untitled buffer's explicit save asks for a path
|
||||
* (Save As). The backend re-keys the buffer; the model is re-pointed and
|
||||
* every tab showing it becomes a file tab.
|
||||
*/
|
||||
private async saveUntitled(model: import("./model/document").DocModel): Promise<boolean> {
|
||||
const path = await saveFileDialog({
|
||||
title: tx("Save As"),
|
||||
defaultPath: this.saveAsSuggestion(model),
|
||||
filters: await languageFilters(),
|
||||
});
|
||||
if (!path) return false;
|
||||
const untitled = model.path;
|
||||
try {
|
||||
const info = await this.manager.backend.saveAs(model.doc, path);
|
||||
this.manager.retarget(model, info);
|
||||
this.swapUntitledTabs(untitled, info.path);
|
||||
return true;
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot save file",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the save dialog opens for a new buffer: the project root, or, in a
|
||||
* LightEdit window, the folder of its most recently open real file. The file
|
||||
* type comes from the dialog's own type dropdown (see `languageFilters`).
|
||||
*/
|
||||
private saveAsSuggestion(model: import("./model/document").DocModel): string {
|
||||
const root = useProject.getState().project?.root;
|
||||
const dir =
|
||||
root ??
|
||||
(() => {
|
||||
const s = useTabs.getState();
|
||||
for (const pane of Object.values(s.panes)) {
|
||||
for (const id of pane.mru) {
|
||||
const tab = pane.tabs.find((t) => t.id === id);
|
||||
if (tab?.input.kind === "file" && tab.input.path) return dirname(tab.input.path);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const name = untitledTitle(model.path);
|
||||
return dir ? `${dir}/${name}` : name;
|
||||
}
|
||||
|
||||
/** Save As turned an untitled buffer into `path`: retarget every tab showing it. */
|
||||
private swapUntitledTabs(oldKey: string, path: string) {
|
||||
const s = useTabs.getState();
|
||||
let changed = false;
|
||||
const panes: TabsState["panes"] = {};
|
||||
for (const [paneId, pane] of Object.entries(s.panes)) {
|
||||
let paneChanged = false;
|
||||
const tabs = pane.tabs.map((tab) => {
|
||||
if (tab.input.key !== oldKey) return tab;
|
||||
paneChanged = true;
|
||||
return { ...tab, input: fileInput(path) };
|
||||
});
|
||||
panes[paneId] = paneChanged ? { ...pane, tabs } : pane;
|
||||
changed ||= paneChanged;
|
||||
}
|
||||
if (changed) useTabs.setState({ ...s, panes }, true);
|
||||
}
|
||||
|
||||
private async admitInput(
|
||||
input: EditorInput,
|
||||
o: OpenOptions,
|
||||
@@ -1609,36 +1450,6 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
authorized.add(d.input.key);
|
||||
}
|
||||
// Dirty untitled buffers: autosave never writes them in place, so
|
||||
// closing their last tab asks (Save opens the Save As dialog).
|
||||
const untitledAuthorized = new Set<string>();
|
||||
for (;;) {
|
||||
const current = useTabs.getState();
|
||||
const key = closedUntitledKeys(current, currentRemoval(current)).find((k) => !untitledAuthorized.has(k));
|
||||
if (!key) break;
|
||||
const model = this.manager.get(key);
|
||||
if (!model || model.closed || !model.getMeta().dirty) {
|
||||
untitledAuthorized.add(key);
|
||||
continue;
|
||||
}
|
||||
const decision = await ask({
|
||||
title: t("app.unsaved.title"),
|
||||
body: t("app.unsaved.fileBody", { name: untitledTitle(key) }),
|
||||
buttons: [
|
||||
{ id: "discard", label: t("app.unsaved.discard") },
|
||||
{ id: "save", label: t("app.unsaved.saveFile"), primary: true },
|
||||
],
|
||||
icon: "warning",
|
||||
});
|
||||
if (decision.button === "save") {
|
||||
await model.save("explicit");
|
||||
// Still dirty: the Save As dialog was cancelled or the save failed.
|
||||
if (model.getMeta().dirty) return false;
|
||||
} else if (decision.button !== "discard") {
|
||||
return false;
|
||||
}
|
||||
untitledAuthorized.add(key);
|
||||
}
|
||||
// Again after the discards: a tab may have become busy while they ran.
|
||||
if (!(await this.confirmBusyClose(currentRemoval, confirmedBusy))) return false;
|
||||
if (generation !== this.projectGeneration) return false;
|
||||
@@ -2029,11 +1840,8 @@ export class EditorController implements SlotHost {
|
||||
if (errors.length) throw combinedError(errors);
|
||||
}
|
||||
|
||||
async saveAll(opts?: { skipUntitled?: boolean }): Promise<void> {
|
||||
const results = await Promise.allSettled([
|
||||
this.manager.saveAll("explicit", opts?.skipUntitled),
|
||||
this.saveCustomDocuments(),
|
||||
]);
|
||||
async saveAll(): Promise<void> {
|
||||
const results = await Promise.allSettled([this.manager.saveAll("explicit"), this.saveCustomDocuments()]);
|
||||
const errors = failures(results);
|
||||
if (errors.length) throw combinedError(errors);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
registerStatusItem,
|
||||
} from "@/workbench/contributions";
|
||||
import { _provideCodePreview, _provideDocumentAttacher, editorsFor } from "@/editor/api";
|
||||
import { isUntitledUri } from "@/platform/virtualDocs";
|
||||
import { formatDocument } from "@/editor/lsp/format";
|
||||
import { editorController } from "./controller";
|
||||
import { attachDocument } from "./model/attachment";
|
||||
@@ -43,13 +42,10 @@ export function activate(): void | (() => void) {
|
||||
provideEditorService({
|
||||
openFile: (path, options) => ctrl.openFile(path, options),
|
||||
openInput: (input, options) => ctrl.openInput(input, options),
|
||||
openUntitled: (options) => ctrl.openUntitled(options),
|
||||
activePath: () => ctrl.activePath(),
|
||||
activeCursor: () => ctrl.activeCursor(),
|
||||
saveAll: (opts) => ctrl.saveAll(opts),
|
||||
saveAll: () => ctrl.saveAll(),
|
||||
dirtyDocumentIds: () => [...ctrl.manager.byId.values()].filter((m) => m.getMeta().dirty).map((m) => m.doc),
|
||||
untitledDirtyDocumentIds: () =>
|
||||
[...ctrl.manager.byId.values()].filter((m) => isUntitledUri(m.path) && m.getMeta().dirty).map((m) => m.doc),
|
||||
focus: () => ctrl.focus(),
|
||||
openPaths: () => ctrl.openPaths(),
|
||||
openTabs: () => ctrl.openTabs(),
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
useEditorRegistry,
|
||||
type EditorDocContext,
|
||||
} from "@/editor/api";
|
||||
import { isUntitledUri, isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { DocSync, type Scheduler, frameScheduler } from "../sync/DocSync";
|
||||
import type { DocBackend, LanguageInfo } from "../backend";
|
||||
import {
|
||||
@@ -98,11 +98,6 @@ export const useDocMeta = create<Record<string, DocMeta>>(() => ({}));
|
||||
export const useOutline = create<Record<string, OutlineItem[]>>(() => ({}));
|
||||
|
||||
const REFRESH_DELAY = 450;
|
||||
/**
|
||||
* Languages whose stream mode works from word lists, so the grammar's highlights are drawn
|
||||
* over it: Kotlin declaration roles, Shell commands, function names and `local`.
|
||||
*/
|
||||
const GRAMMAR_SUPPLEMENTED = new Set(["kotlin", "shellscript"]);
|
||||
function bannerFor(info: DocumentInfo): Banner {
|
||||
// Virtual documents (library classes) get their own notice from the editor area.
|
||||
if (info.uri) return null;
|
||||
@@ -209,7 +204,7 @@ export function saveCleanupChanges(
|
||||
export class DocModel {
|
||||
readonly doc: DocId;
|
||||
/** File path, or the URI of a virtual (read-only, language-server provided) document. */
|
||||
path: string;
|
||||
readonly path: string;
|
||||
readonly virtual: boolean;
|
||||
readonly sync: DocSync;
|
||||
readonly slots = new Set<EditorSlot>();
|
||||
@@ -239,7 +234,7 @@ export class DocModel {
|
||||
) {
|
||||
this.doc = info.id;
|
||||
this.path = info.path;
|
||||
this.virtual = !!info.uri && !isUntitledUri(info.path);
|
||||
this.virtual = !!info.uri;
|
||||
this.info = languageInfo(info.language);
|
||||
this.editorconfig = info.editorconfig ?? null;
|
||||
// `large`: the backend skips LSP and syntax trees for it; heavy UI features follow.
|
||||
@@ -579,7 +574,8 @@ export class DocModel {
|
||||
const [folds, outline, highlights] = await Promise.allSettled([
|
||||
backend.folding(this.doc),
|
||||
backend.outline(this.doc),
|
||||
!this.desc || GRAMMAR_SUPPLEMENTED.has(this.meta.language) ? backend.highlights(this.doc) : null,
|
||||
// Kotlin's stream mode lacks declaration categories; the grammar supplements it.
|
||||
!this.desc || this.meta.language === "kotlin" ? backend.highlights(this.doc) : null,
|
||||
]);
|
||||
if (this.closed) return;
|
||||
if (counter !== this.changeCounter) {
|
||||
@@ -672,14 +668,6 @@ export class DocModel {
|
||||
}
|
||||
await this.sync.whenIdle();
|
||||
if (this.closed) return;
|
||||
if (isUntitledUri(this.path)) {
|
||||
// An untitled buffer has no path to write to: autosave is a no-op and
|
||||
// an explicit save asks for one (Save As re-points the buffer, or stays
|
||||
// dirty on Cancel).
|
||||
if (reason === "auto") return;
|
||||
if (await this.manager.saveAs(this)) this.modified.clear();
|
||||
return;
|
||||
}
|
||||
await this.manager.backend.save(this.doc);
|
||||
this.modified.clear();
|
||||
this.setMeta({ dirty: false });
|
||||
@@ -689,31 +677,6 @@ export class DocModel {
|
||||
return this.saving;
|
||||
}
|
||||
|
||||
/** Save As turned this untitled buffer into the file `info` points at. */
|
||||
retarget(info: DocumentInfo) {
|
||||
const old = this.path;
|
||||
this.path = info.path;
|
||||
this.info = languageInfo(info.language);
|
||||
this.editorconfig = info.editorconfig ?? null;
|
||||
this.meta = {
|
||||
...this.meta,
|
||||
path: info.path,
|
||||
language: info.language,
|
||||
encoding: info.encoding,
|
||||
hasBom: info.hasBom,
|
||||
lineEnding: info.lineEnding,
|
||||
canReloadEncoding: info.canReloadEncoding,
|
||||
dirty: false,
|
||||
};
|
||||
useDocMeta.setState((s) => {
|
||||
const next = { ...s };
|
||||
delete next[old];
|
||||
next[info.path] = this.meta;
|
||||
return next;
|
||||
}, true);
|
||||
_emitEditorEvent({ type: "docSaved", doc: this.doc, path: this.path });
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.closed = true;
|
||||
clearTimeout(this.refreshTimer);
|
||||
@@ -724,11 +687,6 @@ export class DocModel {
|
||||
export interface ManagerHost {
|
||||
settings(): Settings | null | undefined;
|
||||
notify(n: { level: "info" | "warning" | "error"; title: string; body?: string }): void;
|
||||
/**
|
||||
* An untitled buffer's explicit save: asks for a path, writes the buffer
|
||||
* there and re-points the model. `false` = cancelled or failed.
|
||||
*/
|
||||
saveAs?(model: DocModel): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class DocumentManager {
|
||||
@@ -791,29 +749,6 @@ export class DocumentManager {
|
||||
return this.byPath.get(path);
|
||||
}
|
||||
|
||||
/** Registers a backend document opened outside `acquire` (a minted untitled buffer). */
|
||||
adopt(info: DocumentInfo): DocModel {
|
||||
const model = new DocModel(this, info);
|
||||
void model.loadLanguage();
|
||||
this.byPath.set(model.path, model);
|
||||
this.byId.set(model.doc, model);
|
||||
_emitEditorEvent({ type: "docOpened", doc: model.doc, path: model.path, language: info.language });
|
||||
return model;
|
||||
}
|
||||
|
||||
/** An untitled buffer's explicit save (Save As); `false` = cancelled or failed. */
|
||||
saveAs(model: DocModel): Promise<boolean> {
|
||||
return this.host.saveAs?.(model) ?? Promise.resolve(false);
|
||||
}
|
||||
|
||||
/** Save As re-pointed `model` from its untitled URI to a real file path. */
|
||||
retarget(model: DocModel, info: DocumentInfo) {
|
||||
if (model.path === info.path) return;
|
||||
this.byPath.delete(model.path);
|
||||
this.byPath.set(info.path, model);
|
||||
model.retarget(info);
|
||||
}
|
||||
|
||||
/** Opens (or returns the already open) document model for `path`. */
|
||||
acquire(path: string): Promise<DocModel> {
|
||||
const existing = this.byPath.get(path);
|
||||
@@ -829,11 +764,7 @@ export class DocumentManager {
|
||||
})
|
||||
.catch(() => {});
|
||||
const [info] = await Promise.all([
|
||||
isUntitledUri(path)
|
||||
? this.backend.openUntitled(path)
|
||||
: isVirtualUri(path)
|
||||
? this.backend.openVirtual(path)
|
||||
: this.backend.open(path),
|
||||
isVirtualUri(path) ? this.backend.openVirtual(path) : this.backend.open(path),
|
||||
catalog,
|
||||
]);
|
||||
const raced = this.byPath.get(path);
|
||||
@@ -898,16 +829,14 @@ export class DocumentManager {
|
||||
}
|
||||
|
||||
/** Saves dirty documents; failures are notified, and an explicit Save All also rejects with them. */
|
||||
async saveAll(reason: "explicit" | "auto", skipUntitled = false) {
|
||||
async saveAll(reason: "explicit" | "auto") {
|
||||
const errors: unknown[] = [];
|
||||
await Promise.all(
|
||||
[...this.byPath.values()]
|
||||
.filter((m) => !(skipUntitled && isUntitledUri(m.path)))
|
||||
.map((m) =>
|
||||
m.save(reason).catch((e) => {
|
||||
errors.push(e);
|
||||
}),
|
||||
),
|
||||
[...this.byPath.values()].map((m) =>
|
||||
m.save(reason).catch((e) => {
|
||||
errors.push(e);
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (errors.length) {
|
||||
const e = errors[0];
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/platform/dialog", () => ({ save: vi.fn(async () => null) }));
|
||||
const ask = vi.fn(async () => ({ button: "cancel" }));
|
||||
vi.mock("@/workbench/messages", () => ({
|
||||
ask: (...args: unknown[]) => ask(...(args as [])),
|
||||
confirm: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
const { EditorController, isTextInput, tabTitle, untitledInput, useTabs } = await import("./controller");
|
||||
const { createMemoryBackend } = await import("./backend");
|
||||
type MemoryBackend = Awaited<ReturnType<typeof createMemoryBackend>>;
|
||||
const { initialTabsState } = await import("./model/tabs");
|
||||
const { serializeSession } = await import("./model/session");
|
||||
const { useDocMeta } = await import("./model/document");
|
||||
const { save } = await import("@/platform/dialog");
|
||||
const editorKinds = await import("@/workbench/contributions");
|
||||
|
||||
let ctrl: InstanceType<typeof EditorController> | null = null;
|
||||
let backend: MemoryBackend;
|
||||
|
||||
beforeEach(() => {
|
||||
backend = createMemoryBackend({ "/p/main.rs": "fn main() {}\n" });
|
||||
ctrl = new EditorController(backend);
|
||||
ctrl.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctrl?.stop();
|
||||
ctrl = null;
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const activeTab = () => {
|
||||
const s = useTabs.getState();
|
||||
const pane = s.panes[s.activePane]!;
|
||||
return pane.tabs.find((t) => t.id === pane.active)!;
|
||||
};
|
||||
|
||||
const editActive = async (insert: string) => {
|
||||
const slot = ctrl!.slotFor(activeTab().id)!;
|
||||
slot.dispatchLocal({ changes: { from: 0, insert } });
|
||||
const model = slot.model;
|
||||
// The dirty flag arrives with the backend's acknowledgement.
|
||||
await vi.waitFor(() => expect(model.getMeta().dirty).toBe(true));
|
||||
return model;
|
||||
};
|
||||
|
||||
describe("untitled editor input", () => {
|
||||
it("opens editable, pathless buffers with incrementing names", async () => {
|
||||
await ctrl!.openUntitled({ focus: false });
|
||||
const tab = activeTab();
|
||||
expect(tab.input).toEqual({ kind: "untitled", key: "untitled:Untitled-1", path: "untitled:Untitled-1" });
|
||||
expect(isTextInput(tab.input)).toBe(true);
|
||||
expect(tabTitle(tab.input, editorKinds.useContributions.getState().editorKinds)).toBe("Untitled-1");
|
||||
const slot = ctrl!.slotFor(tab.id)!;
|
||||
expect(slot.model.virtual).toBe(false);
|
||||
expect(slot.model.ctx()).toMatchObject({ path: "untitled:Untitled-1", language: "plaintext" });
|
||||
expect(useDocMeta.getState()["untitled:Untitled-1"]).toMatchObject({ dirty: false, readOnly: false });
|
||||
|
||||
await editActive("draft");
|
||||
expect(slot.model.getMeta().dirty).toBe(true);
|
||||
|
||||
await ctrl!.openUntitled({ focus: false });
|
||||
expect(activeTab().input.path).toBe("untitled:Untitled-2");
|
||||
expect(untitledInput("untitled:Untitled-9").key).toBe("untitled:Untitled-9");
|
||||
});
|
||||
|
||||
it("autosave never writes an untitled buffer; explicit save asks for a path and retargets tab and model", async () => {
|
||||
await ctrl!.openUntitled({ focus: false });
|
||||
const model = await editActive("draft");
|
||||
vi.mocked(save).mockResolvedValue("/p/new/dir/hello.rs");
|
||||
|
||||
await model.save("auto");
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(model.getMeta().dirty).toBe(true);
|
||||
|
||||
await model.save("explicit");
|
||||
// No content guessing: the bare name plus the type dropdown (filters).
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ defaultPath: "Untitled-1", filters: expect.any(Array) }),
|
||||
);
|
||||
expect(activeTab().input).toMatchObject({ kind: "file", path: "/p/new/dir/hello.rs" });
|
||||
expect(model.path).toBe("/p/new/dir/hello.rs");
|
||||
expect(model.getMeta().dirty).toBe(false);
|
||||
expect(useDocMeta.getState()["/p/new/dir/hello.rs"]).toBeDefined();
|
||||
expect(useDocMeta.getState()["untitled:Untitled-1"]).toBeUndefined();
|
||||
// Save cleanup adds the final newline.
|
||||
expect(backend.files.get("/p/new/dir/hello.rs")).toBe("draft\n");
|
||||
// Saved as .rs: the buffer is a regular file document again.
|
||||
expect(model.virtual).toBe(false);
|
||||
expect((await backend.snapshot(model.doc)).uri).toBeUndefined();
|
||||
|
||||
// The session keeps the saved file but never an unsaved untitled buffer.
|
||||
await ctrl!.openUntitled({ focus: false });
|
||||
await editActive("second");
|
||||
const session = serializeSession(useTabs.getState(), {});
|
||||
expect(JSON.stringify(session)).toContain("/p/new/dir/hello.rs");
|
||||
expect(JSON.stringify(session)).not.toContain("untitled:");
|
||||
});
|
||||
|
||||
it("offers the registered languages as save-as types", async () => {
|
||||
await ctrl!.openUntitled({ focus: false });
|
||||
const slot = ctrl!.slotFor(activeTab().id)!;
|
||||
slot.dispatchLocal({ changes: { from: 0, insert: "{}" } });
|
||||
vi.mocked(save).mockResolvedValue("/p/anywhere/x.md");
|
||||
await slot.model.save("explicit");
|
||||
const call = vi.mocked(save).mock.calls.at(-1)![0]!;
|
||||
const types = (call.filters ?? []).map((f: { name: string }) => f.name);
|
||||
// Plain text first (the default), registered languages in between, all-files last.
|
||||
expect(types[0]).toBe("Text files (*.txt)");
|
||||
expect(types.at(-1)).toBe("All files (*.*)");
|
||||
// The name carries no extension: the picked type decides it.
|
||||
expect(call.defaultPath).toBe("Untitled-1");
|
||||
// The name carries no extension: the picked type decides it.
|
||||
expect(call.defaultPath).toBe("Untitled-1");
|
||||
});
|
||||
|
||||
it("keeps the buffer open and dirty when the save dialog is cancelled", async () => {
|
||||
await ctrl!.openUntitled({ focus: false });
|
||||
const model = await editActive("draft");
|
||||
vi.mocked(save).mockResolvedValue(null);
|
||||
|
||||
await model.save("explicit");
|
||||
expect(activeTab().input.kind).toBe("untitled");
|
||||
expect(model.getMeta().dirty).toBe(true);
|
||||
expect([...backend.files.keys()]).toEqual(["/p/main.rs"]);
|
||||
});
|
||||
|
||||
it("asks about a dirty untitled buffer when its last tab closes", async () => {
|
||||
await ctrl!.openUntitled({ focus: false });
|
||||
const tab = activeTab();
|
||||
await editActive("draft");
|
||||
const paneId = useTabs.getState().activePane;
|
||||
|
||||
// Cancel keeps the tab.
|
||||
ask.mockResolvedValue({ button: "cancel" });
|
||||
expect(await ctrl!.closeTab(paneId, tab.id)).toBe(false);
|
||||
expect(activeTab().id).toBe(tab.id);
|
||||
|
||||
// Save closes it through the Save As dialog.
|
||||
ask.mockResolvedValue({ button: "save" });
|
||||
vi.mocked(save).mockResolvedValue("/p/kept.txt");
|
||||
expect(await ctrl!.closeTab(paneId, tab.id)).toBe(true);
|
||||
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(0);
|
||||
expect(backend.files.get("/p/kept.txt")).toBe("draft\n");
|
||||
});
|
||||
|
||||
it("closes an untitled buffer without saving when the user declines", async () => {
|
||||
await ctrl!.openUntitled({ focus: false });
|
||||
const tab = activeTab();
|
||||
await editActive("draft");
|
||||
const paneId = useTabs.getState().activePane;
|
||||
ask.mockResolvedValue({ button: "discard" });
|
||||
expect(await ctrl!.closeTab(paneId, tab.id)).toBe(true);
|
||||
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(0);
|
||||
expect([...backend.files.keys()]).toEqual(["/p/main.rs"]);
|
||||
});
|
||||
|
||||
it("reuses the buffer for a re-opened untitled name and shares it across tabs", async () => {
|
||||
const info = await backend.openUntitled(null);
|
||||
const twice = await backend.openUntitled("untitled:Untitled-1");
|
||||
expect(twice.id).toBe(info.id);
|
||||
await ctrl!.openInput(untitledInput(info.path), { focus: false });
|
||||
await ctrl!.openInput(untitledInput(info.path), { focus: false, side: true });
|
||||
const tabs = Object.values(useTabs.getState().panes).flatMap((p) => p.tabs);
|
||||
expect(tabs).toHaveLength(2);
|
||||
expect(ctrl!.slotFor(tabs[0]!.id)!.model).toBe(ctrl!.slotFor(tabs[1]!.id)!.model);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { BarButton, Icon, Menu, badgeKind, fileIconName } from "@/ui";
|
||||
import { notifications, useProjectViewLead } from "@/workbench/contributions";
|
||||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { ENCODING_CHOICES, useEditorPrefs } from "@/platform/editorSettings";
|
||||
import { isUntitledUri, isVirtualUri, invalidateVirtualDocument, untitledTitle } from "@/platform/virtualDocs";
|
||||
import { isVirtualUri, invalidateVirtualDocument } from "@/platform/virtualDocs";
|
||||
import { editorController, isTextInput, useEditorStatus, useGotoLine, useTabs } from "../controller";
|
||||
import { useDocMeta, useOutline } from "../model/document";
|
||||
import { symbolChain } from "./outline";
|
||||
@@ -76,11 +76,6 @@ function projectCrumb(project: { root: string; name: string }): Crumb {
|
||||
|
||||
/** The project › dirs › file part of the navigation path (IDEA navigation bar). */
|
||||
function pathCrumbs(project: { root: string; name: string } | null, path: string, dir = false): Crumb[] {
|
||||
// A new-file buffer has no folder yet: only its name.
|
||||
if (isUntitledUri(path)) {
|
||||
const label = untitledTitle(path);
|
||||
return [{ label, icon: fileIconName(label), title: label }];
|
||||
}
|
||||
const target = dir ? path : dirname(path);
|
||||
const segments: Crumb[] = [];
|
||||
if (project && target !== project.root && !target.startsWith(project.root + "/")) project = null;
|
||||
|
||||
@@ -262,14 +262,3 @@ it("shows the project › dirs › file path above the editor", async () => {
|
||||
hidden.unmount();
|
||||
useProject.setState({ project: null, settings: null });
|
||||
});
|
||||
|
||||
it("shows only the name of a new-file buffer", () => {
|
||||
useProject.setState({
|
||||
project: { root: "/workspace", name: "Workspace", settings: {}, trusted: true, isGit: false },
|
||||
settings: { appearance: { navigationBar: "top" } } as unknown as Settings,
|
||||
});
|
||||
useEditorStatus.setState({ status: { ...caret(0, 0), path: "untitled:Untitled-1" } });
|
||||
const view = render(<Breadcrumbs className="nav" />);
|
||||
expect(screen.getByRole("navigation", { name: "Breadcrumbs" }).textContent).toBe("Untitled-1");
|
||||
view.unmount();
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* The drag handle on the conversation side of a task's Changes pane. The width applies to
|
||||
* every task and is remembered across sessions.
|
||||
*/
|
||||
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
|
||||
import { lockDragCursor } from "@/ui";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { clampChangesWidth, setChangesOptions, useChangesOptions } from "./changesOptions";
|
||||
import styles from "./Session.module.css";
|
||||
|
||||
/** CSS custom property read by `.withChanges` in Session.module.css. */
|
||||
const WIDTH_VAR = "--agent-changes-width";
|
||||
/** Keep in step with the conversation's minimum in `.withChanges`. */
|
||||
const MIN_CONVERSATION = 240;
|
||||
const KEY_STEP = 16;
|
||||
|
||||
/** Style for the session that holds the pane. */
|
||||
export function useChangesWidthStyle(): CSSProperties | undefined {
|
||||
const width = useChangesOptions((s) => s.width);
|
||||
return width == null ? undefined : ({ [WIDTH_VAR]: `${width}px` } as CSSProperties);
|
||||
}
|
||||
|
||||
/** Pane width for `px`, leaving the conversation its minimum in `session`. */
|
||||
function fit(px: number, session: HTMLElement) {
|
||||
return Math.min(clampChangesWidth(px), Math.max(0, session.clientWidth - MIN_CONVERSATION));
|
||||
}
|
||||
|
||||
/** Render as the first child of the pane. */
|
||||
export function ChangesResizer() {
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
const handle = e.currentTarget;
|
||||
const pane = handle.parentElement;
|
||||
const session = pane?.parentElement;
|
||||
if (e.button !== 0 || !pane || !session) return;
|
||||
// No focus ring or text selection from a mouse drag.
|
||||
e.preventDefault();
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
const start = pane.getBoundingClientRect().width;
|
||||
const origin = e.clientX;
|
||||
const previous = session.style.getPropertyValue(WIDTH_VAR);
|
||||
const releaseCursor = lockDragCursor("ew-resize");
|
||||
let next: number | null = null;
|
||||
// Only this session follows the pointer; every task takes the new width on release.
|
||||
const move = (ev: globalThis.PointerEvent) => {
|
||||
if (!(ev.buttons & 1)) return end();
|
||||
next = fit(start + origin - ev.clientX, session);
|
||||
session.style.setProperty(WIDTH_VAR, `${next}px`);
|
||||
};
|
||||
const end = () => {
|
||||
handle.removeEventListener("pointermove", move);
|
||||
handle.removeEventListener("pointerup", end);
|
||||
handle.removeEventListener("pointercancel", end);
|
||||
releaseCursor();
|
||||
if (next != null) setChangesOptions({ width: clampChangesWidth(next) });
|
||||
else if (previous) session.style.setProperty(WIDTH_VAR, previous);
|
||||
else session.style.removeProperty(WIDTH_VAR);
|
||||
};
|
||||
handle.addEventListener("pointermove", move);
|
||||
handle.addEventListener("pointerup", end);
|
||||
handle.addEventListener("pointercancel", end);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const dir = e.key === "ArrowLeft" ? 1 : e.key === "ArrowRight" ? -1 : 0;
|
||||
const pane = e.currentTarget.parentElement;
|
||||
const session = pane?.parentElement;
|
||||
if (!dir || !pane || !session) return;
|
||||
e.preventDefault();
|
||||
setChangesOptions({
|
||||
width: clampChangesWidth(fit(pane.getBoundingClientRect().width + dir * KEY_STEP, session)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={tx("Resize changes")}
|
||||
tabIndex={0}
|
||||
className={styles.changesResizer}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -36,15 +36,13 @@
|
||||
outline: none;
|
||||
}
|
||||
.field :global(.cm-scroller) {
|
||||
min-height: 44px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* The minimum height goes on the text itself, not the scroller: clicks below the last line
|
||||
must land in the editable text, or they focus the bare scroller and no caret appears. */
|
||||
.field :global(.cm-content) {
|
||||
min-height: 44px;
|
||||
padding: 10px 12px 2px;
|
||||
caret-color: var(--fg-strong);
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -30,10 +30,8 @@
|
||||
.docked .conversation {
|
||||
border-right: 0;
|
||||
}
|
||||
/* The pane takes its width while the conversation keeps 240px (half of a narrower session);
|
||||
keep that minimum in step with ChangesResizer. */
|
||||
.withChanges {
|
||||
grid-template-columns: minmax(min(240px, 50%), 1fr) minmax(0, var(--agent-changes-width, 280px));
|
||||
.docked .changes {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.providerIcon {
|
||||
@@ -1135,30 +1133,11 @@
|
||||
}
|
||||
|
||||
.changes {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
/* On the conversation's border; the hit area reaches 3px either side of it. */
|
||||
.changesResizer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -1px;
|
||||
width: 0;
|
||||
outline: none;
|
||||
}
|
||||
.changesResizer::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 -3px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.changesResizer:focus-visible::after {
|
||||
background: color-mix(in srgb, var(--focus-ring) 55%, transparent);
|
||||
width: 280px;
|
||||
}
|
||||
.changesHeader {
|
||||
min-width: 0;
|
||||
@@ -1178,10 +1157,6 @@
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.changesHeader > .muted {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.changesSummary {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -62,7 +62,6 @@ import { isImeKey } from "@/platform/ime";
|
||||
import { commitFileDiffData, openDiff, withChain, type DiffData } from "@/features/vcs/diffInput";
|
||||
import { repositoryPath } from "@/features/vcs/repositoryPaths";
|
||||
import { setChangesOptions, useChangesOptions } from "./changesOptions";
|
||||
import { ChangesResizer, useChangesWidthStyle } from "./ChangesResizer";
|
||||
import { Composer } from "./Composer";
|
||||
import { ReferencedText } from "./ReferenceChip";
|
||||
import { selectionIn } from "./selection";
|
||||
@@ -157,7 +156,6 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
project?.workspace ? (task?.projectRoot ?? project.root) : null,
|
||||
);
|
||||
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null, docked);
|
||||
const changesWidth = useChangesWidthStyle();
|
||||
// The subagent conversation shown, with the subagents that lead to it (empty: the task's own).
|
||||
const [path, setPath] = useState<string[]>([]);
|
||||
const view = path.at(-1) ?? null;
|
||||
@@ -231,8 +229,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
return (
|
||||
<div
|
||||
ref={sessionRoot}
|
||||
className={clsx(styles.session, docked && styles.docked, changesOpen && !docked && styles.withChanges)}
|
||||
style={changesWidth}
|
||||
className={clsx(styles.session, docked && styles.docked)}
|
||||
onFocusCapture={() => shown && setFocusedSession(session)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedSession() === session) {
|
||||
@@ -3154,7 +3151,6 @@ function ChangesPane({
|
||||
};
|
||||
return (
|
||||
<aside className={styles.changes} aria-label={tx("Changes")}>
|
||||
{!docked && <ChangesResizer />}
|
||||
<div className={styles.changesHeader}>
|
||||
{docked && <IconButton icon="arrow-left" title="Back to conversation" onClick={onHide} />}
|
||||
<span className={styles.changesTitle}>
|
||||
|
||||
@@ -4,19 +4,10 @@ import { create } from "zustand";
|
||||
export interface ChangesOptions {
|
||||
/** Files grouped by the commit that changed them; otherwise the flat list since the task started. */
|
||||
groupByCommit: boolean;
|
||||
/** Width of the pane beside the conversation, in px; `null`: the stylesheet's default. */
|
||||
width: number | null;
|
||||
}
|
||||
|
||||
/** IDEA-like default: the review view. */
|
||||
export const DEFAULT_CHANGES_OPTIONS: ChangesOptions = { groupByCommit: true, width: null };
|
||||
|
||||
const MIN_CHANGES_WIDTH = 200;
|
||||
const MAX_CHANGES_WIDTH = 1600;
|
||||
|
||||
export function clampChangesWidth(px: number) {
|
||||
return Math.round(Math.min(MAX_CHANGES_WIDTH, Math.max(MIN_CHANGES_WIDTH, px)));
|
||||
}
|
||||
export const DEFAULT_CHANGES_OPTIONS: ChangesOptions = { groupByCommit: true };
|
||||
|
||||
const KEY = "openidea.agents.changesView.v1";
|
||||
|
||||
@@ -26,7 +17,6 @@ function load(): ChangesOptions {
|
||||
const o = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
return {
|
||||
groupByCommit: typeof o.groupByCommit === "boolean" ? o.groupByCommit : DEFAULT_CHANGES_OPTIONS.groupByCommit,
|
||||
width: typeof o.width === "number" && Number.isFinite(o.width) ? clampChangesWidth(o.width) : null,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_CHANGES_OPTIONS };
|
||||
|
||||
@@ -12,17 +12,12 @@ export function LightEditStatus() {
|
||||
const t = useT();
|
||||
const lightEdit = useProject((s) => s.lightEdit);
|
||||
const path = useEditorRegistry((s) => s.active?.ctx.path);
|
||||
if (!lightEdit) return null;
|
||||
const hasRealFile = !!path && !isVirtualUri(path);
|
||||
if (!lightEdit || !path || isVirtualUri(path)) return null;
|
||||
return (
|
||||
<BarButton
|
||||
emphasis
|
||||
tooltip={
|
||||
hasRealFile
|
||||
? t("app.lightEdit.openProjectTooltip", { folder: dirname(path) })
|
||||
: t("app.lightEdit.pickFolderTooltip")
|
||||
}
|
||||
onClick={() => void executeAction(hasRealFile ? LIGHT_EDIT_OPEN_PROJECT : "file.openProject")}
|
||||
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")}
|
||||
|
||||
@@ -279,13 +279,6 @@ export async function confirmLeave(
|
||||
): Promise<boolean> {
|
||||
if (!(await closeDocumentWindows())) return false;
|
||||
const confirmTerminate = workflowSettings().run.confirmTerminate;
|
||||
// Untitled buffers follow the window across projects: they are neither
|
||||
// counted nor saved by this guard (the window keeps them open).
|
||||
const untitled = new Set(maybeEditorService()?.untitledDirtyDocumentIds?.() ?? []);
|
||||
const saveDeps: CloseDeps =
|
||||
untitled.size > 0
|
||||
? { ...deps, saveAll: () => Promise.resolve(maybeEditorService()?.saveAll({ skipUntitled: true })) }
|
||||
: deps;
|
||||
return decideClose(
|
||||
{
|
||||
quitting: false,
|
||||
@@ -293,10 +286,10 @@ export async function confirmLeave(
|
||||
confirmExit: false,
|
||||
confirmTerminate,
|
||||
processes: confirmTerminate ? await processNames(deps) : [],
|
||||
dirty: dirtyCount() - untitled.size,
|
||||
dirty: dirtyCount(),
|
||||
project,
|
||||
},
|
||||
saveDeps,
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
confirmLeaveProject,
|
||||
dirname,
|
||||
openLightEdit,
|
||||
openLightEditUntitled,
|
||||
openProject,
|
||||
provideStartupLaunch,
|
||||
useProject,
|
||||
@@ -27,7 +26,6 @@ import { holdSplash } from "@/platform/splash";
|
||||
import { editorService, maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { chooseProjectWindow } from "@/workbench/projectWindow";
|
||||
import { cycleWindowTab, useWindowGroup } from "@/platform/windowGroups";
|
||||
import { editorSwitching } from "@/editor/api";
|
||||
import { showWorkspaceDialog } from "./WorkspaceDialog";
|
||||
|
||||
export const LAUNCH_EVENT = "openidea://launch";
|
||||
@@ -482,20 +480,6 @@ export function windowActions(): Action[] {
|
||||
await openFiles(await pickFiles(t("app.window.pickFileTitle")));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "file.newUntitled",
|
||||
title: t("app.action.newFile"),
|
||||
category: t("app.category.file"),
|
||||
icon: "plus",
|
||||
keywords: ["new", "create", "untitled", "file", "light edit"],
|
||||
run: async () => {
|
||||
if (desktopOnly()) return;
|
||||
const s = useProject.getState();
|
||||
// A welcome window becomes a LightEdit one first (its buffers need a backend project).
|
||||
if (!s.project && !s.lightEdit) await openLightEditUntitled();
|
||||
await editorService().openUntitled?.({ focus: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
// Files and folders dropped on the Welcome screen.
|
||||
id: "file.openPaths",
|
||||
@@ -594,9 +578,7 @@ async function closeThisWindow(): Promise<void> {
|
||||
export function installLightEditAutoClose(closeWindow: () => Promise<void> = closeThisWindow): () => void {
|
||||
const editor = maybeEditorService();
|
||||
const off = editor?.onActiveInputChange?.((input) => {
|
||||
// A mode switch clears and re-fills the tabs in place: while it runs, the
|
||||
// momentarily empty editor must not read as "the user closed everything".
|
||||
if (input || !useProject.getState().lightEdit || editor?.openTabs?.().length || editorSwitching()) return;
|
||||
if (input || !useProject.getState().lightEdit || editor?.openTabs?.().length) return;
|
||||
void closeWindow().catch((e) => console.warn("could not close the window", e));
|
||||
});
|
||||
return () => off?.();
|
||||
|
||||
@@ -38,11 +38,4 @@ describe("Language server status popup", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /Language servers/ }));
|
||||
expect(screen.getByRole("button", { name: "Check for jdtls again" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("restarts a server that is still starting (an import can take minutes)", () => {
|
||||
useServers.setState({ servers: new Map([[1 as never, server("starting", null)]]) });
|
||||
render(<ServerStatus />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Language servers/ }));
|
||||
expect(screen.getByRole("button", { name: "Restart jdtls" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,7 @@ export function ServerStatus() {
|
||||
icon="refresh"
|
||||
size="small"
|
||||
title={s.status === "missing" ? tx`Check for ${s.name} again` : tx`Restart ${serverLabel(s)}`}
|
||||
disabled={s.status === "starting"}
|
||||
onClick={() => void restartServer(s.id)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -198,7 +198,6 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
|
||||
"PY.DOC_COMMENT_TAG": [["FOREGROUND", "--syn-python-docstring-tag"]],
|
||||
OPENIDEA_pyDocstringKeyword: [["FOREGROUND", "--syn-python-docstring-keyword"]],
|
||||
"PY.BUILTIN_NAME": [["FOREGROUND", "--syn-predefined"]],
|
||||
"BASH.EXTERNAL_COMMAND": [["FOREGROUND", "--syn-shell-command"]],
|
||||
MARKDOWN_HEADER_LEVEL_1: [["FOREGROUND", "--syn-markup-heading"]],
|
||||
MARKDOWN_CODE_SPAN: [["FOREGROUND", "--syn-markup-code"]],
|
||||
MARKDOWN_LINK_TEXT: [["FOREGROUND", "--syn-link"]],
|
||||
|
||||
@@ -301,11 +301,9 @@
|
||||
.widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
.widgetChooser {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -378,7 +376,6 @@
|
||||
flex: none;
|
||||
}
|
||||
.widgetButtons {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
@@ -33,9 +33,6 @@ function HeaderButtons() {
|
||||
const openKey = shortcutLabel("file.openProject");
|
||||
return (
|
||||
<div className={styles.headerButtons}>
|
||||
<Button disabled={opening} onClick={() => void executeAction("file.newUntitled")}>
|
||||
<T>New File</T>
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button disabled={opening} onClick={() => void executeAction("file.newProject")}>
|
||||
<T>New Project</T>
|
||||
@@ -48,9 +45,6 @@ function HeaderButtons() {
|
||||
>
|
||||
<T>Open</T>
|
||||
</Button>
|
||||
<Button disabled={opening} onClick={() => void executeAction("file.openFile")}>
|
||||
<T>Open File</T>
|
||||
</Button>
|
||||
{canClone && (
|
||||
<Button onClick={() => void executeAction("vcs.clone")}>
|
||||
<T>Clone Repository</T>
|
||||
@@ -87,12 +81,6 @@ function Landing() {
|
||||
<T>You can also drop a folder onto this window.</T>
|
||||
</p>
|
||||
<div className={styles.landingActions}>
|
||||
<button type="button" className={styles.landingAction} onClick={() => void executeAction("file.newUntitled")}>
|
||||
<span className={styles.landingIcon}>
|
||||
<Icon name="plus" size={20} />
|
||||
</span>
|
||||
<T>New File</T>
|
||||
</button>
|
||||
{canCreate && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -117,12 +105,6 @@ function Landing() {
|
||||
</span>
|
||||
<T>Open</T>
|
||||
</button>
|
||||
<button type="button" className={styles.landingAction} onClick={() => void executeAction("file.openFile")}>
|
||||
<span className={styles.landingIcon}>
|
||||
<Icon name="file" size={20} />
|
||||
</span>
|
||||
<T>Open File</T>
|
||||
</button>
|
||||
{canClone && (
|
||||
<button type="button" className={styles.landingAction} onClick={() => void executeAction("vcs.clone")}>
|
||||
<span className={styles.landingIcon}>
|
||||
|
||||
@@ -86,19 +86,6 @@
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
/* A narrow window: a slimmer sidebar leaves the page room to read. */
|
||||
@media (max-width: 560px) {
|
||||
.sidebar {
|
||||
width: 160px;
|
||||
}
|
||||
.tagline {
|
||||
display: none;
|
||||
}
|
||||
.navItem {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Content ---------------------------------------------------------- */
|
||||
|
||||
.content {
|
||||
@@ -138,23 +125,19 @@
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
min-height: 76px;
|
||||
height: 76px;
|
||||
margin-left: 14px;
|
||||
padding: 12px 14px 12px 6px;
|
||||
padding: 0 14px 0 6px;
|
||||
box-shadow: inset 0 -1px 0 var(--border);
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 160px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Wraps in a narrow window, so no button is clipped. */
|
||||
.headerButtons {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.projectList {
|
||||
@@ -357,18 +340,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px 40px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
/* Auto margins centre the content and, unlike justify-content, never push it past the top edge. */
|
||||
.landing > :first-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
.landing > :last-child {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
.landingTitle {
|
||||
color: var(--fg-strong);
|
||||
font-size: 18px;
|
||||
@@ -379,18 +355,16 @@
|
||||
color: var(--fg-muted);
|
||||
line-height: 20px;
|
||||
}
|
||||
/* Equal-width actions keep the icon spacing uniform regardless of each label's
|
||||
length, and wrap onto further rows in a narrow window. */
|
||||
/* Equal columns (as wide as the longest label) keep the icon spacing uniform
|
||||
regardless of each label's length. */
|
||||
.landingActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 16px 24px;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.landingAction {
|
||||
flex: none;
|
||||
width: 104px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -364,7 +364,6 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
|
||||
italicOnly: true,
|
||||
defaultItalic: false,
|
||||
},
|
||||
syn("shellCommand", "Command", ["Shell Script"], "shell-command"),
|
||||
syn("mdHeading", "Heading", ["Markdown"], "markup-heading", { defaultBold: true }),
|
||||
syn("mdCode", "Code span", ["Markdown"], "markup-code"),
|
||||
syn("mdLink", "Link", ["Markdown"], "link", { defaultEffect: "underline" }),
|
||||
|
||||
@@ -7,13 +7,10 @@
|
||||
import {
|
||||
open as pluginOpen,
|
||||
save as pluginSave,
|
||||
type DialogFilter,
|
||||
type OpenDialogOptions,
|
||||
type OpenDialogReturn,
|
||||
type SaveDialogOptions,
|
||||
} from "@tauri-apps/plugin-dialog";
|
||||
|
||||
export type { SaveDialogOptions, DialogFilter };
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
|
||||
let needsGtk: boolean | undefined;
|
||||
|
||||
@@ -182,8 +182,7 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
|
||||
// File / editor tabs
|
||||
"file.save": ["Meta+S"],
|
||||
"file.newUntitled": ["Meta+N"],
|
||||
"file.new": [],
|
||||
"file.new": ["Meta+N"],
|
||||
"file.settings": ["Meta+,"],
|
||||
"file.projectStructure": [],
|
||||
"file.openProject": ["Meta+O"],
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"app.action.cut": "Cut",
|
||||
"app.action.keymapReference": "Keymap Reference",
|
||||
"app.action.lightEditOpenProject": "Open Containing Folder as Project",
|
||||
"app.action.newFile": "New Untitled File",
|
||||
"app.action.newWindow": "New Window",
|
||||
"app.action.nextWindow": "Next Project Window",
|
||||
"app.action.openFile": "Open File…",
|
||||
@@ -50,7 +49,6 @@
|
||||
"app.keymap.title": "Keymap Reference",
|
||||
"app.lightEdit.openProject": "Open as Project",
|
||||
"app.lightEdit.openProjectTooltip": "Open {folder} as a project",
|
||||
"app.lightEdit.pickFolderTooltip": "Pick a folder to open as a project",
|
||||
"app.log.failed": "Cannot show the log",
|
||||
"app.project.opening": "Opening {name}…",
|
||||
"app.terminateProcesses.bodyMany": "Do you want to terminate these {count} processes: {names}?",
|
||||
@@ -62,10 +60,8 @@
|
||||
"app.title.default": "OpenIDEA",
|
||||
"app.unsaved.body": "{count, plural, one {# file has} other {# files have}} unsaved changes in {project}. Save before closing?",
|
||||
"app.unsaved.discard": "Don't Save",
|
||||
"app.unsaved.fileBody": "{name} has unsaved changes. Save it before closing?",
|
||||
"app.unsaved.save": "Save All",
|
||||
"app.unsaved.saveFailed": "Could not save all files",
|
||||
"app.unsaved.saveFile": "Save",
|
||||
"app.unsaved.title": "Unsaved Changes",
|
||||
"app.update.available": "OpenIDEA {version} is available",
|
||||
"app.update.cancel": "Cancel",
|
||||
@@ -203,7 +199,6 @@
|
||||
"menu.workspace.create": "Workspace…",
|
||||
"menu.workspace.open": "Open Workspace…",
|
||||
"menu.workspace.manage": "Manage Workspace…",
|
||||
"menu.file.newUntitled": "New Untitled File",
|
||||
"menu.file.openInNewWindow": "Open in New Window…",
|
||||
"menu.file.openFile": "Open File…",
|
||||
"menu.remote.connect": "Remote Development…",
|
||||
|
||||
@@ -1431,7 +1431,6 @@
|
||||
"{program} {found} is installed, but this project needs {min} or later.": "{program} {found} is installed, but this project needs {min} or later.",
|
||||
"{program} is not installed.": "{program} is not installed.",
|
||||
"Create a project, open a folder from disk or clone a repository from version control.": "Create a project, open a folder from disk or clone a repository from version control.",
|
||||
"New File": "New File",
|
||||
"New Project": "New Project",
|
||||
"{path} already exists and is not empty": "{path} already exists and is not empty",
|
||||
"Example": "Example",
|
||||
|
||||
@@ -783,7 +783,6 @@
|
||||
"Cannot generate the DDL": "无法生成 DDL",
|
||||
"Cannot generate the SQL script": "无法生成 SQL 脚本",
|
||||
"Cannot get the DDL of {name}": "无法获取 {name} 的 DDL",
|
||||
"Cannot save file": "无法保存文件",
|
||||
"Cannot open file": "无法打开文件",
|
||||
"Cannot open library class": "无法打开库类",
|
||||
"Cannot open link": "无法打开链接",
|
||||
@@ -1440,7 +1439,6 @@
|
||||
"Hide Active Tool Window": "隐藏活动工具窗口",
|
||||
"Hide All Tool Windows": "隐藏所有工具窗口",
|
||||
"Hide changes": "隐藏更改",
|
||||
"Resize changes": "调整更改面板宽度",
|
||||
"Hide Coverage": "隐藏覆盖率",
|
||||
"Hide Library Frames": "隐藏库帧",
|
||||
"Hide Replace": "隐藏替换",
|
||||
@@ -2503,7 +2501,6 @@
|
||||
"Sample plugin: inserts today's date and counts words in the active file.": "示例插件:插入今天的日期,并统计当前文件的字数。",
|
||||
"Save": "保存",
|
||||
"Save All": "全部保存",
|
||||
"Save As": "另存为",
|
||||
"Save Files": "保存文件",
|
||||
"Save files if the IDE is idle for": "IDE 空闲以下时长时保存文件",
|
||||
"Save files when switching to a different application or a built-in terminal": "切换到其他应用程序或内置终端时保存文件",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"app.action.cut": "剪切",
|
||||
"app.action.keymapReference": "快捷键参考",
|
||||
"app.action.lightEditOpenProject": "将所在文件夹作为项目打开",
|
||||
"app.action.newFile": "新建无标题文件",
|
||||
"app.action.newWindow": "新建窗口",
|
||||
"app.action.nextWindow": "下一个项目窗口",
|
||||
"app.action.openFile": "打开文件…",
|
||||
@@ -50,7 +49,6 @@
|
||||
"app.keymap.title": "快捷键参考",
|
||||
"app.lightEdit.openProject": "作为项目打开",
|
||||
"app.lightEdit.openProjectTooltip": "将 {folder} 作为项目打开",
|
||||
"app.lightEdit.pickFolderTooltip": "选择一个文件夹作为工程打开",
|
||||
"app.log.failed": "无法显示日志",
|
||||
"app.project.opening": "正在打开 {name}…",
|
||||
"app.terminateProcesses.bodyMany": "是否要终止这 {count} 个进程:{names}?",
|
||||
@@ -62,10 +60,8 @@
|
||||
"app.title.default": "OpenIDEA",
|
||||
"app.unsaved.body": "{project} 中有 {count} 个文件尚未保存。关闭前是否保存?",
|
||||
"app.unsaved.discard": "不保存",
|
||||
"app.unsaved.fileBody": "{name} 尚未保存。关闭前是否保存?",
|
||||
"app.unsaved.save": "全部保存",
|
||||
"app.unsaved.saveFailed": "无法保存所有文件",
|
||||
"app.unsaved.saveFile": "保存",
|
||||
"app.unsaved.title": "未保存的更改",
|
||||
"app.update.available": "OpenIDEA {version} 可用",
|
||||
"app.update.cancel": "取消",
|
||||
@@ -203,7 +199,6 @@
|
||||
"menu.workspace.create": "工作区…",
|
||||
"menu.workspace.open": "打开工作区…",
|
||||
"menu.workspace.manage": "管理工作区…",
|
||||
"menu.file.newUntitled": "新建无标题文件",
|
||||
"menu.file.openInNewWindow": "在新窗口中打开…",
|
||||
"menu.file.openFile": "打开文件…",
|
||||
"menu.remote.connect": "远程开发…",
|
||||
|
||||
@@ -79,12 +79,6 @@ export async function openLightEdit(path: string): Promise<void> {
|
||||
useProject.setState({ project: null, settings, lightEdit: true });
|
||||
}
|
||||
|
||||
/** Switches this window to LightEdit without a file (File › New File from the welcome screen). */
|
||||
export async function openLightEditUntitled(): Promise<void> {
|
||||
const settings = await call(commands.lightEditOpenUntitled());
|
||||
useProject.setState({ project: null, settings, lightEdit: true });
|
||||
}
|
||||
|
||||
export async function closeProject() {
|
||||
await commands.closeProject();
|
||||
useProject.setState({ project: null, lightEdit: false });
|
||||
|
||||
@@ -13,18 +13,6 @@ export function isVirtualUri(s: string | null | undefined): boolean {
|
||||
return !!m && (m[1]!.toLowerCase() !== "file" || isClassFile(s!));
|
||||
}
|
||||
|
||||
/** Scheme of an unsaved new-file buffer (File › New File), keyed like a virtual document. */
|
||||
export const UNTITLED_SCHEME = "untitled:";
|
||||
|
||||
export function isUntitledUri(s: string | null | undefined): boolean {
|
||||
return !!s && s.startsWith(UNTITLED_SCHEME);
|
||||
}
|
||||
|
||||
/** Display name of an untitled buffer's URI (`untitled:Untitled-1` → `Untitled-1`); passthrough otherwise. */
|
||||
export function untitledTitle(uri: string): string {
|
||||
return isUntitledUri(uri) ? uri.slice(UNTITLED_SCHEME.length) : uri;
|
||||
}
|
||||
|
||||
/** Last path segment of a URI (without query / fragment), e.g. `SpringApplication.class`. */
|
||||
export function virtualName(uri: string): string {
|
||||
const rest = uri.replace(/^[^:]+:(\/\/)?/, "").split(/[?#]/)[0]!;
|
||||
|
||||
@@ -114,7 +114,6 @@ html {
|
||||
--syn-type: #bcbec4;
|
||||
--syn-function: #56a8f5;
|
||||
--syn-function-call: #bcbec4;
|
||||
--syn-shell-command: #c57633;
|
||||
--syn-comment: #7a7e85;
|
||||
--syn-comment-style: normal;
|
||||
--syn-doc-comment: #5f826b;
|
||||
@@ -559,7 +558,6 @@ html {
|
||||
--syn-type: #000000;
|
||||
--syn-function: #00627a;
|
||||
--syn-function-call: #080808;
|
||||
--syn-shell-command: #0073bf;
|
||||
--syn-comment: #8c8c8c;
|
||||
--syn-comment-style: italic;
|
||||
--syn-doc-comment: #8c8c8c;
|
||||
@@ -858,7 +856,6 @@ html {
|
||||
--syn-type: #e3e4e8;
|
||||
--syn-function: #7cc0ff;
|
||||
--syn-function-call: #e3e4e8;
|
||||
--syn-shell-command: #c57633;
|
||||
--syn-comment: #a5a9b0;
|
||||
--syn-doc-comment: #8fb89c;
|
||||
--syn-doc-comment-tag: #8fcca3;
|
||||
@@ -1247,7 +1244,6 @@ html {
|
||||
--syn-type: var(--palette-type);
|
||||
--syn-function: var(--palette-blue);
|
||||
--syn-function-call: var(--palette-blue);
|
||||
--syn-shell-command: var(--syn-function-call);
|
||||
--syn-comment: var(--palette-comment);
|
||||
--syn-doc-comment: var(--palette-comment);
|
||||
--syn-doc-comment-tag: var(--palette-keyword);
|
||||
|
||||
@@ -196,12 +196,9 @@
|
||||
gap: 12px;
|
||||
}
|
||||
.buttons {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ const HINTS: Hint[] = [
|
||||
{ label: "Drop files here to open them" },
|
||||
];
|
||||
|
||||
/** LightEdit has no project to navigate: only opening and creating files works there. */
|
||||
/** LightEdit has no project to navigate: only opening files works there. */
|
||||
const LIGHT_EDIT_HINTS: Hint[] = [
|
||||
{ label: "New File", action: "file.newUntitled" },
|
||||
{ label: "Open File", action: "file.openFile" },
|
||||
{ label: "Drop files here to open them" },
|
||||
];
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
}
|
||||
/* Main toolbar dropdown widgets. */
|
||||
.widget {
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
@@ -162,6 +161,7 @@
|
||||
height: var(--stripe-icon-button);
|
||||
overflow: hidden;
|
||||
}
|
||||
.mainMenuSlot ~ .item,
|
||||
.mainMenuSlot ~ .tools {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -236,28 +236,3 @@
|
||||
background: var(--caption-close-pressed);
|
||||
color: var(--fg-caption-close-hover);
|
||||
}
|
||||
|
||||
/* A narrow window: tighter spacing, and the widgets give way (ellipsis) before anything overlaps. */
|
||||
@media (max-width: 560px) {
|
||||
.bar {
|
||||
gap: 2px;
|
||||
}
|
||||
.widget {
|
||||
padding: 0 4px;
|
||||
}
|
||||
.tools {
|
||||
margin-left: 2px;
|
||||
}
|
||||
.drag {
|
||||
min-width: 0;
|
||||
}
|
||||
.mainMenuButton {
|
||||
margin-right: 0;
|
||||
}
|
||||
.captions {
|
||||
margin-left: 0;
|
||||
}
|
||||
.caption {
|
||||
width: 28px;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { Breadcrumbs } from "@/editor/core";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { isUntitledUri, untitledTitle } from "@/platform/virtualDocs";
|
||||
import { useT } from "@/platform/i18n";
|
||||
import { ProgressBar } from "@/ui";
|
||||
import projectMark from "../../src-tauri/icons/source/openidea-mark.svg";
|
||||
@@ -43,10 +42,9 @@ function LightEditFrame({ title }: { title?: string }) {
|
||||
const path = useEditorRegistry((s) => s.active?.ctx.path);
|
||||
const distractionFree = useDistractionFree((s) => s.active);
|
||||
const statusBar = useAppearanceFlag("showStatusBar");
|
||||
const name = path ? (isUntitledUri(path) ? untitledTitle(path) : basename(path)) : "LightEdit";
|
||||
return (
|
||||
<>
|
||||
<TitleBar lightEdit compact={distractionFree} title={title ?? name} />
|
||||
<TitleBar lightEdit compact={distractionFree} title={title ?? (path ? basename(path) : "LightEdit")} />
|
||||
<main className={styles.lightEdit} data-light-edit>
|
||||
<div className={styles.lightEditIsland}>
|
||||
<EditorArea />
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "./contributions";
|
||||
import { installBrowserGuards } from "./browserGuards";
|
||||
import { installDistractionFree } from "./distractionFree";
|
||||
import { installNarrowWindow } from "./narrowWindow";
|
||||
import { installFileDrop } from "./fileDrop";
|
||||
import { installFocusTracking } from "./focus";
|
||||
import { installFocusScopeTracking } from "@/ui/focusScope";
|
||||
@@ -75,7 +74,6 @@ export function activateWorkbench(): () => void {
|
||||
installToolWindowActions(),
|
||||
installToolWindowLayoutSync(),
|
||||
installDistractionFree(),
|
||||
installNarrowWindow(),
|
||||
installFocusTracking(setFocusedToolWindow),
|
||||
installFocusScopeTracking(),
|
||||
installTaskEvents(),
|
||||
|
||||
@@ -644,16 +644,12 @@ export const EDITOR_TAB_MIME = "application/x-openidea-editor-tab";
|
||||
export interface EditorService {
|
||||
openFile(path: string, options?: OpenOptions): Promise<void>;
|
||||
openInput(input: EditorInput, options?: OpenOptions): Promise<void>;
|
||||
/** File › New File: an editable untitled buffer; Save asks for a path. */
|
||||
openUntitled?(options?: OpenOptions): Promise<void>;
|
||||
/** Path of the focused text editor, if any. */
|
||||
activePath(): string | null;
|
||||
/** Doc id + cursor of the focused text editor. */
|
||||
activeCursor(): { doc: number; path: string; line: number; col: number; offset: number } | null;
|
||||
/** Save all dirty documents; rejects with the failures once every save was tried. */
|
||||
saveAll(opts?: { skipUntitled?: boolean }): Promise<void>;
|
||||
/** Doc ids of untitled buffers with unsaved changes (they follow the window, not a project). */
|
||||
untitledDirtyDocumentIds?(): number[];
|
||||
saveAll(): Promise<void>;
|
||||
/** Includes local edits not yet acknowledged by the backend. */
|
||||
dirtyDocumentIds?(): number[];
|
||||
/** Move keyboard focus to the active editor (used by Escape from tool windows). */
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerToolWindow } from "./contributions";
|
||||
import { installNarrowWindow, NARROW_WINDOW_QUERY } from "./narrowWindow";
|
||||
import { isToolWindowVisible, loadToolWindowLayout, resetToolWindowLayout, showToolWindow } from "./toolwindows/store";
|
||||
|
||||
const Empty = () => null;
|
||||
|
||||
/** A matchMedia whose result a test flips, firing the change listeners. */
|
||||
function stubMedia(initial: boolean) {
|
||||
const listeners = new Set<() => void>();
|
||||
const query = {
|
||||
matches: initial,
|
||||
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
|
||||
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
|
||||
};
|
||||
vi.stubGlobal("matchMedia", () => query);
|
||||
return {
|
||||
set(matches: boolean) {
|
||||
query.matches = matches;
|
||||
listeners.forEach((fn) => fn());
|
||||
},
|
||||
listeners,
|
||||
};
|
||||
}
|
||||
|
||||
let disposers: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetToolWindowLayout();
|
||||
disposers = [
|
||||
registerToolWindow({ id: "project", title: "Project", icon: "folder", anchor: "left", component: Empty }),
|
||||
];
|
||||
loadToolWindowLayout("/work/a");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disposers.forEach((d) => d());
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("installNarrowWindow", () => {
|
||||
it("collapses the sides when the window narrows and restores them when it widens", () => {
|
||||
const media = stubMedia(false);
|
||||
const dispose = installNarrowWindow();
|
||||
expect(isToolWindowVisible("project")).toBe(true);
|
||||
media.set(true);
|
||||
expect(isToolWindowVisible("project")).toBe(false);
|
||||
media.set(false);
|
||||
expect(isToolWindowVisible("project")).toBe(true);
|
||||
dispose();
|
||||
expect(media.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("collapses at startup in a narrow window and again for the next project", () => {
|
||||
stubMedia(true);
|
||||
const dispose = installNarrowWindow();
|
||||
expect(isToolWindowVisible("project")).toBe(false);
|
||||
loadToolWindowLayout("/work/b");
|
||||
expect(isToolWindowVisible("project")).toBe(false);
|
||||
showToolWindow("project");
|
||||
expect(isToolWindowVisible("project")).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("collapses again when the same project reopens", () => {
|
||||
stubMedia(true);
|
||||
const dispose = installNarrowWindow();
|
||||
showToolWindow("project");
|
||||
loadToolWindowLayout("/work/a");
|
||||
expect(isToolWindowVisible("project")).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("does nothing where matchMedia is missing", () => {
|
||||
vi.stubGlobal("matchMedia", undefined);
|
||||
expect(() => installNarrowWindow()()).not.toThrow();
|
||||
expect(isToolWindowVisible("project")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("narrow window breakpoint", () => {
|
||||
it("is the one the title bar and welcome page stylesheets use", () => {
|
||||
for (const file of ["./TitleBar.module.css", "../features/welcome/Welcome.module.css"]) {
|
||||
const css = readFileSync(new URL(file, import.meta.url), "utf8");
|
||||
expect(css).toContain(`@media ${NARROW_WINDOW_QUERY}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* In a window too narrow for the side tool windows (a tile on a tiling compositor), the left and
|
||||
* right areas collapse so the editor keeps the width. Their stripe buttons stay; the saved layout
|
||||
* is untouched, and a wider window puts them back. Keep the breakpoint in step with the
|
||||
* `max-width: 560px` queries in Welcome.module.css and TitleBar.module.css.
|
||||
*/
|
||||
import { collapseSidesForNarrowWindow, restoreSidesAfterNarrowWindow, useToolWindowLayout } from "./toolwindows/store";
|
||||
|
||||
export const NARROW_WINDOW_QUERY = "(max-width: 560px)";
|
||||
|
||||
export function installNarrowWindow(): () => void {
|
||||
if (typeof window.matchMedia !== "function") return () => {};
|
||||
const query = window.matchMedia(NARROW_WINDOW_QUERY);
|
||||
const apply = () => (query.matches ? collapseSidesForNarrowWindow() : restoreSidesAfterNarrowWindow());
|
||||
query.addEventListener("change", apply);
|
||||
// A project's layout loads afresh each time it opens, the same project again included.
|
||||
const unsubscribe = useToolWindowLayout.subscribe((s, prev) => {
|
||||
if (s.layoutLoads !== prev.layoutLoads && query.matches) collapseSidesForNarrowWindow();
|
||||
});
|
||||
apply();
|
||||
return () => {
|
||||
query.removeEventListener("change", apply);
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
@@ -5,12 +5,6 @@ import { registerToolWindow } from "../contributions";
|
||||
import { installToolWindowActions } from "./actions";
|
||||
import {
|
||||
activateToolWindow,
|
||||
applyToolWindowLayout,
|
||||
collapseSidesForNarrowWindow,
|
||||
enterDistractionFreeLayout,
|
||||
exitDistractionFreeLayout,
|
||||
toolWindowLayoutSnapshot,
|
||||
restoreSidesAfterNarrowWindow,
|
||||
hideActiveToolWindow,
|
||||
hideAllToolWindows,
|
||||
hideToolWindow,
|
||||
@@ -546,106 +540,3 @@ describe("view modes", () => {
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("narrow window", () => {
|
||||
const saved = () => JSON.parse(localStorage.getItem("openidea.layout.v1:/work/a") ?? "{}").visible;
|
||||
|
||||
it("hides the side windows, keeps the bottom one, and puts the sides back", () => {
|
||||
showToolWindow("terminal");
|
||||
collapseSidesForNarrowWindow();
|
||||
expect(isToolWindowVisible("project")).toBe(false);
|
||||
expect(isToolWindowVisible("terminal")).toBe(true);
|
||||
expect(useToolWindowLayout.getState().stripeButtons.project).not.toBe(false);
|
||||
restoreSidesAfterNarrowWindow();
|
||||
expect(isToolWindowVisible("project")).toBe(true);
|
||||
expect(isToolWindowVisible("terminal")).toBe(true);
|
||||
expect(useToolWindowLayout.getState().narrowStash).toBeNull();
|
||||
});
|
||||
|
||||
it("reopens a window from its stripe button while narrow", () => {
|
||||
collapseSidesForNarrowWindow();
|
||||
toggleToolWindow("project");
|
||||
expect(isToolWindowVisible("project")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not save the collapse, but does save changes made meanwhile", () => {
|
||||
showToolWindow("terminal");
|
||||
collapseSidesForNarrowWindow();
|
||||
expect(saved().left).toBe("project");
|
||||
showToolWindow("problems");
|
||||
expect(saved()).toMatchObject({ left: "project", bottom: "problems" });
|
||||
});
|
||||
|
||||
it("keeps a side window the user opened while narrow over the remembered one", () => {
|
||||
collapseSidesForNarrowWindow();
|
||||
showToolWindow("structure");
|
||||
restoreSidesAfterNarrowWindow();
|
||||
expect(isToolWindowVisible("structure")).toBe(true);
|
||||
expect(isToolWindowVisible("project")).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses once, ignores a layout without sides or a project, and clears a maximized side", () => {
|
||||
toggleMaximizeToolWindow("project");
|
||||
collapseSidesForNarrowWindow();
|
||||
const stash = useToolWindowLayout.getState().narrowStash;
|
||||
expect(useToolWindowLayout.getState().maximized).toBeNull();
|
||||
collapseSidesForNarrowWindow();
|
||||
expect(useToolWindowLayout.getState().narrowStash).toBe(stash);
|
||||
|
||||
resetToolWindowLayout();
|
||||
collapseSidesForNarrowWindow();
|
||||
expect(useToolWindowLayout.getState().narrowStash).toBeNull();
|
||||
loadToolWindowLayout("/work/a");
|
||||
hideToolWindow("project");
|
||||
collapseSidesForNarrowWindow();
|
||||
expect(useToolWindowLayout.getState().narrowStash).toBeNull();
|
||||
});
|
||||
|
||||
it("saves what the user does to a side area while narrow, not the remembered window", () => {
|
||||
collapseSidesForNarrowWindow();
|
||||
toggleToolWindow("project");
|
||||
hideToolWindow("project");
|
||||
expect(saved().left).toBeNull();
|
||||
expect(useToolWindowLayout.getState().narrowStash).toBeNull();
|
||||
restoreSidesAfterNarrowWindow();
|
||||
expect(isToolWindowVisible("project")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the hidden sides in the saved layout through Distraction Free Mode", () => {
|
||||
collapseSidesForNarrowWindow();
|
||||
enterDistractionFreeLayout();
|
||||
expect(saved().left).toBe("project");
|
||||
exitDistractionFreeLayout();
|
||||
expect(saved().left).toBe("project");
|
||||
restoreSidesAfterNarrowWindow();
|
||||
expect(isToolWindowVisible("project")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes the hidden sides in a saved layout snapshot and drops them when a layout is applied", () => {
|
||||
collapseSidesForNarrowWindow();
|
||||
const snapshot = toolWindowLayoutSnapshot();
|
||||
expect(snapshot.visible.left).toBe("project");
|
||||
applyToolWindowLayout({ ...snapshot, visible: { ...snapshot.visible, left: "structure" } });
|
||||
expect(useToolWindowLayout.getState().narrowStash).toBeNull();
|
||||
restoreSidesAfterNarrowWindow();
|
||||
expect(isToolWindowVisible("structure")).toBe(true);
|
||||
expect(saved().left).toBe("structure");
|
||||
});
|
||||
|
||||
it("forgets the hidden sides when another project's layout loads", () => {
|
||||
collapseSidesForNarrowWindow();
|
||||
loadToolWindowLayout("/work/b");
|
||||
expect(useToolWindowLayout.getState().narrowStash).toBeNull();
|
||||
expect(isToolWindowVisible("project")).toBe(true);
|
||||
restoreSidesAfterNarrowWindow();
|
||||
expect(isToolWindowVisible("project")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a maximized bottom window maximized", () => {
|
||||
showToolWindow("terminal");
|
||||
toggleMaximizeToolWindow("terminal");
|
||||
collapseSidesForNarrowWindow();
|
||||
expect(useToolWindowLayout.getState().maximized).toBe("bottom");
|
||||
expect(isToolWindowVisible("terminal")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -102,10 +102,6 @@ export interface ToolWindowLayoutState {
|
||||
viewModes: Readonly<Record<string, ToolWindowViewMode>>;
|
||||
/** Layout before Distraction Free Mode; restored on exit and persisted in place of `visible` meanwhile. */
|
||||
distractionFreeStash: SlotRecord | null;
|
||||
/** Side windows a narrow window hides; reopened from the stripe, put back when it widens, never saved. */
|
||||
narrowStash: Partial<SlotRecord> | null;
|
||||
/** Bumped each time a project's layout loads, even for the project that was already loaded. */
|
||||
layoutLoads: number;
|
||||
}
|
||||
|
||||
const initial = (): ToolWindowLayoutState => ({
|
||||
@@ -124,8 +120,6 @@ const initial = (): ToolWindowLayoutState => ({
|
||||
stripeButtons: {},
|
||||
viewModes: {},
|
||||
distractionFreeStash: null,
|
||||
narrowStash: null,
|
||||
layoutLoads: 0,
|
||||
});
|
||||
|
||||
export const useToolWindowLayout = create<ToolWindowLayoutState>(initial);
|
||||
@@ -255,10 +249,9 @@ function persist() {
|
||||
stripeButtons,
|
||||
viewModes,
|
||||
distractionFreeStash,
|
||||
narrowStash,
|
||||
} = useToolWindowLayout.getState();
|
||||
// Distraction Free Mode and a narrow window's collapsed sides are not saved: the layout to restore is.
|
||||
const visible = withoutSessionWindows({ ...(distractionFreeStash ?? current), ...narrowStash });
|
||||
// Distraction Free Mode is not saved: the layout to restore is.
|
||||
const visible = withoutSessionWindows(distractionFreeStash ?? current);
|
||||
try {
|
||||
localStorage.setItem(
|
||||
storageKey(scope),
|
||||
@@ -389,7 +382,6 @@ export function loadToolWindowLayout(scope: string) {
|
||||
visible = reconcile(visible, placements) ?? visible;
|
||||
useToolWindowLayout.setState({
|
||||
...initial(),
|
||||
layoutLoads: useToolWindowLayout.getState().layoutLoads + 1,
|
||||
scope,
|
||||
visible,
|
||||
sizes: sanitizeSizes(saved?.sizes),
|
||||
@@ -461,7 +453,6 @@ function publishVisibility() {
|
||||
|
||||
function update(patch: Partial<ToolWindowLayoutState>, save = true) {
|
||||
useToolWindowLayout.setState(patch);
|
||||
keepNarrowStashToHiddenSides();
|
||||
publishVisibility();
|
||||
if (save) persist();
|
||||
}
|
||||
@@ -598,58 +589,6 @@ export function exitDistractionFreeLayout() {
|
||||
if (s.focused && !Object.values(visible).includes(s.focused)) focusEditor();
|
||||
}
|
||||
|
||||
const SIDE_SLOTS: readonly ToolWindowSlot[] = ["left", "left-split", "right", "right-split"];
|
||||
|
||||
/** A side area the user fills while the window is narrow is their choice now, so it leaves the stash. */
|
||||
function keepNarrowStashToHiddenSides() {
|
||||
const { narrowStash, visible } = useToolWindowLayout.getState();
|
||||
if (!narrowStash) return;
|
||||
const kept: Partial<SlotRecord> = {};
|
||||
for (const slot of SIDE_SLOTS) if (narrowStash[slot] && !visible[slot]) kept[slot] = narrowStash[slot];
|
||||
if (Object.keys(kept).length === Object.keys(narrowStash).length) return;
|
||||
useToolWindowLayout.setState({ narrowStash: Object.keys(kept).length ? kept : null });
|
||||
}
|
||||
|
||||
/**
|
||||
* A window too narrow for the side areas to leave the editor any room hides them; their stripe
|
||||
* buttons stay, so one click reopens a window. The layout on disk is untouched.
|
||||
*/
|
||||
export function collapseSidesForNarrowWindow() {
|
||||
const s = useToolWindowLayout.getState();
|
||||
if (!s.scope || s.narrowStash || s.distractionFreeStash) return;
|
||||
const shown = SIDE_SLOTS.filter((slot) => s.visible[slot]);
|
||||
if (!shown.length) return;
|
||||
const stash: Partial<SlotRecord> = {};
|
||||
const visible = { ...s.visible };
|
||||
for (const slot of shown) {
|
||||
stash[slot] = s.visible[slot];
|
||||
visible[slot] = null;
|
||||
}
|
||||
const hadFocus = !!s.focused && shown.some((slot) => s.visible[slot] === s.focused);
|
||||
update({
|
||||
narrowStash: stash,
|
||||
visible,
|
||||
maximized: s.maximized === "bottom" ? s.maximized : null,
|
||||
lastActive: shown.some((slot) => s.visible[slot] === s.lastActive) ? null : s.lastActive,
|
||||
});
|
||||
if (hadFocus) focusEditor();
|
||||
}
|
||||
|
||||
/** Puts back the side windows a narrow window hid, except in areas the user has since filled. */
|
||||
export function restoreSidesAfterNarrowWindow() {
|
||||
const s = useToolWindowLayout.getState();
|
||||
if (!s.narrowStash) return;
|
||||
const visible = { ...s.visible };
|
||||
const mounted = new Set(s.mounted);
|
||||
for (const slot of SIDE_SLOTS) {
|
||||
const id = s.narrowStash[slot];
|
||||
if (!id || visible[slot]) continue;
|
||||
visible[slot] = id;
|
||||
mounted.add(id);
|
||||
}
|
||||
update({ visible, mounted, narrowStash: null });
|
||||
}
|
||||
|
||||
export function hideActiveToolWindow() {
|
||||
const s = useToolWindowLayout.getState();
|
||||
if (s.maximized && !hasRegisteredVisibleOn(s.maximized, s.visible, s.placements)) {
|
||||
@@ -760,7 +699,7 @@ export interface ToolWindowLayoutSnapshot {
|
||||
export function toolWindowLayoutSnapshot(): ToolWindowLayoutSnapshot {
|
||||
const s = useToolWindowLayout.getState();
|
||||
return {
|
||||
visible: { ...(s.distractionFreeStash ?? s.visible), ...s.narrowStash },
|
||||
visible: { ...(s.distractionFreeStash ?? s.visible) },
|
||||
sizes: { ...s.sizes },
|
||||
splits: { ...s.splits },
|
||||
placements: { ...s.placements },
|
||||
@@ -807,7 +746,6 @@ export function applyToolWindowLayout(snapshot: ToolWindowLayoutSnapshot) {
|
||||
maximized: null,
|
||||
stashed: null,
|
||||
distractionFreeStash: null,
|
||||
narrowStash: null,
|
||||
});
|
||||
if (hadFocus) focusEditor();
|
||||
}
|
||||
|
||||
Reference in new issue
Заблокувати користувача