Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai 54e3b63547 fix(themes): add the shell command token to the theme template and docs
#3458 added --syn-shell-command without setting it in the Harbor Night
scheme (the template new themes start from) or regenerating plugins/themes.md,
so scripts/theme/theme.test.ts failed. Imported IntelliJ schemes map
BASH.EXTERNAL_COMMAND to it.
2026-11-15 08:23:08 +08:00
l0ng-ai 0af3bd00e7 fix(editor): highlight shell commands, function names and local from the grammar (#3458)
The legacy shell stream mode works from fixed word lists: local, declare and
return were plain text, and so were function definitions and any command
outside its ~70 builtins. The bundled tree-sitter-bash grammar now draws its
highlights over the stream mode, as Kotlin's does: declaration keywords,
function definition names, command names and variables. Command names get
their own colour (--syn-shell-command, Color Scheme › Shell Script ›
Command), blue in light and orange in dark schemes, falling back to the
function-call colour in colour themes.
2026-11-15 05:10:11 +08:00
SunZhiQiang 951e81e065 feat(editor): untitled buffers and Save As with a file type (#3427)
File › New Untitled File opens an editable untitled buffer (Untitled-N), also from the welcome page (New File, plus Open File beside Open), the LightEdit empty-editor hints, and Meta+N in the VS Code keymap. Saving asks for a path in a Save As dialog whose type dropdown lists every registered language; the backend re-keys the buffer under the operations lock, keeps its undo history and re-detects the language. Untitled buffers belong to the window and follow it into and out of projects; closing a dirty one asks to save it. A LightEdit window with only untitled buffers offers Open Project via the folder picker.

Closes #3426

Co-authored-by: panda-student <[email protected]>
2026-11-15 04:32:12 +08:00
l0ng-ai 023c0a6ee6 fix(debugger): silence the unix-only runInTerminal pid on Windows (#3456)
ProgramTerminal's pid and process_id are read only by the unix process-group
cleanup, so Windows clippy failed with dead-code warnings.
2026-11-15 04:11:35 +08:00
rohitkumardandaтаrohitdanda c5481e8fe4 fix(lsp): language servers that give up, start slowly or send bad data (#3449)
Several causes behind servers that ended up absent or stuck:
- Fallback servers now take over when the preferred one gives up (missing, not launchable, crashed for good, expired), not only when it is missing, and hand back after a manual restart.
- Crash-budget uptime counts from ready, not from launch; the crash message carries why initialize failed (timeout or the server's error).
- Restart works while a server is still starting and no longer holds the reconfigure lock while it waits; a server dropped during its crash backoff no longer comes back.
- Progress of a still-starting server is watched.
- One malformed diagnostic no longer drops the file's other problems; DocumentLink.target URIs are escaped.
- Semantic tokens, inlay hints and code lens are asked again on ContentModified (staleRequestSupport).
- rustup toolchain lookup uses the platform executable suffix and USERPROFILE, and probes in the project root with a timeout.
- Server output lines are capped at 8 KiB.
- Go to Declaration from Java to a Kotlin class declared in a differently named file finds the .kt source.
- The kotlin-lsp install hint names the Language Servers settings page.

Co-authored-by: rohitdanda <[email protected]>
2026-11-15 04:10:44 +08:00
Deng Liuтаdengliu dfacb45575 fix(agents): sanitize unsupported ambient GitHub tokens for Copilot CLI (#3443)
Copilot CLI rejects a classic PAT (ghp_…) in GITHUB_TOKEN / GH_TOKEN / COPILOT_GITHUB_TOKEN outright instead of falling back to its stored login, and a stale or narrow-scope token from the login shell made model discovery fail with "Not authenticated". Classic PATs inherited from the shell are now cleared for copilot; when discovery still fails with an authentication error and such a token is inherited, it retries once without the ambient tokens and keeps doing so for later launches. Tokens set in the agent's own environment settings are kept.

Co-authored-by: dengliu <[email protected]>
2026-11-15 04:07:51 +08:00
Mr0Bread 3363bb8a46 feat(agents): resize the task Changes pane by dragging its edge (#3451)
The Changes pane beside a task's conversation was fixed at 280 px. Its left edge is now a separator that drags (or moves with ←/→ when focused); the width applies to every task, is remembered across restarts, and always leaves the conversation 240 px (half of a narrower session). In the tool window the Changes view fills the window as before.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 04:05:17 +08:00
Mr0Bread 39f34ec22f fix(agents): clicking the bottom of the message box puts the caret in it (#3450)
The composer's 44 px minimum height sat on CodeMirror's scroller, while the text inside stopped at 32 px (its min-height: 100% doesn't resolve against a min-height parent), so a click in the strip below focused the bare scroller and showed no caret. The minimum height now sits on .cm-content.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 04:05:07 +08:00
Taylor BurkeтаTaylorBurke 34ae8acbf8 fix(window): lower the minimum window width to 400 px and let the UI fit it (#3440)
Tiling compositors ignore a client's minimum size, so a tile narrower than 800 px laid the webview out at 800 px and scaled it down. The minimum is now 400 px on every platform. At that width the welcome page header and action tiles wrap, the title bar's run and VCS widgets ellipsize instead of covering the toolbar, dialog footers wrap, and below 560 px the left and right tool windows collapse (their stripe buttons stay, the saved layout is unchanged, a wider window brings them back).

Closes #3046

Co-authored-by: TaylorBurke <[email protected]>
2026-11-15 04:02:55 +08:00
l0ng-ai 6bafcb2b5c fix(i18n): translate the memory popup's PID tooltip in Chinese (#3453)
The PID tooltips from #3437 kept the English text in zh.json, which the i18n catalog test rejects; they now read 进程号 {pid}.
2026-11-15 03:58:02 +08:00
88 змінених файлів з 2622 додано та 201 видалено

No files matched your search

+20 -1
Переглянути файл
@@ -17,11 +17,30 @@ 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(&root)
move || launch.resolve_for_launch_with_copilot_env(&root, strip_ambient)
})
.await
.map_err(|e| e.to_string())?;
+91 -1
Переглянути файл
@@ -120,10 +120,18 @@ impl ProviderSpec {
}
/// Resolve once before connecting, so authentication guidance names that same install.
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
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 {
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 }
@@ -133,6 +141,69 @@ 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);
@@ -1101,4 +1172,23 @@ 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())]
);
}
}
+2
Переглянути файл
@@ -883,6 +883,7 @@ 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>,
@@ -895,6 +896,7 @@ 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 }
}
+22
Переглянути файл
@@ -21,6 +21,28 @@ 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)
+4 -1
Переглянути файл
@@ -50,7 +50,10 @@ pub async fn doc_set_encoding(
reload: bool,
) -> IdeResult<DocumentEncoding> {
let project = host.project(window)?;
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
if reload
&& !project.documents.is_untitled(doc)?
&& 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 {
+38 -3
Переглянути файл
@@ -88,6 +88,9 @@ 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
@@ -621,6 +624,9 @@ 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);
@@ -703,12 +709,29 @@ 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
{
current.watch_light_dir(&dir);
if let Some(dir) = watch_dir {
current.watch_light_dir(dir);
}
return Ok(current);
}
self.close_project(window);
@@ -727,7 +750,16 @@ 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 = light_watcher(&dir, &sink, &documents, &history)?;
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 project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings, &sink);
let notebooks = crate::notebook::NotebookService::new(
@@ -773,8 +805,11 @@ impl Host {
light: true,
root: dir,
});
tracing::info!("window {window} edits {} without a project", file.display());
tracing::info!("window {window} edits {} without a project", project.root.display());
self.insert_project(window, project.clone());
for (key, text, dirty) in &carried {
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
}
Ok(project)
}
}
+7
Переглянути файл
@@ -218,6 +218,13 @@ 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
})
+1
Переглянути файл
@@ -401,6 +401,7 @@ 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"
+70
Переглянути файл
@@ -179,3 +179,73 @@ 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;
}
+48 -4
Переглянути файл
@@ -80,18 +80,47 @@ 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 {
line.clear();
match reader.read_until(b'\n', &mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => on_line(decode(trim_line_end(&line))),
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);
}
}
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)]
@@ -133,4 +162,19 @@ 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);
}
}
+56
Переглянути файл
@@ -0,0 +1,56 @@
; 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
+1 -1
Переглянути файл
@@ -314,7 +314,7 @@ pub(crate) static SPECS: &[Spec] = &[
name: "bash",
language: tree_sitter_bash::LANGUAGE,
outline: outline!("bash"),
highlights: None,
highlights: Some(Highlights { query: highlights!("bash"), first_wins: false }),
fold_nodes: &["do_group", "if_statement", "case_statement", "heredoc_body"],
fold_bodies: NONE,
imports: NONE,
+29 -1
Переглянути файл
@@ -43,11 +43,13 @@ 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; 25] = [
pub const ALL: [Self; 26] = [
Self::Keyword,
Self::String,
Self::StringSpecial,
@@ -73,6 +75,7 @@ impl SyntaxHighlightKind {
Self::Operator,
Self::Punctuation,
Self::Parameter,
Self::Command,
];
/// The kebab-case name plugins use (`doc-comment`, `function-call`, …).
@@ -153,6 +156,7 @@ 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)),
@@ -408,6 +412,30 @@ 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 [
+36 -6
Переглянути файл
@@ -41,20 +41,44 @@ 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.running_gen(self.generation).map(|r| r.caps.read().encoding).unwrap_or_default()
self.server.initialized_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.running_gen(generation)
if let Some(run) = server.initialized_gen(generation)
&& let Err(e) = run.peer.notify("window/workDoneProgress/cancel", json!({ "token": token }))
{
tracing::debug!("{}: cannot cancel progress: {e}", server.name);
@@ -176,7 +200,7 @@ impl ClientHandler {
loop {
tokio::time::sleep(PROGRESS_SWEEP).await;
let Some(mgr) = weak.upgrade() else { return };
if server.running_gen(generation).is_none() || !server.has_progress(&token) {
if !server.is_live(generation) || !server.has_progress(&token) {
return; // the instance is gone, or the task ended normally
}
if server.progress_silent(&token, PROGRESS_STALE) {
@@ -251,18 +275,24 @@ impl ClientHandler {
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
// Problems come only from servers used for `diagnostics`.
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
if self.server.initialized_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
return;
}
let mut params = params;
crate::virtual_doc::normalize_uris(&mut params);
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(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) {
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 {
@@ -480,7 +510,7 @@ impl Handler for ClientHandler {
crate::kotlin_lsp::record_import_state(&self.server, self.generation, &params)
}
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
if self.server.running_gen(self.generation).is_some() {
if self.server.initialized_gen(self.generation).is_some() {
self.server.set_java_ready();
}
mgr.java_import_ready(self.server.clone(), self.generation);
+75 -25
Переглянути файл
@@ -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))
.and_then(|b| resolve_rustup_proxy(&config.command, b, root))
.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,34 +208,16 @@ 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).
fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option<PathBuf> {
/// 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> {
let is_proxy = binary.parent().is_some_and(|d| d.ends_with(".cargo/bin"));
if !is_proxy || command.contains('/') {
return Some(binary);
}
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 {
if proxy_works(&binary, root) {
return Some(binary);
}
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();
let mut candidates = rustup_home().map(|h| toolchain_binaries(&h.join("toolchains"), command)).unwrap_or_default();
// 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() {
@@ -250,6 +232,57 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option<PathBuf> {
}
}
/// 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.
@@ -363,7 +396,24 @@ 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()), Some(p));
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());
}
/// Exercises the real machine setup when a broken rustup proxy is present.
@@ -372,7 +422,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()).unwrap();
let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone(), Path::new(".")).unwrap();
eprintln!("resolved: {}", resolved.display());
assert_ne!(resolved, proxy);
}
+94 -36
Переглянути файл
@@ -343,6 +343,10 @@ 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;
@@ -654,8 +658,9 @@ impl LspManager {
}
/// The servers serving `path`: every regular one, and each `fallback` one
/// 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.
/// 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.
fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec<Arc<Server>> {
if let Some(servers) = self.arduino_servers(language, path) {
return servers;
@@ -672,7 +677,7 @@ impl LspManager {
continue;
}
let server = self.server_for(sc, language, path);
available_before |= !sc.require_root_marker && server.status() != ide_core::event::ServerStatus::Missing;
available_before |= !sc.require_root_marker && !server.gave_up();
out.push(server);
}
out
@@ -748,9 +753,14 @@ 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 || !self.servers.lock().iter().any(|s| s.id == server.id) {
if state.transitioning {
return;
}
let (shared, roots) = self.desired_jdtls_workspace();
@@ -861,24 +871,28 @@ impl LspManager {
}
Err(LaunchError::Config(message)) => {
tracing::warn!("{}: {message}", server.name);
server.fail_start(
if 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);
server.fail_start(
if server.fail_start(
generation,
Conn::Failed,
&self.sink,
ide_core::event::ServerStatus::Crashed,
Some(message),
);
) {
self.attach_fallbacks();
}
return;
}
};
@@ -920,9 +934,18 @@ impl LspManager {
}
Err(e) => {
tracing::warn!("{}: initialize failed: {e}", server.name);
if matches!(e, RpcError::Closed) {
match e {
// The server is exiting: let it, so the crash report has its exit code and last output.
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
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})")),
}
Self::kill_peer(&server, &peer).await;
// Treated like a crash so transient failures retry with backoff.
@@ -1008,11 +1031,15 @@ impl LspManager {
self.forget(std::slice::from_ref(&server));
return;
}
if let Some(delay) = delay {
tokio::time::sleep(delay).await;
if server.is_idle_gen(generation) {
self.ensure_started(&server);
match delay {
Some(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(),
}
}
@@ -1458,6 +1485,28 @@ 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,
@@ -2892,11 +2941,10 @@ impl LspManager {
async move {
(
t,
self.call::<_, Option<Vec<lsp::InlayHint>>>(
self.call_retrying::<_, Option<Vec<lsp::InlayHint>>>(
t,
"textDocument/inlayHint",
params,
None,
DEFAULT_TIMEOUT,
)
.await,
@@ -2927,7 +2975,8 @@ 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(t, "textDocument/semanticTokens/full", params, None, DEFAULT_TIMEOUT).await?;
let r: Option<Value> =
self.call_retrying(t, "textDocument/semanticTokens/full", params, 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) = {
@@ -2948,7 +2997,7 @@ impl LspManager {
let params = json!({ "textDocument": snap.ident() });
async move {
let lenses = self
.call::<_, Option<Vec<lsp::CodeLens>>>(t, "textDocument/codeLens", params, None, DEFAULT_TIMEOUT)
.call_retrying::<_, Option<Vec<lsp::CodeLens>>>(t, "textDocument/codeLens", params, DEFAULT_TIMEOUT)
.await?;
let lenses = lenses.unwrap_or_default();
if !t.caps().has_sub("codeLensProvider", "resolveProvider") {
@@ -3876,26 +3925,35 @@ impl LspManager {
}
pub async fn restart(&self, server: ide_core::ServerId) -> IdeResult<()> {
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);
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
};
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))))
}
+72 -11
Переглянути файл
@@ -136,7 +136,8 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
return Some((entry.clone(), text));
}
}
// A facade renamed with `@file:JvmName("…")`.
// A facade renamed with `@file:JvmName("…")`, or a class declared in a file named otherwise
// (`UserDto` in `Models.kt`).
let kotlin_files: Vec<String> = zip
.file_names()
.filter_map(|n| n.ok().map(|n| n.into_owned()))
@@ -145,7 +146,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)
&& (text.text.contains(&jvm_name) || declares_type(&text.text, outer))
{
return Some((entry, text));
}
@@ -153,6 +154,26 @@ 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 {
@@ -245,6 +266,22 @@ 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.
@@ -255,19 +292,24 @@ 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> = 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()))
let mut found: Vec<PathBuf> = walk_sources(root)
.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();
@@ -358,6 +400,10 @@ 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"), &[]);
@@ -373,6 +419,11 @@ 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) =
@@ -385,6 +436,7 @@ 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");
@@ -513,6 +565,11 @@ 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())
};
@@ -523,6 +580,10 @@ 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"), "");
}
+4 -7
Переглянути файл
@@ -166,13 +166,10 @@ 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,
_ => 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;
}
},
_ => Some(crate::handler::lenient_diagnostics(
&t.server.name,
report.get("items").cloned().unwrap_or(json!([])),
)),
};
let shown = t.server.set_pulled(t.path.clone(), result_id, items);
let enc = t.run.caps.read().encoding;
+66 -6
Переглянути файл
@@ -112,8 +112,11 @@ pub struct Server {
status: Mutex<(ServerStatus, Option<String>)>,
/// Consecutive crashes of short-lived instances.
crashes: Mutex<usize>,
/// When the current instance was launched.
/// 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).
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.
@@ -177,6 +180,7 @@ 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()),
@@ -257,7 +261,8 @@ impl Server {
st.conn = Conn::Starting;
st.restarting = false;
*self.start_cancel.lock() = CancellationToken::new();
*self.started.lock() = Some(Instant::now());
*self.started.lock() = None;
*self.start_error.lock() = None;
// A crash report quotes only this instance's output, not its predecessors'.
self.stderr.lock().clear();
self.import_state.send_replace(None);
@@ -305,9 +310,15 @@ 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();
@@ -327,6 +338,13 @@ 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())
@@ -351,6 +369,13 @@ 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();
@@ -441,10 +466,12 @@ impl Server {
}
let tail = self.stderr_tail(5);
let cfg = self.effective_config();
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),
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),
};
if !tail.is_empty() {
message.push_str(":\n");
@@ -477,6 +504,7 @@ 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();
@@ -1039,6 +1067,38 @@ 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();
+17 -1
Переглянути файл
@@ -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")
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri" | "target")
&& let Some(uri) = value.as_str().and_then(escaped_uri)
{
*value = uri.into();
@@ -247,6 +247,22 @@ 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]
+181
Переглянути файл
@@ -146,6 +146,9 @@ 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),
@@ -2073,6 +2076,29 @@ 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())]);
@@ -2100,6 +2126,118 @@ 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")]
@@ -2998,6 +3136,49 @@ 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
+200 -2
Переглянути файл
@@ -193,6 +193,12 @@ 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 {
@@ -529,6 +535,75 @@ 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();
@@ -740,6 +815,90 @@ 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(&reg, &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();
@@ -894,9 +1053,12 @@ 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() {
if st.uri.is_some() && !is_untitled(&st.path) {
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 {
@@ -937,6 +1099,42 @@ 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,
@@ -967,7 +1165,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.uri.is_some() || st.read_only {
if st.read_only || (st.uri.is_some() && !is_untitled(&st.path)) {
return Err(read_only_error(&st.path));
}
if st.encoding != "utf-8" || st.native_to_ascii {
+101
Переглянути файл
@@ -1004,3 +1004,104 @@ 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");
}
+2
Переглянути файл
@@ -88,6 +88,8 @@ 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**.
+2
Переглянути файл
@@ -88,6 +88,8 @@ agent 运行过一个任务后,新任务会以它自动批准所有操作的
在任务标题栏中单击 **显示更改**,列出任务改动过的文件;单击文件可以打开它从任务开始到现在的 diff。worktree 任务的改动在它的 `agent/…` 分支上:用 **在终端中打开** 在那里打开终端,满意后用 Git 工具合并或 cherry-pick 这个分支(见 [版本控制](/zh/guide/vcs))。
拖动改动面板的左边缘可以调整宽度。宽度对所有任务生效,重启后保留。
### 从编辑器提供上下文
- 编辑器和项目视图右键菜单中的 **发送给 Agent** 会把选区或选中的文件作为 `@` 引用加到你最近输入的消息中。它默认没有快捷键,可以在 **设置 › 按键映射** 中分配。
+69
Переглянути файл
@@ -0,0 +1,69 @@
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");
});
+47 -21
Переглянути файл
@@ -88,6 +88,30 @@ 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 });
}
@@ -345,27 +369,7 @@ 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 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 seedChanges(page);
await page.getByRole("button", { name: /1 changed file/ }).click();
const changes = page.getByRole("complementary", { name: "Changes", exact: true });
await expect(changes).toBeVisible();
@@ -377,3 +381,25 @@ 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));
});
+1 -1
Переглянути файл
@@ -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: brew install JetBrains/utils/kotlin-lsp (https://github.com/Kotlin/kotlin-lsp)"
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"
[[language-servers]]
name = "kotlin-language-server"
+1
Переглянути файл
@@ -9,6 +9,7 @@
"--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",
+2 -1
Переглянути файл
@@ -332,7 +332,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
266 themeable tokens: 125 in the UI theme file, 141 in the editor colour scheme file.
267 themeable tokens: 125 in the UI theme file, 142 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -429,6 +429,7 @@ 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` |
+27
Переглянути файл
@@ -119,6 +119,33 @@ 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)
}
+25
Переглянути файл
@@ -26,6 +26,31 @@ 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]
+3
Переглянути файл
@@ -106,6 +106,7 @@ 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,
@@ -198,9 +199,11 @@ 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,
+1
Переглянути файл
@@ -217,6 +217,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
}
let mut file = vec![
act("file.newUntitled", "New Untitled File"),
sub(
"new",
"New",
+6
Переглянути файл
@@ -170,6 +170,12 @@ 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) {
+1 -1
Переглянути файл
@@ -17,7 +17,7 @@
"title": "OpenIDEA",
"width": 1440,
"height": 900,
"minWidth": 800,
"minWidth": 400,
"minHeight": 500,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
+1 -1
Переглянути файл
@@ -7,7 +7,7 @@
"title": "OpenIDEA",
"width": 1440,
"height": 900,
"minWidth": 800,
"minWidth": 400,
"minHeight": 500,
"decorations": false,
"backgroundColor": "#E9EAEE",
+1 -1
Переглянути файл
@@ -8,7 +8,7 @@
"width": 1440,
"height": 900,
"center": true,
"minWidth": 800,
"minWidth": 400,
"minHeight": 500,
"decorations": false,
"backgroundColor": "#E9EAEE",
+18 -1
Переглянути файл
@@ -75,6 +75,11 @@ 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,
@@ -325,6 +330,11 @@ 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
@@ -332,6 +342,11 @@ 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 })),
@@ -10182,7 +10197,9 @@ 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";
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";
/** Highlighted ranges of a whole document, packed like LSP semantic tokens. */
export type SyntaxHighlights = {
+9
Переглянути файл
@@ -532,6 +532,15 @@ 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 =
+55
Переглянути файл
@@ -41,6 +41,10 @@ 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>;
@@ -79,6 +83,8 @@ 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))),
@@ -384,6 +390,14 @@ 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,
@@ -504,6 +518,47 @@ 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);
+4 -2
Переглянути файл
@@ -593,8 +593,10 @@ describe("Angular templates", () => {
moduleStyle: "es",
typeScript: true,
});
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
// 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")));
});
it("is the Angular Template mode", async () => {
+4 -2
Переглянути файл
@@ -153,8 +153,10 @@ describe("Astro components", () => {
moduleStyle: "es",
typeScript: true,
});
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
// 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")));
});
it("is the Astro mode", async () => {
+4 -2
Переглянути файл
@@ -168,8 +168,10 @@ describe("Django template grammar", () => {
moduleStyle: "es",
typeScript: true,
});
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
// 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")));
});
});
+3
Переглянути файл
@@ -9,6 +9,8 @@ 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);
@@ -60,6 +62,7 @@ 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)),
+4 -2
Переглянути файл
@@ -237,8 +237,10 @@ describe("JSP pages", () => {
moduleStyle: "es",
typeScript: true,
});
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
// 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")));
});
it("is the JSP mode", async () => {
+9 -1
Переглянути файл
@@ -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 } from "./highlight";
import { openideaHighlightStyle, parameter, shellCommand } from "./highlight";
import { findLanguageDescription, loadSupport } from "./languages";
import { setSyntaxHighlights, syntaxHighlightField } from "./syntaxHighlight";
@@ -46,6 +46,14 @@ 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({
+3 -2
Переглянути файл
@@ -1,6 +1,6 @@
/**
* Tree-sitter highlighting (`doc_highlights`) for languages without a
* CodeMirror mode (Elixir, ...) or a limited stream mode (Kotlin): backend kinds → Lezer tags, so the colours
* CodeMirror mode (Elixir, ...) or a limited stream mode (Kotlin, Shell): 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 } from "./highlight";
import { parameter, shellCommand } from "./highlight";
import type { SyntaxHighlightKind, SyntaxHighlights } from "@/platform/ipc";
const TAGS: Record<SyntaxHighlightKind, Tag> = {
@@ -38,6 +38,7 @@ const TAGS: Record<SyntaxHighlightKind, Tag> = {
tag: t.tagName,
operator: t.operator,
punctuation: t.punctuation,
command: shellCommand,
};
export const setSyntaxHighlights = StateEffect.define<SyntaxHighlights>();
+199 -7
Переглянути файл
@@ -11,10 +11,11 @@ import { create } from "zustand";
import { setContext } from "@/platform/context";
import { shortcutLabel } from "@/platform/keymap";
import { isSameOrInside } from "@/platform/paths";
import { basename, useProject } from "@/platform/project";
import { basename, dirname, useProject } from "@/platform/project";
import { editorPrefs } from "@/platform/editorSettings";
import {
_emitEditorEvent,
_setEditorSwitching,
_provideNavigationHistory,
_provideOpenEditorPaths,
_setActiveEditor,
@@ -44,8 +45,10 @@ import {
import { useDistractionFree } from "@/workbench/distractionFree";
import { restoreMaximizedToolWindow } from "@/workbench/toolwindows/store";
import { ask, confirm } from "@/workbench/messages";
import { t } from "@/platform/i18n";
import { t, tx } 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";
@@ -132,9 +135,16 @@ export const virtualInput = (
data: { title: vd.title, description: vd.description, decompiled: vd.decompiled } satisfies VirtualInputData,
});
/** Inputs shown in the text editor: files and virtual documents. */
/**
* 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. */
export function isTextInput(input: EditorInput): input is EditorInput & { path: string } {
return (input.kind === "file" || input.kind === "virtual") && !!input.path;
return (input.kind === "file" || input.kind === "virtual" || input.kind === "untitled") && !!input.path;
}
export const useTabs = create<TabsState>(() => initialTabsState());
@@ -159,6 +169,29 @@ 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. */
@@ -176,13 +209,22 @@ export function openFilePaths(s: TabsState): string[] {
/** Editor Tabs › Sort tabs alphabetically: by file name. */
function tabSortKey(input: EditorInput): string {
return basename(input.path ?? input.key);
return basename(untitledTitle(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 {
@@ -196,6 +238,7 @@ 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);
@@ -208,6 +251,7 @@ 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 {
@@ -273,6 +317,7 @@ export class EditorController implements SlotHost {
{
settings: () => useProject.getState().settings ?? useProject.getState().project?.settings,
notify: (n) => notifications().notify(n),
saveAs: (model) => this.saveUntitled(model),
},
scheduler,
);
@@ -330,14 +375,45 @@ 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();
@@ -426,6 +502,7 @@ 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);
@@ -524,6 +601,8 @@ 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();
@@ -1128,6 +1207,86 @@ 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,
@@ -1450,6 +1609,36 @@ 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;
@@ -1840,8 +2029,11 @@ export class EditorController implements SlotHost {
if (errors.length) throw combinedError(errors);
}
async saveAll(): Promise<void> {
const results = await Promise.allSettled([this.manager.saveAll("explicit"), this.saveCustomDocuments()]);
async saveAll(opts?: { skipUntitled?: boolean }): Promise<void> {
const results = await Promise.allSettled([
this.manager.saveAll("explicit", opts?.skipUntitled),
this.saveCustomDocuments(),
]);
const errors = failures(results);
if (errors.length) throw combinedError(errors);
}
+5 -1
Переглянути файл
@@ -11,6 +11,7 @@ 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";
@@ -42,10 +43,13 @@ 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: () => ctrl.saveAll(),
saveAll: (opts) => ctrl.saveAll(opts),
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(),
+83 -12
Переглянути файл
@@ -26,7 +26,7 @@ import {
useEditorRegistry,
type EditorDocContext,
} from "@/editor/api";
import { isVirtualUri } from "@/platform/virtualDocs";
import { isUntitledUri, isVirtualUri } from "@/platform/virtualDocs";
import { DocSync, type Scheduler, frameScheduler } from "../sync/DocSync";
import type { DocBackend, LanguageInfo } from "../backend";
import {
@@ -98,6 +98,11 @@ 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;
@@ -204,7 +209,7 @@ export function saveCleanupChanges(
export class DocModel {
readonly doc: DocId;
/** File path, or the URI of a virtual (read-only, language-server provided) document. */
readonly path: string;
path: string;
readonly virtual: boolean;
readonly sync: DocSync;
readonly slots = new Set<EditorSlot>();
@@ -234,7 +239,7 @@ export class DocModel {
) {
this.doc = info.id;
this.path = info.path;
this.virtual = !!info.uri;
this.virtual = !!info.uri && !isUntitledUri(info.path);
this.info = languageInfo(info.language);
this.editorconfig = info.editorconfig ?? null;
// `large`: the backend skips LSP and syntax trees for it; heavy UI features follow.
@@ -574,8 +579,7 @@ export class DocModel {
const [folds, outline, highlights] = await Promise.allSettled([
backend.folding(this.doc),
backend.outline(this.doc),
// Kotlin's stream mode lacks declaration categories; the grammar supplements it.
!this.desc || this.meta.language === "kotlin" ? backend.highlights(this.doc) : null,
!this.desc || GRAMMAR_SUPPLEMENTED.has(this.meta.language) ? backend.highlights(this.doc) : null,
]);
if (this.closed) return;
if (counter !== this.changeCounter) {
@@ -668,6 +672,14 @@ 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 });
@@ -677,6 +689,31 @@ 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);
@@ -687,6 +724,11 @@ 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 {
@@ -749,6 +791,29 @@ 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);
@@ -764,7 +829,11 @@ export class DocumentManager {
})
.catch(() => {});
const [info] = await Promise.all([
isVirtualUri(path) ? this.backend.openVirtual(path) : this.backend.open(path),
isUntitledUri(path)
? this.backend.openUntitled(path)
: isVirtualUri(path)
? this.backend.openVirtual(path)
: this.backend.open(path),
catalog,
]);
const raced = this.byPath.get(path);
@@ -829,14 +898,16 @@ export class DocumentManager {
}
/** Saves dirty documents; failures are notified, and an explicit Save All also rejects with them. */
async saveAll(reason: "explicit" | "auto") {
async saveAll(reason: "explicit" | "auto", skipUntitled = false) {
const errors: unknown[] = [];
await Promise.all(
[...this.byPath.values()].map((m) =>
m.save(reason).catch((e) => {
errors.push(e);
}),
),
[...this.byPath.values()]
.filter((m) => !(skipUntitled && isUntitledUri(m.path)))
.map((m) =>
m.save(reason).catch((e) => {
errors.push(e);
}),
),
);
if (errors.length) {
const e = errors[0];
+171
Переглянути файл
@@ -0,0 +1,171 @@
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);
});
});
+6 -1
Переглянути файл
@@ -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 { isVirtualUri, invalidateVirtualDocument } from "@/platform/virtualDocs";
import { isUntitledUri, isVirtualUri, invalidateVirtualDocument, untitledTitle } from "@/platform/virtualDocs";
import { editorController, isTextInput, useEditorStatus, useGotoLine, useTabs } from "../controller";
import { useDocMeta, useOutline } from "../model/document";
import { symbolChain } from "./outline";
@@ -76,6 +76,11 @@ 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;
+11
Переглянути файл
@@ -262,3 +262,14 @@ 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();
});
+85
Переглянути файл
@@ -0,0 +1,85 @@
/**
* 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}
/>
);
}
+3 -1
Переглянути файл
@@ -36,13 +36,15 @@
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;
+28 -3
Переглянути файл
@@ -30,8 +30,10 @@
.docked .conversation {
border-right: 0;
}
.docked .changes {
width: auto;
/* 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));
}
.providerIcon {
@@ -1133,11 +1135,30 @@
}
.changes {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
width: 280px;
}
/* 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);
}
.changesHeader {
min-width: 0;
@@ -1157,6 +1178,10 @@
font-weight: 600;
color: var(--fg-strong);
}
.changesHeader > .muted {
flex: none;
white-space: nowrap;
}
.changesSummary {
flex: none;
overflow: hidden;
+5 -1
Переглянути файл
@@ -62,6 +62,7 @@ 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";
@@ -156,6 +157,7 @@ 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;
@@ -229,7 +231,8 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
return (
<div
ref={sessionRoot}
className={clsx(styles.session, docked && styles.docked)}
className={clsx(styles.session, docked && styles.docked, changesOpen && !docked && styles.withChanges)}
style={changesWidth}
onFocusCapture={() => shown && setFocusedSession(session)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedSession() === session) {
@@ -3151,6 +3154,7 @@ 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}>
+11 -1
Переглянути файл
@@ -4,10 +4,19 @@ 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 };
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)));
}
const KEY = "openidea.agents.changesView.v1";
@@ -17,6 +26,7 @@ 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 };
+8 -3
Переглянути файл
@@ -12,12 +12,17 @@ export function LightEditStatus() {
const t = useT();
const lightEdit = useProject((s) => s.lightEdit);
const path = useEditorRegistry((s) => s.active?.ctx.path);
if (!lightEdit || !path || isVirtualUri(path)) return null;
if (!lightEdit) return null;
const hasRealFile = !!path && !isVirtualUri(path);
return (
<BarButton
emphasis
tooltip={t("app.lightEdit.openProjectTooltip", { folder: dirname(path) })}
onClick={() => void executeAction(LIGHT_EDIT_OPEN_PROJECT)}
tooltip={
hasRealFile
? t("app.lightEdit.openProjectTooltip", { folder: dirname(path) })
: t("app.lightEdit.pickFolderTooltip")
}
onClick={() => void executeAction(hasRealFile ? LIGHT_EDIT_OPEN_PROJECT : "file.openProject")}
>
<Icon name="folder-open" size={14} />
{t("app.lightEdit.openProject")}
+9 -2
Переглянути файл
@@ -279,6 +279,13 @@ 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,
@@ -286,10 +293,10 @@ export async function confirmLeave(
confirmExit: false,
confirmTerminate,
processes: confirmTerminate ? await processNames(deps) : [],
dirty: dirtyCount(),
dirty: dirtyCount() - untitled.size,
project,
},
deps,
saveDeps,
);
}
+19 -1
Переглянути файл
@@ -15,6 +15,7 @@ import {
confirmLeaveProject,
dirname,
openLightEdit,
openLightEditUntitled,
openProject,
provideStartupLaunch,
useProject,
@@ -26,6 +27,7 @@ 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";
@@ -480,6 +482,20 @@ 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",
@@ -578,7 +594,9 @@ async function closeThisWindow(): Promise<void> {
export function installLightEditAutoClose(closeWindow: () => Promise<void> = closeThisWindow): () => void {
const editor = maybeEditorService();
const off = editor?.onActiveInputChange?.((input) => {
if (input || !useProject.getState().lightEdit || editor?.openTabs?.().length) return;
// 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;
void closeWindow().catch((e) => console.warn("could not close the window", e));
});
return () => off?.();
+7
Переглянути файл
@@ -38,4 +38,11 @@ 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();
});
});
-1
Переглянути файл
@@ -88,7 +88,6 @@ 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)}
/>
</>
+1
Переглянути файл
@@ -198,6 +198,7 @@ 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"]],
+3
Переглянути файл
@@ -301,9 +301,11 @@
.widget {
display: flex;
align-items: center;
min-width: 0;
gap: 4px;
}
.widgetChooser {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 4px;
@@ -376,6 +378,7 @@
flex: none;
}
.widgetButtons {
flex: none;
display: flex;
align-items: center;
gap: 2px;
+18
Переглянути файл
@@ -33,6 +33,9 @@ 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>
@@ -45,6 +48,9 @@ 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>
@@ -81,6 +87,12 @@ 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"
@@ -105,6 +117,12 @@ 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}>
+38 -12
Переглянути файл
@@ -86,6 +86,19 @@
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 {
@@ -125,19 +138,23 @@
flex: none;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
height: 76px;
min-height: 76px;
margin-left: 14px;
padding: 0 14px 0 6px;
padding: 12px 14px 12px 6px;
box-shadow: inset 0 -1px 0 var(--border);
}
.search {
flex: 1;
flex: 1 1 160px;
min-width: 0;
}
/* Wraps in a narrow window, so no button is clipped. */
.headerButtons {
flex: none;
flex: 0 1 auto;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.projectList {
@@ -340,11 +357,18 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding-bottom: 40px;
padding: 0 16px 40px;
overflow-y: auto;
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;
@@ -355,16 +379,18 @@
color: var(--fg-muted);
line-height: 20px;
}
/* Equal columns (as wide as the longest label) keep the icon spacing uniform
regardless of each label's length. */
/* Equal-width actions keep the icon spacing uniform regardless of each label's
length, and wrap onto further rows in a narrow window. */
.landingActions {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 24px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px 24px;
margin-top: 16px;
}
.landingAction {
flex: none;
width: 104px;
display: flex;
flex-direction: column;
align-items: center;
+1
Переглянути файл
@@ -364,6 +364,7 @@ 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" }),
+3
Переглянути файл
@@ -7,10 +7,13 @@
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;
+2 -1
Переглянути файл
@@ -182,7 +182,8 @@ export const vscodeMacos: Record<string, string[]> = {
// File / editor tabs
"file.save": ["Meta+S"],
"file.new": ["Meta+N"],
"file.newUntitled": ["Meta+N"],
"file.new": [],
"file.settings": ["Meta+,"],
"file.projectStructure": [],
"file.openProject": ["Meta+O"],
+5
Переглянути файл
@@ -19,6 +19,7 @@
"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…",
@@ -49,6 +50,7 @@
"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}?",
@@ -60,8 +62,10 @@
"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",
@@ -199,6 +203,7 @@
"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…",
+1
Переглянути файл
@@ -1431,6 +1431,7 @@
"{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",
+5 -2
Переглянути файл
@@ -783,6 +783,7 @@
"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": "无法打开链接",
@@ -1439,6 +1440,7 @@
"Hide Active Tool Window": "隐藏活动工具窗口",
"Hide All Tool Windows": "隐藏所有工具窗口",
"Hide changes": "隐藏更改",
"Resize changes": "调整更改面板宽度",
"Hide Coverage": "隐藏覆盖率",
"Hide Library Frames": "隐藏库帧",
"Hide Replace": "隐藏替换",
@@ -2501,6 +2503,7 @@
"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": "切换到其他应用程序或内置终端时保存文件",
@@ -8161,8 +8164,8 @@
"{name} (copy {n})": "{name}(副本 {n})",
"New Project Library": "新建项目库",
"Copy Library": "复制库",
"PID {pid}": "PID {pid}",
"PID {pid} · {count} processes": "PID {pid} · {count} 个进程",
"PID {pid}": "进程号 {pid}",
"PID {pid} · {count} processes": "进程号 {pid} · {count} 个进程",
"End Process {label}": "结束进程 {label}",
"Diff too large: a side is over {max} MiB": "差异过大:有一侧超过 {max} MiB"
}
+5
Переглянути файл
@@ -19,6 +19,7 @@
"app.action.cut": "剪切",
"app.action.keymapReference": "快捷键参考",
"app.action.lightEditOpenProject": "将所在文件夹作为项目打开",
"app.action.newFile": "新建无标题文件",
"app.action.newWindow": "新建窗口",
"app.action.nextWindow": "下一个项目窗口",
"app.action.openFile": "打开文件…",
@@ -49,6 +50,7 @@
"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}?",
@@ -60,8 +62,10 @@
"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": "取消",
@@ -199,6 +203,7 @@
"menu.workspace.create": "工作区…",
"menu.workspace.open": "打开工作区…",
"menu.workspace.manage": "管理工作区…",
"menu.file.newUntitled": "新建无标题文件",
"menu.file.openInNewWindow": "在新窗口中打开…",
"menu.file.openFile": "打开文件…",
"menu.remote.connect": "远程开发…",
+6
Переглянути файл
@@ -79,6 +79,12 @@ 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 });
+12
Переглянути файл
@@ -13,6 +13,18 @@ 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]!;
+4
Переглянути файл
@@ -114,6 +114,7 @@ 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;
@@ -558,6 +559,7 @@ 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;
@@ -856,6 +858,7 @@ 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;
@@ -1244,6 +1247,7 @@ 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);
+4 -1
Переглянути файл
@@ -196,9 +196,12 @@
gap: 12px;
}
.buttons {
flex: none;
flex: 0 1 auto;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-left: auto;
}
+2 -1
Переглянути файл
@@ -16,8 +16,9 @@ const HINTS: Hint[] = [
{ label: "Drop files here to open them" },
];
/** LightEdit has no project to navigate: only opening files works there. */
/** LightEdit has no project to navigate: only opening and creating 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" },
];
+26 -1
Переглянути файл
@@ -67,6 +67,7 @@
}
/* Main toolbar dropdown widgets. */
.widget {
min-width: 0;
height: 30px;
margin: 0;
padding: 0 8px;
@@ -161,7 +162,6 @@
height: var(--stripe-icon-button);
overflow: hidden;
}
.mainMenuSlot ~ .item,
.mainMenuSlot ~ .tools {
flex-shrink: 0;
}
@@ -236,3 +236,28 @@
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;
}
}
+3 -1
Переглянути файл
@@ -7,6 +7,7 @@
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";
@@ -42,9 +43,10 @@ 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 ?? (path ? basename(path) : "LightEdit")} />
<TitleBar lightEdit compact={distractionFree} title={title ?? name} />
<main className={styles.lightEdit} data-light-edit>
<div className={styles.lightEditIsland}>
<EditorArea />
+2
Переглянути файл
@@ -15,6 +15,7 @@ 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";
@@ -74,6 +75,7 @@ export function activateWorkbench(): () => void {
installToolWindowActions(),
installToolWindowLayoutSync(),
installDistractionFree(),
installNarrowWindow(),
installFocusTracking(setFocusedToolWindow),
installFocusScopeTracking(),
installTaskEvents(),
+5 -1
Переглянути файл
@@ -644,12 +644,16 @@ 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(): Promise<void>;
saveAll(opts?: { skipUntitled?: boolean }): Promise<void>;
/** Doc ids of untitled buffers with unsaved changes (they follow the window, not a project). */
untitledDirtyDocumentIds?(): number[];
/** Includes local edits not yet acknowledged by the backend. */
dirtyDocumentIds?(): number[];
/** Move keyboard focus to the active editor (used by Escape from tool windows). */
+90
Переглянути файл
@@ -0,0 +1,90 @@
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}`);
}
});
});
+25
Переглянути файл
@@ -0,0 +1,25 @@
/**
* 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();
};
}
+109
Переглянути файл
@@ -5,6 +5,12 @@ import { registerToolWindow } from "../contributions";
import { installToolWindowActions } from "./actions";
import {
activateToolWindow,
applyToolWindowLayout,
collapseSidesForNarrowWindow,
enterDistractionFreeLayout,
exitDistractionFreeLayout,
toolWindowLayoutSnapshot,
restoreSidesAfterNarrowWindow,
hideActiveToolWindow,
hideAllToolWindows,
hideToolWindow,
@@ -540,3 +546,106 @@ 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);
});
});
+65 -3
Переглянути файл
@@ -102,6 +102,10 @@ 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 => ({
@@ -120,6 +124,8 @@ const initial = (): ToolWindowLayoutState => ({
stripeButtons: {},
viewModes: {},
distractionFreeStash: null,
narrowStash: null,
layoutLoads: 0,
});
export const useToolWindowLayout = create<ToolWindowLayoutState>(initial);
@@ -249,9 +255,10 @@ function persist() {
stripeButtons,
viewModes,
distractionFreeStash,
narrowStash,
} = useToolWindowLayout.getState();
// Distraction Free Mode is not saved: the layout to restore is.
const visible = withoutSessionWindows(distractionFreeStash ?? current);
// Distraction Free Mode and a narrow window's collapsed sides are not saved: the layout to restore is.
const visible = withoutSessionWindows({ ...(distractionFreeStash ?? current), ...narrowStash });
try {
localStorage.setItem(
storageKey(scope),
@@ -382,6 +389,7 @@ export function loadToolWindowLayout(scope: string) {
visible = reconcile(visible, placements) ?? visible;
useToolWindowLayout.setState({
...initial(),
layoutLoads: useToolWindowLayout.getState().layoutLoads + 1,
scope,
visible,
sizes: sanitizeSizes(saved?.sizes),
@@ -453,6 +461,7 @@ function publishVisibility() {
function update(patch: Partial<ToolWindowLayoutState>, save = true) {
useToolWindowLayout.setState(patch);
keepNarrowStashToHiddenSides();
publishVisibility();
if (save) persist();
}
@@ -589,6 +598,58 @@ 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)) {
@@ -699,7 +760,7 @@ export interface ToolWindowLayoutSnapshot {
export function toolWindowLayoutSnapshot(): ToolWindowLayoutSnapshot {
const s = useToolWindowLayout.getState();
return {
visible: { ...(s.distractionFreeStash ?? s.visible) },
visible: { ...(s.distractionFreeStash ?? s.visible), ...s.narrowStash },
sizes: { ...s.sizes },
splits: { ...s.splits },
placements: { ...s.placements },
@@ -746,6 +807,7 @@ export function applyToolWindowLayout(snapshot: ToolWindowLayoutSnapshot) {
maximized: null,
stashed: null,
distractionFreeStash: null,
narrowStash: null,
});
if (hadFocus) focusEditor();
}