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

...
Автор SHA1 Повідомлення Дата
l0ng-ai a97907a2bd fix(plugins): list the official marketplace in debug builds too
Debug builds set no default marketplace index, so the Plugins page showed only the repository's local plugins and the marketplace flows (browse, install, update checks, revocations) could not be exercised without pasting the index URL by hand. The official index is now the default in every build; the local plugins stay in their own group.
2026-11-12 22:16:58 +08:00
l0ng-ai 405343b7b7 fix(code-insight): show a language server busy only once it stays busy (#2953)
jdtls reports a $/progress task on almost every edit, save, search and diagnostics publish, most lasting 0-20 ms, so the status bar's language-server dot kept switching to busy and back. A running server now shows busy only after it has been busy for 500 ms, the delay background tasks already use. Other statuses and the switch back to running still show at once; only the status bar display is delayed.
2026-11-12 21:47:39 +08:00
Muuтаmuutot cc29842e6b fix(python): workspace members inherit the project interpreter, and a deleted one falls back (#2920)
A workspace member attached to a project resolved its Python interpreter only from its own settings and folder, so a member with no Python of its own ran on the PATH python while the status widget named the primary project's interpreter. Members now inherit the primary project's interpreter when they have none (explicit setting, then virtualenv or pin, as a module without its own SDK inherits the project SDK), sdk_python_effective answers with the same chain, and the status widget asks about the open file's member.

A configured interpreter whose executable was deleted is skipped during resolution, so runs fall back to the project virtualenv or the inherited interpreter. Settings mark it Invalid (decided from what the backend resolves, so working custom paths are not flagged), the status widget warns when nothing resolves, and starting a Python configuration on a missing path names the cause.

Co-authored-by: muutot <[email protected]>
2026-11-12 21:45:37 +08:00
l0ng-ai de0f738bb2 fix(editor): keep Enter for new lines in a completion's snippet (#2967)
Completing an XML tag inserts the server's snippet <tag>$1</tag>$0. Enter moved to the next snippet field in every snippet, so it jumped to the end of the line instead of splitting the tag. Enter now moves between fields only in live templates and Emmet; in a completion's snippet Tab does and Enter inserts a line break.

Closes #2951
2026-11-12 21:25:54 +08:00
l0ng-ai d781df542a fix(lsp): answer Java usages without waiting for jdtls' background work (#2945)
A usages search that found nothing waited up to 10 s for every $/progress task to end before asking again, and jdtls reports background work (builds, validation) for long stretches, so Show Usages on a method nothing calls could take 10 s. jdtls' searches wait for its index themselves, so after ServiceReady an empty answer is final: it is neither waited on nor flagged as incomplete. Before ServiceReady, and for other servers, running progress still counts as indexing.

Refs #2770
2026-11-12 21:12:54 +08:00
SunZhiQiang abfc4cbade feat(agents): review a task's commits in the Changes pane (#2960)
A worktree task's Changes pane only listed the cumulative diff since the task started, so work the agent committed was mixed into the current changes. It now groups the task's work: uncommitted files compared with HEAD, and each commit made since the task started with the files it changed against its first parent. Double-clicking a commit or one of its files opens the parent-to-commit diff; commit files are history and are not rolled back. Rollback in this view restores files to HEAD. Accept still brings over the task's whole work, committed or not. View Options > Group By > Commit switches back to the flat since-start list; tasks in the project's own checkout keep that list.

Co-authored-by: panda-student <[email protected]>
2026-11-12 21:10:33 +08:00
arookieofcтаa-rookie-of-C-language 646e5f8364 fix(vcs): explain a mode-only change in the diff viewer (#2864)
A chmod-only change is a real modification: git status lists it and a commit records the new mode, so status keeps reporting it (core.fileMode=false still hides it). The diff viewer used to show "Contents are identical" with no explanation; when both texts are equal but the git modes differ it now says "File mode changed: 100644 → 100755". The modes come from a new vcs_file_mode command (index/tree entry mode for revisions, worktree permissions for the working copy).

Closes #2853

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-12 21:01:13 +08:00
Mr0Bread a3b48b78fd fix(editor): keep narrow editor groups usable with vertical tabs (#2962)
With tabs placed left or right, the tab column was sized to its longest label (140–260 px) and never shrank, so in a narrow split group a long file name left the editor almost no room. The column is now capped at 40% of its group; long labels truncate and keep the full path in the tooltip. Top and bottom placement are unchanged.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-12 20:55:15 +08:00
Ernstjan FreriksтаEjaYF 5e4fa861bb fix(agents): move the caret with Home and End in the agent composer on macOS (#2943)
WebKit maps Home / End in a text box to scrolling and leaves the caret where it is, so they did nothing in the agent composer on macOS. The composer now moves the caret to the start or end of the visual line (Shift extends the selection); with ⌘, Ctrl or Alt the keys keep their native behaviour, and other platforms are unchanged.

Co-authored-by: EjaYF <[email protected]>
2026-11-12 20:55:06 +08:00
hwlLL0703 068ce8e721 feat(maven): import external Maven modules from Project Structure (#2922)
A Maven project outside the opened root can be linked explicitly: Add Maven Projects accepts an external pom.xml, and Project Structure › Modules › Import Module from Existing Sources (also on the project root's context menu) imports a directory with a pom.xml. The linked project becomes an extra project-tree root and an independent Maven project with its own working directory and wrapper; links are stored as canonical paths, survive reloads and reopening, and unlinking removes only the association. The opened project's jdtls gets the external directory as an extra workspace folder, keeping automatic discovery under the opened root. Neither POM is edited.

Merged with main's aggregation fix: a project an aggregator lists later becomes its module only when the aggregator lies inside the opened root and is not on the same chain, so a linked sibling does not take over the opened project.

Closes #2884

Co-authored-by: hwlLL0703 <[email protected]>
2026-11-12 20:52:00 +08:00
l0ng-ai e5cc1ed712 fix(http-client): show the request in the tool window while it is sent (#2966)
The HTTP Client tool window opened only once a response arrived, so slow requests looked unanswered. It now opens as the request is sent, and the response pane shows the request line, a progress indicator and Stop Request until the response replaces it.

Fixes #2937
2026-11-12 20:40:59 +08:00
l0ng-ai abe6285629 feat(app): resizable New Project dialog (#2965)
The New Project dialog gets a resize grip and remembers its size; the template list and settings pane grow with it. It opens at the same default size as before.

Part of #2903
2026-11-12 20:36:00 +08:00
l0ng-ai 66a829c24c fix(maven): an aggregator in a subfolder holds the root POM it lists as ../ (#2963)
The root POM was found first as a top-level project and skipped when parent/pom.xml listed it again as <module>../</module>, and module paths kept their '..', so the Maven tool window showed the root module beside the aggregator. Module paths are normalised and a project an aggregator lists later becomes its module.

Fixes #2877
2026-11-12 20:32:00 +08:00
l0ng-ai 04f3f9434f fix(remote): serve Open in Browser and the built-in preview from the remote host (#2961)
In a remote window the built-in web server ran on the client and read the local file system, so URLs for remote files were 404s. The server now lives in the host: openidea-server answers web_url with its own server on the remote loopback, and the client forwards that port over the SSH or dev container connection and returns the forwarded URL. The Host check accepts any port with 127.0.0.1 / localhost, since the forwarded port can differ.

Fixes #2915
2026-11-12 20:18:11 +08:00
l0ng-ai 4330034403 perf(remote): list the deployment tree concurrently and report sync phases (#2959)
Sync before run listed the remote project one folder after another and checked each external classpath entry in turn, a round trip each, silently. Folders are now listed 16 at a time and classpath entries checked the same way; the run console reports the comparing / checking phases and the elapsed time. The walk still visits every folder, so results are unchanged.

Part of #2873
2026-11-12 20:05:43 +08:00
l0ng-ai de8afe31d2 fix(remote): finish SSH runs whose channel outlives the process (#2957)
A remote run finished only when its SSH channel closed, so a channel that outlived the process (a child holding the inherited stdout, or a server that never closes it) left the run, and Stop / Rerun, hanging. The exit status now ends the process once the channel has been quiet for a second, and Stop finishes the run locally when the channel still reports nothing 3 s after KILL.

Fixes #2872
2026-11-12 20:02:25 +08:00
l0ng-ai 579e5eee9b feat(languages): add Prisma (#2955)
Prisma schema files get highlighting, structure view and folding from the bundled tree-sitter-prisma-io grammar, a file icon, and code insight from @prisma/language-server (installable from the IDE). The language definition provides the `prisma` settings section the server reads before each validation; without it the server crashed.

Closes #2926
2026-11-12 20:00:13 +08:00
l0ng-ai 099ede3478 fix(vcs): Commit… from Local Changes includes the selected files and focuses the message (#2952)
With the modal commit interface, Commit… (⌘K) and Commit and Push… invoked from Local Changes include just the selected files, and the commit message field takes focus. Invoked elsewhere, the dialog starts with the default inclusion instead of the previous dialog's leftovers.

Fixes #2923
2026-11-12 19:33:15 +08:00
l0ng-ai fb269afdfa fix(lsp): run every server's fix-all on save, and give ESLint its codeActionOnSave setting (#2950)
Run code cleanup applied one source.fixAll action, so in TypeScript files vtsls's won and ESLint's was dropped; fix-all now runs once per server that offers it, each asked again after the previous edit. ESLint rejected every fix-all request because the built-in settings lacked codeActionOnSave; all language files attaching eslint now send { mode = "all", rules = [] }. Failed save actions are logged.

Fixes #2927
Fixes #2928
2026-11-12 19:23:59 +08:00
Alexanderтаformiat 435b00d893 feat(search): colour Find in Files preview matches with the editor's search result colour (#2949)
Matches in the Find in Files and Search Everywhere preview use the editor scheme's search result background (IntelliJ's SEARCH_RESULT_ATTRIBUTES, new token --bg-search-result with values for every built-in theme) instead of the lists' SearchMatch yellow, as a background only so syntax colours stay readable. SEARCH_RESULT_ATTRIBUTES now imports into the new token; --bg-search-match keeps coming from SearchMatch.

Closes #2858

Co-authored-by: formiat <[email protected]>
2026-11-12 19:20:34 +08:00
jiejie1133 14fe542617 fix(editor): leave the caret after a completion snippet without fields (#2939)
CodeMirror leaves the caret before an inserted snippet that has no fields, so accepting a field like `val` at `root.` put the caret before it. LSP snippets without tab stops, choices or unresolved variables now get a final ${0} stop; SQL functions without parameters had the same problem and get one too. Snippets with fields keep their selection.

Closes #2936

Co-authored-by: jiejie1133 <[email protected]>
2026-11-12 19:19:37 +08:00
persicaTaoтаpersicaTao 513762147a fix(db): do not require TLS when SQL Server SSL is disabled (#2938)
Tiberius defaults to EncryptionLevel::Required with rustls, and trust_cert only skips certificate checks, so a SQL Server data source with SSL = Disable still negotiated TLS and timed out against a server without encryption (or one rustls can't speak TLS with). Disable now selects NotSupported; Prefer still trusts the certificate, Require and Verify keep validated TLS.

Closes #2917

Co-authored-by: persicaTao <[email protected]>
2026-11-12 19:18:49 +08:00
l0ng-ai 489f55f52c fix(vcs): don't commit a file that was added to the index and then deleted (#2947)
A file added to the index and then deleted from disk is listed nowhere in the Commit view, but it stayed in the index copy the commit is made from, so committing other files committed it too. Such staged entries are now excluded like unchecked staged files.

Fixes #2908
2026-11-12 19:15:23 +08:00
l0ng-ai 55fcbf281a fix(editor): keep hover documentation inside its popup near the window edge (#2946)
Near the window edge CodeMirror caps the hover tooltip's height, but the documentation section kept its own 380px maximum and ran past the popup's border. The tooltip is now a flex column whose sections shrink and scroll within it.

Fixes #2910
2026-11-12 19:13:31 +08:00
Clarence f8aa003b47 feat(editor): render documentation comments (#2934)
Javadoc, KDoc, JSDoc, rustdoc and other /** and /// comments can be drawn in place as formatted documentation and switched back to source: globally (Settings › Editor › General › Appearance › Render documentation comments, off by default), per file (View › Active Editor › Render Documentation Comments, gutter menu, editor.toggleRenderedDocs) and per comment (gutter icon on hover, editor.toggleRenderedView at the caret). Comments are rescanned only once typing pauses and the Markdown renderer loads on first use.

Co-authored-by: Clarence404 <[email protected]>
2026-11-12 19:05:25 +08:00
Qingтаqtvbwfn e27b7f2502 feat(services): show a running-services badge on the window button (#2891)
The Services tool window's stripe button shows how many listed services are running: one per configuration (a run and its attached debugger count once), leaving out types not added, Docker kinds and hidden configurations; hidden at zero. The run provider keeps the count in a shared store, so the Services feature renders it without importing the run feature.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-12 19:03:45 +08:00
langxpтаkradme bc00302ef8 fix(debugger): echo the Java launch command in the debug console (#2930)
Java Debug launches through java-debug in jdtls, which bypasses the Run process that echoes its command line. Debug now prints the equivalent java … -cp … Main … command after the build and classpath resolution, so Debug and Run output stay comparable.

Co-authored-by: kradme <[email protected]>
2026-11-12 19:03:38 +08:00
Mansur Akhmadiтаmansur-akhmadi 2e044c4a2b feat(vcs): show each repository's branch on its node in the Commit tool window (#2911)
With several Git repositories, each repository node in the Commit tool window now shows its current branch as a dimmed hint after the name (the short HEAD commit when detached), with the branch widget's branch / commit icon, from the heads the VCS store already loads.

Closes #2912

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-12 19:03:18 +08:00
l0ng-ai b1959e8fb5 fix(java): state the installed jdtls' JDK requirement in the language server JDK settings (#2876)
Settings said the language server JDK needs "jdtls 21+", but recent jdtls bundles an Eclipse platform that needs JDK 25, so a JDK 21 chosen there was rejected at launch. The requirement is now read from the installed jdtls (new jdtls_required_java command, the same bundle scan as the launch check): the hint states it, flags a too-old selection, the JDK picker marks too-old JDKs, and Download JDK preselects that version.
2026-11-12 19:03:00 +08:00
Qingтаqtvbwfn ce67c7e129 feat(run): colour console log lines by level (#2924)
Run and Debug consoles can colour whole lines by detected log level (ERROR, WARN, INFO, DEBUG, TRACE), reusing the log editor mode's level vocabulary; ANSI colours the program prints win. Settings › Build, Execution, Deployment › Console has the toggle and one colour per level (scheme tokens by default). Off by default, since IDEA's console doesn't colour lines by level.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-12 19:01:01 +08:00
l0ng-ai 3cc94e340b fix(editor): move through the completion lookup with ⌃N / ⌃P on macOS (#2944)
On macOS ⌃N / ⌃P are line down / up in text, so with the lookup open they moved the caret instead of the selection. With a lookup open they now move its selection, as ↓ / ↑ do.

Fixes #2905
2026-11-12 18:58:15 +08:00
arookieofcтаa-rookie-of-C-language 51793e02c5 fix(debugger): use the same console font as the Run window (#2865)
The debug console used the UI mono font and size instead of the console font the Run window resolves from Tools › Terminal › Font (or Editor › Font). Both consoles now take family, size and row height from a shared useConsoleFont() hook, the debug console re-measures its rows when the size changes, and its evaluate input uses the same font.

Closes #2844

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-12 18:57:51 +08:00
l0ng-ai 1e0ce64b0c fix(plugins): don't block Find Usages on a failed plugin index batch (#2941)
A plugin indexer batch that failed left its paths dirty forever, so every later Find Usages and rename failed with "Plugin indexes are still updating". Failed paths are now retried before the next usages or refactoring; usages go ahead with a "may be incomplete" note while an index is not up to date, and refactoring refuses with a message that says the index failed.

Fixes #2913
2026-11-12 18:37:36 +08:00
208 змінених файлів з 6745 додано та 399 видалено

No files matched your search

Створено
+13
Переглянути файл
@@ -4443,6 +4443,7 @@ dependencies = [
"ide-tools",
"ide-vcs",
"ide-vfs",
"ide-web",
"ide-workspace",
"ignore",
"jsonschema",
@@ -4601,6 +4602,7 @@ dependencies = [
"tree-sitter-nix",
"tree-sitter-ocaml",
"tree-sitter-php",
"tree-sitter-prisma-io",
"tree-sitter-proto",
"tree-sitter-python",
"tree-sitter-ruby",
@@ -4726,6 +4728,7 @@ name = "ide-remote"
version = "0.1.43"
dependencies = [
"dunce",
"futures",
"globset",
"ide-core",
"ide-run",
@@ -10437,6 +10440,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-prisma-io"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c35912d65a6adb07f781db46d843bf0464935e267da6ed192705516adafc7c24"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-proto"
version = "0.6.0"
+21 -5
Переглянути файл
@@ -1343,6 +1343,15 @@ impl AgentManager {
blocking(move || at.changes(&before)).await
}
/// The task's uncommitted changes and, for a worktree task, the commits made since it
/// started (newest first). Both are read against the same `HEAD`.
pub async fn grouped_changes(&self, id: &str) -> IdeResult<AgentChanges> {
let task = self.task(id)?;
let at = task.base();
let before = task.state.lock().before.clone();
blocking(move || at.grouped_changes(&before)).await
}
/// Content of `path` (in the task's directory) when the task started.
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
let at = self.task(id)?.base();
@@ -1353,9 +1362,14 @@ impl AgentManager {
.await
}
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
/// were when it started (files the user had changed then, as the user left them).
pub async fn rollback_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<()> {
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as
/// `view` compares them: as they were when it started, or as they are in `HEAD`.
pub async fn rollback_changes(
&self,
id: &str,
rel_paths: Option<Vec<String>>,
view: AgentChangesView,
) -> IdeResult<()> {
let task = self.task(id)?;
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
@@ -1366,8 +1380,10 @@ impl AgentManager {
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let changes = at.changes(&before)?;
let rev = at.rev()?;
let (changes, rev) = match view {
AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?),
AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?),
};
let sources = match rev.as_deref() {
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
None => Default::default(),
+40 -1
Переглянути файл
@@ -484,7 +484,8 @@ pub struct NewAgentTask {
pub base: Option<String>,
}
/// A file the task changed, relative to [`AgentTask::base_commit`].
/// A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
/// grouped view.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentChange {
@@ -493,6 +494,44 @@ pub struct AgentChange {
pub kind: ide_vcs::ChangeKind,
pub added: u32,
pub removed: u32,
/// Path before a rename; the left side of its diff reads this path.
pub old_path: Option<String>,
}
/// A commit made while the task ran, with the files it changed against its first parent.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentCommit {
pub id: String,
pub short_id: String,
pub summary: String,
pub message: String,
pub time_ms: f64,
/// First parent, or `None` for a root commit.
pub parent: Option<String>,
pub files: Vec<AgentChange>,
}
/// Which comparison a rollback of the task's changes uses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentChangesView {
/// Everything since the commit the task started from.
#[default]
SinceStart,
/// The uncommitted files against `HEAD`; commits made since the start stay.
Group,
}
/// A task's uncommitted files and the commits made since it started.
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentChanges {
/// Files changed against `HEAD`, less those changed before the task started and untouched since.
pub uncommitted: Vec<AgentChange>,
/// Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty
/// for tasks working in the project's own checkout.
pub commits: Vec<AgentCommit>,
}
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
+29 -8
Переглянути файл
@@ -2853,14 +2853,14 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
}
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt", "other.txt"]);
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).await.unwrap();
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()]), AgentChangesView::SinceStart).await.unwrap();
assert_eq!(read("other.txt"), "base\n");
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt"]);
// After a restart, from the saved copy.
mgr.shutdown();
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
assert!(!dir.join("new.txt").exists());
assert!(changed(&mgr, &task.id).await.is_empty());
@@ -2973,7 +2973,7 @@ async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
assert_eq!(base.map(|b| b.replace("\r\n", "\n")).as_deref(), Some("base\n"), "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}: the user's work survives the rollback");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
@@ -2996,7 +2996,7 @@ async fn files_a_mixed_reset_during_a_local_task_leaves_behind_are_not_rolled_ba
repo.git(&["reset", "-q", "HEAD~1"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}");
assert_eq!(repo.read("mine-new.txt"), "my new file\n", "{eol:?}");
@@ -3023,7 +3023,7 @@ async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
repo.git(&["checkout", "-q", "feature"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("other.txt"), "feature\n", "{eol:?}");
assert_eq!(repo.read("only-feature.txt"), "feature\n", "{eol:?}");
@@ -3048,7 +3048,7 @@ async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
repo.write("a.txt", "agent\nagain\n");
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to before it");
assert!(!repo.dir.join("b.txt").exists(), "{eol:?}");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
@@ -3069,7 +3069,7 @@ async fn rolling_back_a_rename_brings_the_old_file_back() {
let listed = changed(&mgr, &task.id).await;
assert_eq!(listed, ["CASE.java", "New.java"], "{eol:?}");
mgr.rollback_changes(&task.id, Some(listed)).await.unwrap();
mgr.rollback_changes(&task.id, Some(listed), AgentChangesView::SinceStart).await.unwrap();
assert_eq!(repo.read("Old.java"), "class Old {}\n", "{eol:?}");
assert!(!repo.dir.join("New.java").exists(), "{eol:?}");
assert_eq!(repo.read("Case.java"), "class Case {}\n", "{eol:?}");
@@ -3082,6 +3082,27 @@ async fn rolling_back_a_rename_brings_the_old_file_back() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn the_group_view_rolls_back_only_uncommitted_files() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("a.txt", "base\n");
repo.commit("init");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
repo.commit("agent work");
repo.write("a.txt", "agent\nagain\n");
let grouped = mgr.grouped_changes(&task.id).await.unwrap();
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"], "{eol:?}");
assert!(grouped.commits.is_empty(), "{eol:?}: local tasks have no commits to group");
mgr.rollback_changes(&task.id, None, AgentChangesView::Group).await.unwrap();
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to the start");
assert!(mgr.grouped_changes(&task.id).await.unwrap().uncommitted.is_empty(), "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
for eol in EOLS {
@@ -3092,7 +3113,7 @@ async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_c
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"], "{eol:?}");
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None, "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert!(!repo.dir.join("new.txt").exists(), "{eol:?}");
assert_eq!(repo.read("mine.txt"), "mine\n", "{eol:?}");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
+180 -4
Переглянути файл
@@ -1,8 +1,8 @@
//! Task worktrees and the changes made in them.
use crate::model::AgentChange;
use crate::model::{AgentChange, AgentChanges, AgentCommit};
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_vcs::{ChangeKind, Repo};
use ide_vcs::{ChangeKind, LogQuery, Repo};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH};
@@ -35,6 +35,14 @@ pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> {
Ok((info.head.unwrap_or_else(|| commit.clone()), commit))
}
/// `HEAD`'s commit in `cwd`, `None` outside a repository or before its first commit.
pub(crate) fn head_commit(cwd: &Path) -> IdeResult<Option<String>> {
match discover(cwd)? {
Some(repo) => Ok(repo.head()?.1),
None => Ok(None),
}
}
/// `git worktree add -b agent/<slug> <dir>/<slug> <base>`.
pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult<Created> {
let repo = repo_at(project_root)?;
@@ -111,7 +119,14 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
.into_iter()
.map(|f| {
let (added, removed) = stat(&f.path);
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
AgentChange {
path: IpcPath(root.join(&f.path)),
rel_path: f.path,
kind: f.kind,
added,
removed,
old_path: f.old_path,
}
})
.collect();
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
@@ -140,6 +155,7 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String
kind: ChangeKind::Deleted,
added: 0,
removed: 0,
old_path: None,
};
Some((f.path, source))
})
@@ -148,7 +164,57 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String
fn added(path: IpcPath, rel_path: String) -> AgentChange {
let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32);
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0, old_path: None }
}
/// The commits from `base` (exclusive) to `head` (inclusive) in `cwd`, newest first, each
/// with the files it changed against its first parent and their line counts.
pub(crate) fn commits(cwd: &Path, base: &str, head: &str) -> IdeResult<Vec<AgentCommit>> {
if base == head {
return Ok(Vec::new());
}
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
let root = repo.root().to_path_buf();
let query = LogQuery { range: Some(format!("{base}..{head}")), ..LogQuery::default() };
let mut out = Vec::new();
for commit in repo.log(&query)? {
let parent = commit.parents.first().cloned();
// A root commit has no parent to count lines against; its files still show.
let stats: std::collections::HashMap<String, (u32, u32)> = match &parent {
Some(parent) => repo
.diff_numstat(parent, &commit.id, &[])?
.into_iter()
.map(|s| (s.path, (s.added, s.removed)))
.collect(),
None => std::collections::HashMap::new(),
};
let mut files: Vec<AgentChange> = repo
.commit_files(&commit.id)?
.into_iter()
.map(|f| {
let (added, removed) = stats.get(&f.path).copied().unwrap_or((0, 0));
AgentChange {
path: IpcPath(root.join(&f.path)),
rel_path: f.path,
kind: f.kind,
added,
removed,
old_path: f.old_path,
}
})
.collect();
files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
out.push(AgentCommit {
id: commit.id,
short_id: commit.short_id,
summary: commit.summary,
message: commit.message,
time_ms: commit.time_ms,
parent,
files,
});
}
Ok(out)
}
/// Whether two paths name one file (a case-insensitive disk resolving both spellings).
@@ -243,6 +309,34 @@ impl TaskBase {
.collect())
}
/// The task's uncommitted changes and, for a worktree task, the commits made since it
/// started (newest first). Both read the same `HEAD`, so a commit landing between the two
/// reads cannot put a file in both lists.
pub(crate) fn grouped_changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<AgentChanges> {
let cwd = self.cwd();
let Some(repo) = discover(cwd)? else { return Ok(AgentChanges::default()) };
let head = repo.head()?.1;
let uncommitted = match &head {
Some(head) => without_untouched(changes(cwd, head)?, before),
None => {
// No commits yet: every file is new, as in `changes` for a local task.
let mut all: Vec<AgentChange> = repo
.status()?
.into_iter()
.filter(|s| s.path.is_file())
.map(|s| added(s.path, s.rel_path))
.collect();
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
without_untouched(all, before)
}
};
let commits = match (self, &head) {
(Self::Worktree { cwd, base }, Some(head)) => commits(cwd, base, head)?,
_ => Vec::new(),
};
Ok(AgentChanges { uncommitted, commits })
}
/// The revision the task's changed files go back to on rollback, and are compared with:
/// the commit the task started from, or the current `HEAD` once the user committed on top
/// of it, so those commits stay. `None` while the repository has no commits.
@@ -598,6 +692,88 @@ mod tests {
assert!(!created.path.exists());
}
#[test]
fn commits_since_the_base_are_listed_with_their_files() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
assert!(commits(&created.path, &created.base_commit, &created.base_commit).unwrap().is_empty());
std::fs::write(created.path.join("a.txt"), "one\ntwo\n").unwrap();
git(&created.path, &["add", "."]);
git(&created.path, &["commit", "-q", "-m", "change a"]);
std::fs::write(created.path.join("b.txt"), "b\n").unwrap();
git(&created.path, &["add", "."]);
git(&created.path, &["commit", "-q", "-m", "add b"]);
let head = head_of(&created.path).unwrap().1;
let log = commits(&created.path, &created.base_commit, &head).unwrap();
assert_eq!(log.iter().map(|c| c.summary.as_str()).collect::<Vec<_>>(), ["add b", "change a"]);
assert_eq!(log[0].files.iter().map(|f| f.rel_path.as_str()).collect::<Vec<_>>(), ["b.txt"]);
assert_eq!((log[0].files[0].added, log[0].files[0].removed), (1, 0));
assert_eq!(log[1].files.iter().map(|f| f.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
assert_eq!((log[1].files[0].added, log[1].files[0].removed), (1, 0));
assert!(log.iter().all(|c| c.parent.is_some() && !c.message.is_empty()));
// Nothing is uncommitted after the commits; editing a committed file brings it back.
let at = TaskBase::Worktree { cwd: created.path.clone(), base: created.base_commit.clone() };
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
assert!(grouped.uncommitted.is_empty());
assert_eq!(grouped.commits.len(), 2);
std::fs::write(created.path.join("a.txt"), "one\ntwo\nthree\n").unwrap();
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
assert_eq!(grouped.commits.len(), 2);
}
#[test]
fn a_rename_in_a_commit_keeps_its_old_path() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
let base = head_of(&repo).unwrap().1;
std::fs::rename(repo.join("a.txt"), repo.join("b.txt")).unwrap();
std::fs::write(repo.join("b.txt"), "one\ntwo\nthree\nfour\nFIVE\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "rename a"]);
let head = head_of(&repo).unwrap().1;
let log = commits(&repo, &base, &head).unwrap();
assert_eq!(log.len(), 1);
assert_eq!(log[0].files.len(), 1);
assert_eq!(log[0].files[0].rel_path, "b.txt");
assert_eq!(log[0].files[0].old_path.as_deref(), Some("a.txt"));
assert_eq!(log[0].files[0].kind, ChangeKind::Renamed);
}
#[test]
fn a_local_task_has_no_commits() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
let at = TaskBase::Local { cwd: repo.clone(), start: None };
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
assert!(grouped.commits.is_empty());
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
}
#[test]
fn a_broken_worktree_registration_still_leaves_a_deletable_folder() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
Переглянути файл
@@ -204,7 +204,7 @@ pub fn parse_settings(text: &str) -> Settings {
}
/// `a/b/../c` → `a/c`, without touching the filesystem.
fn normalize(p: &Path) -> PathBuf {
pub(crate) fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
+8
Переглянути файл
@@ -122,6 +122,14 @@ pub fn gradle_external_roots(
gradle::external_roots(root, dirs.iter().map(String::as_str))
}
/// Directories of Maven POMs linked from outside `root`, outermost only.
/// Reads just those explicit paths (no POM parse and no directory walk).
pub fn maven_external_roots(root: &Path, linked: &[String]) -> Vec<PathBuf> {
let dirs = maven::external_pom_dirs(root, linked);
let owned: Vec<String> = dirs.iter().map(|dir| dir.to_string_lossy().into_owned()).collect();
gradle::external_roots(root, owned.iter().map(String::as_str))
}
/// Source, resource and build folders of the last Gradle sync, absolute.
pub fn gradle_source_roots(
root: &Path,
+337 -31
Переглянути файл
@@ -106,7 +106,9 @@ pub struct MavenProject {
pub profiles: Vec<String>,
/// Java feature version the project compiles for (`maven.compiler.release`, `java.version`…).
pub java_version: Option<u32>,
/// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`.
/// Maven launcher: the nearest `mvnw` (absolute), else `mvn`. Inside the opened
/// project the search stops at its root; outside it, the search stops before
/// that root so a linked project keeps its own wrapper.
pub command: String,
/// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings.
pub options: Vec<String>,
@@ -1032,10 +1034,6 @@ pub(crate) fn plugin(
const MAX_PROJECTS: usize = 1000;
fn rel_path(root: &Path, p: &Path) -> String {
p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")
}
/// The POMs that are not modules of another one: the root `pom.xml`, else the
/// shallowest `pom.xml` of each branch (a folder holding several builds). An
/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml`
@@ -1049,18 +1047,29 @@ pub fn is_maven_project(root: &Path) -> bool {
!top_level_poms(root).is_empty()
}
/// The `mvnw` nearest to `dir`, looking up to `root`.
/// The `mvnw` nearest to `dir`. Inside `root` the walk stops at `root`. Outside it,
/// the walk stops before entering `root` or any ancestor of `root`: a sibling project
/// keeps its own wrapper, and the search does not continue to the filesystem root.
fn wrapper(root: &Path, dir: &Path) -> String {
let name = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
let mut cur = Some(dir);
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
let external = !dir.starts_with(&root);
let mut cur = Some(dir.as_path());
while let Some(d) = cur {
if d.join(name).is_file() {
return d.join(name).to_string_lossy().into_owned();
}
if d == root {
if !external && d == root {
break;
}
cur = d.parent();
let Some(parent) = d.parent() else { break };
// `parent` inside the opened project, or an ancestor of it (the common parent,
// then the filesystem root). Stop before inspecting those.
if external && (parent.starts_with(&root) || root.starts_with(parent)) {
break;
}
cur = Some(parent);
}
if cfg!(windows) { "mvn.cmd".into() } else { "mvn".into() }
}
@@ -1104,24 +1113,118 @@ pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[St
load_with_linked(root, local_repo, profiles, &[])
}
/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
/// The `pom.xml` files `linked` names. Each entry is used as written (project-relative,
/// `../sibling/pom.xml`, or absolute) and canonicalized; a path that is not an existing
/// `pom.xml` is skipped. Nothing here lists a directory or walks parents looking for builds.
fn linked_poms(root: &Path, linked: &[String]) -> Vec<PathBuf> {
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut out: Vec<PathBuf> = Vec::new();
for pom in linked {
let pom = normalized_pom(pom);
if pom.is_empty() || pom.starts_with("../") || pom == ".." {
continue;
}
let path = root.join(&pom);
let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base));
if path.is_file() && inside && !out.contains(&path) {
let Some(path) = dunce::canonicalize(base.join(pom.trim())).ok() else { continue };
if is_pom_file(&path) && !out.contains(&path) {
out.push(path);
}
}
out
}
fn is_pom_file(path: &Path) -> bool {
path.is_file()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| if cfg!(windows) { name.eq_ignore_ascii_case("pom.xml") } else { name == "pom.xml" })
}
/// Identity of `path` (both canonical) within `root`: project-relative inside it,
/// otherwise the absolute path with forward slashes.
fn project_rel(root: &Path, path: &Path) -> String {
path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/")
}
/// Canonical `pom.xml` paths named by `linked`, keyed by the spelling to store and to
/// show so unlink removes the same entry settings hold. The first spelling wins.
fn linked_identities(root: &Path, linked: &[String]) -> HashMap<PathBuf, String> {
let mut out = HashMap::new();
for entry in linked {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let Some(path) = dunce::canonicalize(root.join(entry)).ok() else { continue };
if is_pom_file(&path) {
out.entry(path).or_insert_with(|| entry.replace('\\', "/"));
}
}
out
}
/// Stable, deduplicated form of `poms` for `languages.java.maven.linkedProjects`.
/// An existing `pom.xml` inside `root` is project-relative; one outside it is the
/// canonical absolute path. Spellings of the same file (`../B/pom.xml`, an absolute
/// path, a symlink) collapse to one entry. A path that exists and is not a `pom.xml`
/// is dropped. A `pom.xml` that cannot be resolved yet is kept, so a link is not
/// discarded while its volume is offline. No directory is scanned.
pub fn normalize_linked_poms(root: &Path, poms: &[String]) -> Vec<String> {
let Some(root) = dunce::canonicalize(root).ok() else {
let mut out = Vec::new();
for pom in poms {
let pom = normalized_pom(pom);
if !pom.is_empty() && pom_name_ok(&pom) && !out.contains(&pom) {
out.push(pom);
}
}
return out;
};
let mut out = Vec::new();
let mut seen = HashSet::new();
for pom in poms {
let pom = pom.trim();
if pom.is_empty() {
continue;
}
let key = match dunce::canonicalize(root.join(pom)) {
Ok(path) if is_pom_file(&path) => project_rel(&root, &path),
Ok(_) => continue,
Err(_) => {
let fallback = normalized_pom(pom);
let joined = root.join(pom);
if !pom_name_ok(&fallback) || (joined.exists() && !is_pom_file(&joined)) {
continue;
}
fallback
}
};
if key.is_empty() || !seen.insert(key.clone()) {
continue;
}
out.push(key);
}
out
}
fn pom_name_ok(path: &str) -> bool {
let name = path.rsplit('/').next().unwrap_or(path);
if cfg!(windows) { name.eq_ignore_ascii_case("pom.xml") } else { name == "pom.xml" }
}
/// Directories of linked `pom.xml` files that sit outside `root` (the opened project
/// stays the root). Callers keep the outermost directory only. Modules a POM declares
/// outside its own directory are not included: a linked file cannot widen the indexed
/// tree to an arbitrary path.
pub fn external_pom_dirs(root: &Path, linked: &[String]) -> Vec<PathBuf> {
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut out = Vec::new();
for pom in linked_poms(root, linked) {
let Some(dir) = pom.parent() else { continue };
if dir.starts_with(&base) || out.iter().any(|have| have == dir) {
continue;
}
out.push(dir.to_path_buf());
}
out
}
/// Whether `root` holds a Maven build or the settings link one inside it.
pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool {
is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty()
@@ -1130,7 +1233,9 @@ pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -
/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules,
/// dependencies and properties are part of the model, those turned off are not) and the
/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent
/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
/// top-level projects, including a `pom.xml` outside the opened root. A linked POM that
/// turns out to be a module of the root reactor stays that module. The same canonical file
/// is loaded once, so a cycle or a second spelling does not duplicate it or edit either POM.
pub fn load_with_linked(
root: &Path,
local_repo: Option<PathBuf>,
@@ -1139,7 +1244,10 @@ pub fn load_with_linked(
) -> MavenModel {
let mut resolver = Resolver::new(local_repo, profiles);
let mut model = MavenModel::default();
let mut seen: HashSet<PathBuf> = HashSet::new();
// Canonical POM → its project, for a module listed again by a later aggregator.
let mut seen: HashMap<PathBuf, Option<usize>> = HashMap::new();
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let linked_ids = linked_identities(&canonical_root, linked);
let mut tops = top_level_poms(root);
for pom in linked_poms(root, linked) {
if !tops.contains(&pom) {
@@ -1147,17 +1255,31 @@ pub fn load_with_linked(
}
}
// (pom, aggregated); the root reactor and its modules come before linked POMs.
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
while let Some((path, aggregated)) = stack.pop() {
// (pom, listed by): a module carries the top-level project its chain started from and
// whether the aggregator listing it lies inside the opened root.
let mut stack: Vec<(PathBuf, Option<(usize, bool)>)> = tops.into_iter().rev().map(|p| (p, None)).collect();
while let Some((path, listed_by)) = stack.pop() {
let aggregated = listed_by.is_some();
if model.projects.len() >= MAX_PROJECTS {
break;
}
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
if !seen.insert(canonical) {
if let Some(known) = seen.get(&canonical) {
// Found as a top-level POM first, then listed by an aggregator elsewhere
// (`<module>../</module>` of `parent/pom.xml`): it is that aggregator's module.
// Not when the chain leads back to where it started (a cycle), and not for an
// aggregator outside the opened root: a linked sibling does not take it over.
if let (Some(known), Some((origin, true))) = (*known, listed_by)
&& known != origin
{
model.projects[known].aggregated = true;
}
continue;
}
seen.insert(canonical.clone(), None);
let Some(pom) = resolver.pom(&path) else { continue };
let dir = path.parent().unwrap_or(root).to_path_buf();
let dir_canon = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
let ctx = resolver.context(&path, 0);
let props = resolver.properties_for(&pom, &ctx, &dir);
let i = |s: &str| interpolate(s, &props);
@@ -1168,7 +1290,7 @@ pub fn load_with_linked(
}
let mut modules: Vec<PathBuf> = Vec::new();
for m in module_names {
let mut mp = dir.join(i(&m));
let mut mp = crate::gradle::normalize(&dir.join(i(&m)));
if mp.is_dir() || mp.extension().is_none_or(|e| e != "xml") {
mp.push("pom.xml");
}
@@ -1232,28 +1354,39 @@ pub fn load_with_linked(
}
}
let artifact_id = i(&pom.artifact_id);
let module_ids: Vec<String> = modules
.iter()
.map(|m| {
let canonical = dunce::canonicalize(m).unwrap_or_else(|_| m.clone());
linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical))
})
.collect();
model.projects.push(MavenProject {
project_root: None,
pom: rel_path(root, &path),
dir: rel_path(root, &dir),
pom: linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical)),
dir: project_rel(&canonical_root, &dir_canon),
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
version: i(props.get("project.version").map(String::as_str).unwrap_or_default()),
display_name: pom.name.as_deref().map(i).filter(|n| !n.is_empty()).unwrap_or_else(|| artifact_id.clone()),
artifact_id,
packaging,
parent: pom.parent.as_ref().map(|p| format!("{}:{}:{}", p.group_id, p.artifact_id, i(&p.version))),
modules: modules.iter().map(|m| rel_path(root, m)).collect(),
modules: module_ids,
aggregated,
plugins,
dependencies,
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
java_version: jv,
command: wrapper(root, &dir),
command: wrapper(&canonical_root, &dir_canon),
options: Vec::new(),
env: BTreeMap::new(),
});
let index = model.projects.len() - 1;
seen.insert(canonical, Some(index));
let origin = listed_by.map_or(index, |(origin, _)| origin);
let inside = dir_canon.starts_with(&canonical_root);
for m in modules.into_iter().rev() {
stack.push((m, true));
stack.push((m, Some((origin, inside))));
}
}
model
@@ -1341,17 +1474,27 @@ impl UnlinkedProjects {
fn normalized_pom(pom: &str) -> String {
let pom = pom.replace('\\', "/");
let mut parts = Vec::new();
let unc = pom.starts_with("//");
let absolute = pom.starts_with('/');
let mut parts: Vec<&str> = Vec::new();
for part in pom.split('/') {
match part {
"" | "." => {}
".." if parts.last().is_some_and(|p| *p != "..") => {
// Keep a drive letter (`C:`) and a leading `..` that has nothing to climb.
".." if parts.last().is_some_and(|p| *p != ".." && !p.ends_with(':')) => {
parts.pop();
}
part => parts.push(part),
}
}
parts.join("/")
let joined = parts.join("/");
if unc {
format!("//{joined}")
} else if absolute {
format!("/{joined}")
} else {
joined
}
}
/// Every POM of the model (for change detection).
@@ -2147,6 +2290,169 @@ mod tests {
assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]);
}
/// A sibling Maven project is linked beside the opened root. Its reactor, including a
/// module that points back at the opened project, is read once. Neither POM is rewritten,
/// and only the linked project's directory is an extra content root — not the common
/// parent and not a module that lives outside that directory.
#[test]
fn external_sibling_keeps_its_directory_and_does_not_edit_poms() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let a = base.join("A");
let b = base.join("B");
let c = base.join("C");
std::fs::create_dir_all(a.join("core")).unwrap();
std::fs::create_dir_all(b.join("mod")).unwrap();
std::fs::create_dir_all(&c).unwrap();
let a_pom = "<project><groupId>demo</groupId><artifactId>a</artifactId><version>1</version><packaging>pom</packaging><modules><module>core</module></modules></project>";
let b_pom = "<project><groupId>demo</groupId><artifactId>b</artifactId><version>1</version><packaging>pom</packaging><modules><module>mod</module><module>../A</module><module>../C</module><module>.</module></modules></project>";
let c_pom = "<project><groupId>demo</groupId><artifactId>c</artifactId><version>1</version><modules><module>../B</module></modules></project>";
std::fs::write(a.join("pom.xml"), a_pom).unwrap();
std::fs::write(a.join("core/pom.xml"), "<project><artifactId>core</artifactId></project>").unwrap();
std::fs::write(b.join("pom.xml"), b_pom).unwrap();
std::fs::write(b.join("mod/pom.xml"), "<project><artifactId>bmod</artifactId></project>").unwrap();
std::fs::write(c.join("pom.xml"), c_pom).unwrap();
let wrapper = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
std::fs::write(a.join(wrapper), "#!/bin/sh\n").unwrap();
std::fs::write(b.join(wrapper), "#!/bin/sh\n").unwrap();
std::fs::write(base.join(wrapper), "#!/bin/sh\n").unwrap();
let discovered = load_with_linked(&a, None, &[], &[]);
assert_eq!(discovered.projects.iter().map(|p| p.artifact_id.as_str()).collect::<Vec<_>>(), ["a", "core"]);
let stored = vec!["../B/pom.xml".into()];
let linked = load_with_linked(&a, None, &[], &stored);
let b_project = linked.projects.iter().find(|p| p.artifact_id == "b").unwrap();
assert!(!b_project.aggregated);
assert_eq!(b_project.pom, "../B/pom.xml");
assert_eq!(dunce::canonicalize(a.join(&b_project.dir)).unwrap(), b);
assert_eq!(dunce::canonicalize(&b_project.command).unwrap(), dunce::canonicalize(b.join(wrapper)).unwrap());
let a_project = linked.projects.iter().find(|p| p.artifact_id == "a").unwrap();
assert!(!a_project.aggregated);
assert_eq!(a_project.modules, ["core/pom.xml"]);
assert_eq!(dunce::canonicalize(&a_project.command).unwrap(), dunce::canonicalize(a.join(wrapper)).unwrap());
assert_eq!(linked.projects.iter().filter(|p| p.artifact_id == "a").count(), 1);
assert_eq!(linked.projects.iter().filter(|p| p.artifact_id == "b").count(), 1);
assert!(linked.projects.iter().any(|p| p.artifact_id == "c" && p.aggregated));
assert!(linked.projects.iter().any(|p| p.artifact_id == "bmod" && p.aggregated));
let again = load_with_linked(&a, None, &[], &stored);
assert_eq!(
again.projects.iter().map(|p| &p.pom).collect::<Vec<_>>(),
linked.projects.iter().map(|p| &p.pom).collect::<Vec<_>>()
);
let absolute = b.join("pom.xml").to_string_lossy().replace('\\', "/");
let via_a = a.join("../B/pom.xml").to_string_lossy().replace('\\', "/");
let normalized = normalize_linked_poms(
&a,
&[
"../B/pom.xml".into(),
absolute,
via_a,
a.join("pom.xml").to_string_lossy().into_owned(),
b.to_string_lossy().into_owned(),
"stub/pom.xml".into(),
"notes.xml".into(),
],
);
assert_eq!(normalized.len(), 3, "{normalized:?}");
assert_eq!(normalized[0], dunce::canonicalize(b.join("pom.xml")).unwrap().to_string_lossy().replace('\\', "/"));
assert_eq!(normalized[1], "pom.xml");
assert_eq!(normalized[2], "stub/pom.xml");
assert_eq!(crate::maven_external_roots(&a, &normalized), vec![b.clone()]);
assert_eq!(crate::maven_external_roots(&a, &stored), vec![b.clone()]);
let removed = load_with_linked(&a, None, &[], &[]);
assert!(removed.projects.iter().all(|p| p.artifact_id != "b"));
assert_eq!(std::fs::read_to_string(a.join("pom.xml")).unwrap(), a_pom);
assert_eq!(std::fs::read_to_string(b.join("pom.xml")).unwrap(), b_pom);
assert_eq!(std::fs::read_to_string(c.join("pom.xml")).unwrap(), c_pom);
assert!(b.join("mod/pom.xml").is_file());
}
/// The launcher recorded on an external project is that project's wrapper, and
/// running it uses the external directory as the working directory.
#[cfg(unix)]
#[test]
fn external_project_wrapper_runs_in_its_own_directory() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let a = base.join("A");
let b = base.join("B");
std::fs::create_dir_all(b.join("src/main/java")).unwrap();
std::fs::create_dir_all(&a).unwrap();
std::fs::write(a.join("pom.xml"), "<project><artifactId>a</artifactId></project>").unwrap();
std::fs::write(
b.join("pom.xml"),
"<project><artifactId>b</artifactId><build><sourceDirectory>src/main/java</sourceDirectory></build></project>",
)
.unwrap();
std::fs::write(b.join("src/main/java/B.java"), "class B {}").unwrap();
let script = b.join("mvnw");
std::fs::write(&script, "#!/bin/sh\npwd > \"$1\"\nprintf 'BUILD SUCCESS\\n'\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let model = load_with_linked(&a, None, &[], &["../B/pom.xml".into()]);
let project = model.projects.iter().find(|p| p.artifact_id == "b").unwrap();
let cwd = a.join(&project.dir);
assert_eq!(dunce::canonicalize(&cwd).unwrap(), b);
let marker = tmp.path().join("cwd");
let out = crate::process::run(
&project.command,
&[marker.to_string_lossy().into_owned()],
&cwd,
&std::collections::BTreeMap::new(),
&ide_core::CancellationToken::new(),
|_, _| {},
)
.unwrap();
assert!(out.success, "{}", out.text);
let recorded = dunce::canonicalize(std::fs::read_to_string(&marker).unwrap().trim()).unwrap();
assert_eq!(recorded, b);
}
/// #2877: an aggregator in a subfolder lists the root POM as `../`; linked, it holds every
/// module, the root one included, under the same POM paths the projects have.
#[test]
fn an_aggregator_in_a_subfolder_holds_the_root_pom() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let child = |id: &str, rel: &str| {
format!(
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><relativePath>{rel}</relativePath></parent><artifactId>{id}</artifactId></project>"
)
};
write(root, "pom.xml", &child("app", "parent/pom.xml"));
write(
root,
"parent/pom.xml",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>../</module><module>../infra</module></modules></project>",
);
write(root, "infra/pom.xml", &child("infra", "../parent/pom.xml"));
let model = load_with_linked(root, None, &[], &["parent/pom.xml".into()]);
let tops: Vec<&str> = model.projects.iter().filter(|p| !p.aggregated).map(|p| p.artifact_id.as_str()).collect();
assert_eq!(tops, ["parent"]);
let parent = model.projects.iter().find(|p| p.artifact_id == "parent").unwrap();
assert_eq!(parent.modules, ["pom.xml", "infra/pom.xml"]);
for pom in &parent.modules {
assert!(model.projects.iter().any(|p| &p.pom == pom), "{pom}");
}
// A cycle (the root lists the aggregator, which lists the root) keeps the root on top.
write(
root,
"pom.xml",
"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>parent</module></modules></project>",
);
let model = load_with_linked(root, None, &[], &[]);
let tops: Vec<&str> = model.projects.iter().filter(|p| !p.aggregated).map(|p| p.artifact_id.as_str()).collect();
assert_eq!(tops, ["app"]);
}
#[test]
fn chosen_profiles_bring_their_modules_and_dependencies() {
let tmp = tempfile::tempdir().unwrap();
+5
Переглянути файл
@@ -78,6 +78,10 @@ pub struct EditorAppearanceSettings {
/// Right margin and visual guides from Code Style.
pub hard_wrap_guides: bool,
pub intention_bulb: bool,
/// Draw doc comments (Javadoc, KDoc, JSDoc, rustdoc) as rendered documentation
/// instead of their source, as IDEA's option of the same name. View › Active
/// Editor › Render Documentation Comments overrides this for one editor.
pub render_doc_comments: bool,
/// Distraction Free Mode: width of the centred text column, in characters.
pub distraction_free_width: u32,
}
@@ -100,6 +104,7 @@ impl Default for EditorAppearanceSettings {
rainbow_indent_guides: false,
hard_wrap_guides: true,
intention_bulb: true,
render_doc_comments: false,
distraction_free_width: 80,
}
}
+3 -1
Переглянути файл
@@ -1182,7 +1182,9 @@ pub struct MavenSettings {
pub runner_jre: String,
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Project-relative POMs outside the root reactor added as independent top-level projects.
/// POMs added as independent top-level projects. Inside the opened project the path is
/// project-relative (`tools/pom.xml`); outside it, the canonical absolute path. Linking
/// does not edit either POM.
pub linked_projects: Vec<String>,
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
+30
Переглянути файл
@@ -192,6 +192,33 @@ pub struct IssueLink {
pub link: String,
}
/// Per-level colours for console log lines: a line whose level is detected
/// (`ERROR`, `WARN`, …) takes the level's colour; ANSI colours the program
/// prints itself win over it. Values are CSS colours, usually scheme tokens.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LogLevelColors {
pub enabled: bool,
pub error: String,
pub warn: String,
pub info: String,
pub debug: String,
pub trace: String,
}
impl Default for LogLevelColors {
fn default() -> Self {
Self {
enabled: false,
error: "var(--ansi-red)".into(),
warn: "var(--ansi-yellow)".into(),
info: "var(--ansi-green)".into(),
debug: "var(--ansi-cyan)".into(),
trace: "var(--ansi-magenta)".into(),
}
}
}
/// Build, Execution, Deployment › Console.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -203,6 +230,8 @@ pub struct ConsoleSettings {
pub encoding: String,
/// Consecutive lines containing any of these are folded.
pub fold_patterns: Vec<String>,
/// Log lines take the colour of their level (Run/Debug consoles).
pub log_colors: LogLevelColors,
}
impl Default for ConsoleSettings {
@@ -219,6 +248,7 @@ impl Default for ConsoleSettings {
"at node:internal/".into(),
"<frozen importlib._bootstrap".into(),
],
log_colors: LogLevelColors::default(),
}
}
}
+38 -3
Переглянути файл
@@ -283,6 +283,16 @@ fn mssql_pool_err(e: bb8::RunError<tiberius::error::Error>) -> IdeError {
}
}
/// Tiberius requires TLS when a TLS backend is enabled. `trust_cert` only skips
/// certificate checks, so Disable has to turn encryption off.
fn apply_sqlserver_ssl(config: &mut tiberius::Config, ssl: DbSslMode) {
match ssl {
DbSslMode::Disable => config.encryption(tiberius::EncryptionLevel::NotSupported),
DbSslMode::Prefer => config.trust_cert(),
DbSslMode::Require | DbSslMode::VerifyCa | DbSslMode::VerifyFull => {}
}
}
const DAYS_1900_01_01: i64 = 693_595;
/// Formats a proleptic Gregorian date given days since `0001-01-01`.
@@ -876,9 +886,7 @@ impl Pool {
if !source.database.is_empty() {
config.database(&source.database);
}
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
config.trust_cert();
}
apply_sqlserver_ssl(&mut config, source.ssl);
let manager = MssqlConnectionManager(config);
let pool = bb8::Pool::builder()
.max_size(MAX)
@@ -1331,6 +1339,33 @@ mod tests {
use super::*;
use crate::source::{DbOptions, DbProperty};
fn sqlserver_debug(ssl: DbSslMode) -> String {
let mut config = tiberius::Config::new();
apply_sqlserver_ssl(&mut config, ssl);
format!("{config:?}")
}
#[test]
fn sqlserver_disable_does_not_require_tls() {
let debug = sqlserver_debug(DbSslMode::Disable);
assert!(debug.contains("encryption: NotSupported"), "{debug}");
assert!(debug.contains("bypass: false"), "{debug}");
}
#[test]
fn sqlserver_prefer_trusts_the_server_certificate() {
let debug = sqlserver_debug(DbSslMode::Prefer);
assert!(debug.contains("encryption: Required"), "{debug}");
assert!(debug.contains("bypass: true"), "{debug}");
}
#[test]
fn sqlserver_require_keeps_validated_tls() {
let debug = sqlserver_debug(DbSslMode::Require);
assert!(debug.contains("encryption: Required"), "{debug}");
assert!(debug.contains("bypass: false"), "{debug}");
}
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
+1
Переглянути файл
@@ -46,6 +46,7 @@ globset.workspace = true
ide-db.workspace = true
ide-ssh.workspace = true
ide-remote.workspace = true
ide-web.workspace = true
ide-jsonrpc.workspace = true
ide-agent.workspace = true
ide-notebook.workspace = true
+4
Переглянути файл
@@ -84,6 +84,9 @@ pub struct Host {
pub remote: Arc<ide_remote::RemoteService>,
/// What child processes get beyond this process's environment (Reload Environment, `remoteEnv`).
pub environment: environment::Environment,
/// The built-in web server (Open In › Browser, the editor's browser bar). On a remote host the
/// client forwards its ports.
pub web: ide_web::WebServer,
/// The HTTP proxy's saved password (see `proxy.rs`).
proxy_password: ide_ssh::secrets::SecretFile,
/// The proxy password when it could not be saved (no credential store).
@@ -140,6 +143,7 @@ impl Host {
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
web: ide_web::WebServer::new(),
global_settings,
dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new(
data_dir.join("cache/dependency-versions"),
+20 -7
Переглянути файл
@@ -5,9 +5,9 @@ use crate::Host;
use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput,
NewAgentTask,
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
McpScope, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -304,16 +304,28 @@ pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<A
host.project(window)?.agents.changes(&task).await
}
/// The task's uncommitted changes and the commits made since it started.
pub async fn grouped_changes(host: &Host, window: &str, task: String) -> IdeResult<AgentChanges> {
host.require_trust(window)?;
host.project(window)?.agents.grouped_changes(&task).await
}
/// Content of `path` when the task started (left side of its diff).
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
host.require_trust(window)?;
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
}
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Option<Vec<String>>) -> IdeResult<()> {
/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them.
pub async fn rollback_changes(
host: &Host,
window: &str,
task: String,
paths: Option<Vec<String>>,
view: Option<AgentChangesView>,
) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.rollback_changes(&task, paths).await
host.project(window)?.agents.rollback_changes(&task, paths, view.unwrap_or_default()).await
}
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
@@ -476,9 +488,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await },
);
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
d.add("agent_grouped_changes", |c| async move { grouped_changes(&c.host, &c.window, c.arg("task")?).await });
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
d.add("agent_rollback_changes", |c| async move {
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?, c.arg("view")?).await
});
d.add("agent_accept_changes", |c| async move {
accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
+1
Переглянути файл
@@ -35,6 +35,7 @@ pub mod syntax;
pub mod term;
pub mod tools;
pub mod vcs;
pub mod web;
use crate::Host;
use ide_core::{IdeError, IdeResult};
+12 -1
Переглянути файл
@@ -164,10 +164,21 @@ pub fn set_settings(
// A LightEdit window has no project layer: its values are global.
let project_scope = matches!(scope, SettingsScope::Project) && current.as_ref().is_some_and(|p| !p.light);
let is_proxy = key == "proxy" || key.starts_with("proxy.");
let Some(value) = host.take_proxy_password(key, value)? else {
let Some(mut value) = host.take_proxy_password(key, value)? else {
host.apply_proxy();
return Ok(SettingsApplied { settings: settings(host, window), project_scope, roots_changed: None });
};
// One canonical spelling per linked POM, including a project outside the opened root.
if project_scope
&& key == "languages.java.maven.linkedProjects"
&& let Some(project) = current.as_ref()
&& let Some(list) = value.as_array()
{
let poms: Vec<String> = list.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect();
if poms.len() == list.len() {
value = serde_json::json!(ide_build::maven::normalize_linked_poms(&project.root, &poms));
}
}
let settings = match &current {
Some(p) if project_scope => p.settings.set(scope, key, value)?,
Some(p) => {
+48 -8
Переглянути файл
@@ -3,6 +3,7 @@ use crate::Host;
use ide_core::remote_settings::RunTargetKind;
use ide_core::{IdeError, IdeResult, RunId};
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
use std::path::Path;
use std::time::Duration;
/// Detected configs followed by user-defined ones (user ids win on conflict).
@@ -138,11 +139,24 @@ pub fn save_configs(
ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids)
}
/// The project `config` runs in (a workspace member, by its working directory) and its settings.
/// The project `config` runs in (a workspace member, by its working directory) and its
/// settings, with the primary project as the inherited fallback: a member with no Python
/// of its own runs on the primary project's interpreter (see `apply_to_run_configs_in`).
fn settings_for(
host: &Host,
project: &crate::project::Project,
config: &mut RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings, Option<std::path::PathBuf>)> {
let (root, settings) = settings_only(host, project, config)?;
let fallback = (root != project.root).then_some(project.root.clone());
Ok((root, settings, fallback))
}
/// [`settings_for`] without the fallback, for callers that only rebase or read settings.
fn settings_only(
host: &Host,
project: &crate::project::Project,
config: &mut RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
let owner = ide_run::scope::owner(&project.root, config);
@@ -180,8 +194,8 @@ pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> Id
if c.kind == "gradle" {
return Ok(c);
}
let (root, settings) = settings_for(host, &project, &mut c)?;
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
let (root, settings, fallback) = settings_for(host, &project, &mut c)?;
Ok(ide_sdk::apply::without_derived_in(&c, &root, fallback.as_deref(), &settings))
})
.collect()
}
@@ -194,8 +208,13 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
.into_iter()
.map(|mut c| {
if c.kind != "gradle" {
let (root, settings) = settings_for(host, &project, &mut c)?;
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
let (root, settings, fallback) = settings_for(host, &project, &mut c)?;
ide_sdk::apply::apply_to_run_configs_in(
std::slice::from_mut(&mut c),
&root,
fallback.as_deref(),
&settings,
);
}
Ok(c)
})
@@ -329,7 +348,7 @@ async fn start_inner(
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
}
let mut config = config;
let (root, settings) = settings_for(host, &project, &mut config)?;
let (root, settings, fallback) = settings_for(host, &project, &mut config)?;
let root = &root;
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
// Maven re-applies too: a config listed before a settings change (daemon toggle,
@@ -338,17 +357,38 @@ async fn start_inner(
if config.kind == "maven" {
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
} else if matches!(config.kind.as_str(), "gradle" | "tomcat" | "junit") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
ide_sdk::apply::apply_to_run_configs_in(
std::slice::from_mut(&mut config),
root,
fallback.as_deref(),
&settings,
);
} else {
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
}
if config.kind == "go" {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
ide_sdk::apply::apply_to_run_configs_in(
std::slice::from_mut(&mut config),
root,
fallback.as_deref(),
&settings,
);
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
}
if config.command == ide_sdk::godot::COMMAND {
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
}
// A Python configuration pointing at a missing interpreter fails in the depth of
// process spawn otherwise; name the cause and the fix like PyCharm's "Invalid SDK".
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
&& !Path::new(&config.command).is_file()
&& Path::new(&config.command).components().count() > 1
{
return Err(IdeError::invalid(format!(
"The configured Python interpreter does not exist: {}\nSelect a working interpreter in Settings › Languages & Frameworks › Python Interpreter.",
config.command
)));
}
if config.kind == "tomcat" {
if host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
return Err(IdeError::invalid("This Tomcat configuration is already running"));
+8 -1
Переглянути файл
@@ -69,7 +69,14 @@ pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) ->
}
None => (project.root.clone(), project.settings.effective()),
};
Ok(ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).map(Into::into))
// A member with no Python of its own falls back to the primary project's
// (the same chain run configurations use), so every answer names one interpreter.
let interpreter = ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).or_else(|| {
(root != project.root)
.then(|| ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()))
.flatten()
});
Ok(interpreter.map(Into::into))
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
+18
Переглянути файл
@@ -161,6 +161,23 @@ pub async fn jdk_download(
Ok(sdk)
}
/// The Java version the jdtls this window launches needs; `None` while it isn't
/// installed or doesn't tell.
pub async fn jdtls_required_java(host: &Host, window: &str) -> IdeResult<Option<u32>> {
let settings = match host.project(window) {
Ok(project) => project.settings.effective(),
Err(_) => host.global_settings.effective(),
};
let Some(mut cfg) =
languages(host, window).into_iter().flat_map(|l| l.language_servers).find(|s| s.name == "jdtls")
else {
return Ok(None);
};
ide_sdk::apply::server_launch_overrides(&mut cfg, &settings);
ide_sdk::apply::server_protocol_overrides(&mut cfg, &settings);
tokio::task::spawn_blocking(move || ide_sdk::java::jdtls_required_java(&cfg)).await.map_err(join_err)
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("tools_list", |c| async move { tools_list(&c.host, &c.window).await });
@@ -173,6 +190,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
});
d.add("jdk_download_location", |c| async move { jdk_download_location(&c.host, &c.window) });
d.add("jdtls_required_java", |c| async move { jdtls_required_java(&c.host, &c.window).await });
d.add("jdk_download", |c| async move {
jdk_download(
&c.host,
+20
Переглянути файл
@@ -373,6 +373,25 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
.await
}
/// The file's git mode at `rev` (`100644`, `100755`, `120000`…); `"WT"` reads the
/// worktree's. `None` when the path is absent on that side. The diff viewer shows
/// "File mode changed" when the texts compare equal but the modes differ.
pub async fn vcs_file_mode(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
}
external
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
.file_mode_at(&path, rev.as_deref())
})
.await
}
pub async fn vcs_blame(
host: &Host,
window: &str,
@@ -1392,6 +1411,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?).await });
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
d.add("vcs_file_mode", |c| async move { vcs_file_mode(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
d.add("vcs_blame", |c| async move {
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
});
+11
Переглянути файл
@@ -0,0 +1,11 @@
//! The built-in web server's URLs.
use crate::Host;
use ide_core::IdeResult;
use std::path::Path;
/// The URL serving `file`, under the window's project when it holds the file.
pub async fn url(host: &Host, window: &str, file: &Path) -> IdeResult<String> {
let root = host.project(window).ok().map(|p| p.root.clone());
host.web.url_for(root.as_deref(), file).await
}
+35 -3
Переглянути файл
@@ -142,6 +142,10 @@ impl Host {
crate::plugins::injections::configure_lsp(&lsp);
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
lsp.set_content_roots(extra_roots.clone());
lsp.set_jdtls_import_roots(ide_build::maven_external_roots(
&root,
&settings.effective().languages.java.maven.linked_projects,
));
let settings = Arc::new(settings);
{
let settings = settings.clone();
@@ -1120,6 +1124,10 @@ impl Project {
}
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
self.lsp.set_content_roots(roots.clone());
self.lsp.set_jdtls_import_roots(ide_build::maven_external_roots(
&self.root,
&self.settings.effective().languages.java.maven.linked_projects,
));
self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone());
self.watcher.set_excludes(&excludes);
let mut watchers = self.content_watchers.lock();
@@ -1239,8 +1247,9 @@ pub(crate) fn workspace_member_roots(root: &Path) -> Vec<PathBuf> {
.unwrap_or_default()
}
/// Workspace members in manifest order, or extra Project Structure directories and the
/// Gradle project folders outside the project as of the last sync (`build_cache`).
/// Workspace members in manifest order, or extra Project Structure directories, Gradle
/// project folders outside the project, and directories of Maven POMs linked from
/// outside it (`build_cache` is the Gradle sync cache).
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
if workspace.is_container(root) {
@@ -1332,7 +1341,10 @@ fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, bui
.iter()
.chain(settings.project_structure.dependencies.iter())
.map(|r| PathBuf::from(r.trim()));
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
for p in user
.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle))
.chain(ide_build::maven_external_roots(root, &settings.languages.java.maven.linked_projects))
{
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
}
@@ -1768,6 +1780,26 @@ mod maven_linkage_tests {
assert_eq!(super::jdtls_maven_import(root, &settings).1, None);
}
#[test]
fn external_maven_link_is_a_content_root_and_not_its_sibling_module() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let a = base.join("A");
let b = base.join("B");
let c = base.join("C");
for dir in [&a, &b, &c] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("pom.xml"), "<project/>").unwrap();
}
let mut settings = ide_core::settings::Settings::default();
settings.languages.java.maven.linked_projects = vec!["../B/pom.xml".into()];
assert_eq!(super::content_roots(&settings, &a, &base.join("cache")), vec![b]);
// External links must not replace jdtls's scan of the opened root with a build-file list.
assert_eq!(super::jdtls_maven_import(&a, &settings), (Vec::new(), None));
settings.languages.java.maven.linked_projects.clear();
assert!(super::content_roots(&settings, &a, &base.join("cache")).is_empty());
}
#[test]
fn unlinked_roots_remain_plain_files() {
let tmp = tempfile::tempdir().unwrap();
+1
Переглянути файл
@@ -265,6 +265,7 @@ impl Dispatcher {
ops::run::start_request(&c.host, &c.window, c.arg("config")?, on_output, c.arg("request")?).await
})
.add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) })
.add("web_url", |c| async move { ops::web::url(&c.host, &c.window, &c.arg::<PathBuf>("path")?).await })
.add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
+1
Переглянути файл
@@ -254,6 +254,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "jdk_download_releases"
| "jdk_download_location"
| "jdk_download"
| "jdtls_required_java"
| "run_system_env"
| "run_gradle_jvm_args_script"
| "proxy_check_connection"
+71
Переглянути файл
@@ -0,0 +1,71 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use ide_workspace::settings::SettingsScope;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_maven_link_persists_reloads_and_unlink_keeps_files() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("A");
let sibling = base.join("B");
std::fs::create_dir_all(root.join("core")).unwrap();
std::fs::create_dir_all(&sibling).unwrap();
let a_pom = "<project><artifactId>a</artifactId><packaging>pom</packaging><modules><module>core</module></modules></project>";
let b_pom = "<project><artifactId>b</artifactId></project>";
std::fs::write(root.join("pom.xml"), a_pom).unwrap();
std::fs::write(root.join("core/pom.xml"), "<project><artifactId>core</artifactId></project>").unwrap();
std::fs::write(sibling.join("pom.xml"), b_pom).unwrap();
let config = base.join("config");
let host = Host::new(HostConfig {
config_dir: config.clone(),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
let written =
serde_json::to_string(&vec!["../B/pom.xml".to_owned(), sibling.join("pom.xml").to_string_lossy().into_owned()])
.unwrap();
ops::project::set_settings(&host, "main", SettingsScope::Project, "languages.java.maven.linkedProjects", &written)
.unwrap();
let saved =
|| ide_workspace::SettingsStore::load(&config, Some(&root)).effective().languages.java.maven.linked_projects;
let canonical = dunce::canonicalize(sibling.join("pom.xml")).unwrap().to_string_lossy().replace('\\', "/");
assert_eq!(saved(), vec![canonical.clone()]);
let model = ops::build::build_model(&host, "main").await.unwrap();
let projects = &model.maven.unwrap().projects;
assert_eq!(projects.iter().filter(|p| p.artifact_id == "a").count(), 1);
let b = projects.iter().find(|p| p.artifact_id == "b").unwrap();
assert!(!b.aggregated);
assert_eq!(b.pom, canonical);
assert_eq!(dunce::canonicalize(root.join(&b.dir)).unwrap(), sibling);
assert_eq!(host.project("main").unwrap().content_roots(), vec![sibling.clone()]);
assert_eq!(host.project("main").unwrap().lsp.jdtls_import_roots(), vec![sibling.clone()]);
let reloaded = ops::build::build_model(&host, "main").await.unwrap();
assert_eq!(
reloaded.maven.unwrap().projects.iter().map(|p| p.pom.clone()).collect::<Vec<_>>(),
projects.iter().map(|p| p.pom.clone()).collect::<Vec<_>>()
);
assert_eq!(saved(), vec![canonical]);
ops::project::set_settings(&host, "main", SettingsScope::Project, "languages.java.maven.linkedProjects", "[]")
.unwrap();
let gone = ops::build::build_model(&host, "main").await.unwrap();
assert!(gone.maven.unwrap().projects.iter().all(|p| p.artifact_id != "b"));
assert!(host.project("main").unwrap().content_roots().is_empty());
assert!(host.project("main").unwrap().lsp.jdtls_import_roots().is_empty());
assert_eq!(saved(), Vec::<String>::new());
assert_eq!(std::fs::read_to_string(root.join("pom.xml")).unwrap(), a_pom);
assert_eq!(std::fs::read_to_string(sibling.join("pom.xml")).unwrap(), b_pom);
host.shutdown().await;
}
+6
Переглянути файл
@@ -126,6 +126,12 @@ async fn serves_projects_files_documents_search_and_runs() {
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
assert_eq!(current["name"], "demo");
// The built-in web server runs where the project is; the client forwards its port.
let root = std::path::PathBuf::from(opened["root"].as_str().unwrap());
let url = invoke(&peer, "web_url", json!({ "path": root.join("src/main.py") })).await.unwrap();
let url = url.as_str().unwrap();
assert!(url.starts_with("http://127.0.0.1:") && url.ends_with("/demo/src/main.py"), "{url}");
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
+1
Переглянути файл
@@ -53,6 +53,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"jdk_download_releases",
"jdk_download_location",
"jdk_download",
"jdtls_required_java",
"scaffold_catalog",
"scaffold_options",
"scaffold_requirements",
+50
Переглянути файл
@@ -105,6 +105,56 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn python_effective_interpreter_falls_back_to_the_primary_project() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let primary = base.join("primary");
let venv_python =
if cfg!(windows) { primary.join(".venv/Scripts/python.exe") } else { primary.join(".venv/bin/python") };
std::fs::create_dir_all(venv_python.parent().unwrap()).unwrap();
std::fs::write(primary.join(".venv/pyvenv.cfg"), "home = /usr/bin\n").unwrap();
std::fs::write(&venv_python, "").unwrap();
let member = base.join("attached");
std::fs::create_dir_all(member.join("src")).unwrap();
// The primary project with an attached folder (the "attach" flow, not a container workspace).
Workspace::attach(&primary, "Primary", vec![primary.clone(), member.clone()]).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&primary, true, false).unwrap();
host.trust.decide(&member, true, false).unwrap();
host.open_project("main", &primary).unwrap();
let python = |near: Option<&std::path::Path>| {
ops::sdk::sdk_python_effective(&host, "main", near.map(|p| p.to_path_buf())).unwrap().map(|p| p.into_path_buf())
};
// The primary project's own virtualenv, and the bare member inheriting it.
assert_eq!(python(Some(&primary)), Some(venv_python.clone()));
assert_eq!(python(Some(&member.join("src"))), Some(venv_python.clone()));
// The member's own settings win: an explicit interpreter configured for it
// (its file is created here — a missing one would be skipped as invalid).
let configured = member.join("selected").join(if cfg!(windows) { "python.exe" } else { "python" });
std::fs::create_dir_all(configured.parent().unwrap()).unwrap();
std::fs::write(&configured, "").unwrap();
ide_workspace::SettingsStore::load(&base.join("config"), Some(&member))
.set(
ide_workspace::SettingsScope::Project,
"languages.python.interpreter",
serde_json::to_value(configured.to_string_lossy().into_owned()).unwrap(),
)
.unwrap();
assert_eq!(python(Some(&member.join("src"))), Some(configured));
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn php_language_level_follows_the_member_of_the_file() {
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
+1
Переглянути файл
@@ -55,6 +55,7 @@ tree-sitter-xml = "0.7.0"
tree-sitter-haskell = "0.24.1"
tree-sitter-dart = "0.2.0"
tree-sitter-graphql = "0.3.0"
tree-sitter-prisma-io = "1.6.0"
tree-sitter-gdscript = "6.1.0"
[dev-dependencies]
+40
Переглянути файл
@@ -0,0 +1,40 @@
; Prisma schema highlighting (our captures, not the grammar crate's). Later patterns win.
(identifier) @variable
(developer_comment) @comment
(comment) @comment.doc
(number) @number
(string) @string
[(true) (false) (null)] @keyword
(variable) @property
(column_declaration . (identifier) @property)
(type_expression . (identifier) @variable.parameter)
(enumeral (identifier) @constant)
(column_type (identifier) @type)
(column_type (call_expression (identifier) @type))
(call_expression (identifier) @function)
(model_declaration (identifier) @type)
(view_declaration (identifier) @type)
(type_declaration (identifier) @type)
(type_declaration (type_declaration_type) @type)
(enum_declaration (identifier) @type)
(datasource_declaration (identifier) @type)
(generator_declaration (identifier) @type)
(attribute (identifier) @attribute)
(attribute (call_expression (identifier) @attribute))
(attribute (member_expression (identifier) @attribute (property_identifier) @attribute))
(attribute (call_expression (member_expression (identifier) @attribute (property_identifier) @attribute)))
(block_attribute_declaration (identifier) @attribute)
(block_attribute_declaration (call_expression (identifier) @attribute))
["@" "@@"] @attribute
["datasource" "enum" "generator" "model" "type" "view"] @keyword
["(" ")" "[" "]" "{" "}"] @punctuation.bracket
["," ":" "."] @punctuation.delimiter
(maybe) @operator
"=" @operator
+8
Переглянути файл
@@ -0,0 +1,8 @@
(model_declaration (identifier) @name) @def.class
(view_declaration (identifier) @name) @def.class
(type_declaration . (identifier) @name (statement_block)) @def.struct
(enum_declaration (identifier) @name) @def.enum
(enumeral (identifier) @name) @def.enum_member
(datasource_declaration (identifier) @name) @outline.module
(generator_declaration (identifier) @name) @outline.module
(column_declaration . (identifier) @name) @def.field
+2
Переглянути файл
@@ -67,6 +67,7 @@ const BUILTIN: &[(&str, &str)] = &[
("php", include_str!("../../../languages/php.toml")),
("plaintext", include_str!("../../../languages/plaintext.toml")),
("powershell", include_str!("../../../languages/powershell.toml")),
("prisma", include_str!("../../../languages/prisma.toml")),
("properties", include_str!("../../../languages/properties.toml")),
("proto", include_str!("../../../languages/proto.toml")),
("python", include_str!("../../../languages/python.toml")),
@@ -1026,6 +1027,7 @@ mod tests {
("CMakeLists.txt", "cmake"),
("api.proto", "proto"),
("schema.graphql", "graphql"),
("schema.prisma", "prisma"),
("main.tf", "terraform"),
("prod.tfvars", "terraform-vars"),
("job.nomad", "hcl"),
+10
Переглянути файл
@@ -430,6 +430,16 @@ pub(crate) static SPECS: &[Spec] = &[
imports: NONE,
constructors: NONE,
},
Spec {
name: "prisma",
language: tree_sitter_prisma_io::LANGUAGE,
outline: outline!("prisma"),
highlights: Some(Highlights { query: highlights!("prisma"), first_wins: false }),
fold_nodes: NONE,
fold_bodies: NONE,
imports: NONE,
constructors: NONE,
},
Spec {
name: "make",
language: tree_sitter_make::LANGUAGE,
+22
Переглянути файл
@@ -501,6 +501,28 @@ mod tests {
("NEWHOPE", K::Constant),
],
),
(
"prisma",
"// hi\n/// Doc\nmodel User {\n id Int @id @default(autoincrement())\n email String? @db.VarChar(255)\n role Role\n @@index([email])\n}\nenum Role { ADMIN }\ndatasource db {\n provider = \"postgresql\"\n}\n",
&[
("// hi", K::Comment),
("/// Doc", K::DocComment),
("model", K::Keyword),
("User", K::Type),
("id", K::Property),
("Int", K::Type),
("@id", K::Attribute),
("@default", K::Attribute),
("autoincrement", K::FunctionCall),
("VarChar", K::Attribute),
("255", K::Number),
("Role", K::Type),
("@@index", K::Attribute),
("ADMIN", K::Constant),
("provider", K::Property),
("\"postgresql\"", K::String),
],
),
(
"hcl",
"# hi\nresource \"aws_instance\" \"web\" {\n ami = var.ami\n count = 2\n tags = { Name = \"web-${var.env}\" }\n name = upper(\"x\")\n}\n",
+11
Переглянути файл
@@ -886,6 +886,17 @@ fn outline_more_languages() {
Function GetUser
",
);
check_outline(
"prisma",
"datasource db {\n provider = \"sqlite\"\n}\nmodel User {\n id Int @id\n @@map(\"users\")\n}\nenum Role {\n ADMIN\n}\n",
"
Module db
Class User
Field id
Enum Role
EnumMember ADMIN
",
);
check_outline(
"nix",
"{ pkgs, ... }:\nlet\n foo = 1;\n bar = x: x;\nin { inherit foo; }\n",
+3
Переглянути файл
@@ -430,6 +430,9 @@ 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() {
self.server.set_java_ready();
}
mgr.java_import_ready(self.server.clone(), self.generation);
}
"language/status" if self.server.name == "jdtls" && params["type"] == "ProjectStatus" => {
+58 -8
Переглянути файл
@@ -201,6 +201,10 @@ pub struct LspManager {
me: Weak<LspManager>,
project_root: PathBuf,
content_roots: RwLock<Vec<PathBuf>>,
/// Maven projects linked from outside `project_root`. jdtls scans them as extra
/// workspace folders; `projectConfigurations` stays unset so the opened root is
/// still discovered automatically.
jdtls_import_roots: RwLock<Vec<PathBuf>>,
languages: Arc<LanguageRegistry>,
sink: SharedSink,
launcher: Arc<dyn Launcher>,
@@ -444,6 +448,7 @@ impl LspManager {
me: me.clone(),
project_root,
content_roots: RwLock::new(Vec::new()),
jdtls_import_roots: RwLock::new(Vec::new()),
languages,
sink: {
let me = me.clone();
@@ -503,6 +508,28 @@ impl LspManager {
}
}
/// Directories of Maven projects linked from outside the opened root. The jdtls
/// rooted at the opened project scans them together with that root. An empty list
/// leaves discovery as the single `rootUri` scan.
pub fn set_jdtls_import_roots(&self, mut roots: Vec<PathBuf>) {
roots.retain(|root| root.is_absolute() && root.is_dir() && !root.starts_with(&self.project_root));
roots.sort();
roots.dedup();
let changed = {
let mut current = self.jdtls_import_roots.write();
let changed = *current != roots;
*current = roots;
changed
};
if changed && let Some(me) = self.me.upgrade() {
me.reconfigure_soon();
}
}
pub fn jdtls_import_roots(&self) -> Vec<PathBuf> {
self.jdtls_import_roots.read().clone()
}
/// Shares JDT LS within this manager; callers reconfigure after the hook's value changes.
pub fn set_jdtls_shared_hook(&self, hook: Box<JdtlsSharedFn>) {
*self.jdtls_shared_hook.write() = Some(Arc::from(hook));
@@ -537,10 +564,29 @@ impl LspManager {
if state.shared {
return state.roots.clone();
}
// The opened project's server also scans linked external Maven projects.
// Other in-root builds keep their own server and their own root.
if server.root == self.project_root {
return self.project_and_import_roots();
}
}
vec![server.root.clone()]
}
fn project_and_import_roots(&self) -> Vec<PathBuf> {
let mut roots = vec![self.project_root.clone()];
for root in self.jdtls_import_roots.read().iter() {
if root.is_absolute() && root.is_dir() && !roots.contains(root) {
roots.push(root.clone());
}
}
roots
}
fn imports_external_java(&self, config: &LanguageServerConfig, path: &Path) -> bool {
is_local_jdtls(config) && self.jdtls_import_roots.read().iter().any(|root| path.starts_with(root))
}
fn project_root_for(&self, path: &Path) -> PathBuf {
self.content_roots
.read()
@@ -557,7 +603,9 @@ impl LspManager {
let mut servers = self.servers.lock();
let project = self.project_root_for(path);
let sketch = (cfg.name == arduino::SERVER).then(|| arduino::sketch_root(&project, path)).flatten();
let root = if shared.as_ref().is_some_and(|state| state.shared) {
let root = if shared.as_ref().is_some_and(|state| state.shared) || self.imports_external_java(cfg, path) {
// One server for the opened root and a linked external Maven project, so
// Java completion and navigation see the import jdtls made of both.
self.project_root.clone()
} else {
sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| {
@@ -953,13 +1001,15 @@ impl LspManager {
}
init_options["settings"] = handler::all_settings(&cfg);
let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared;
if shared {
init_options["workspaceFolders"] = json!(
self.server_workspace_roots(server)
.iter()
.map(|root| uri::path_to_uri_string(root))
.collect::<Vec<_>>()
);
// jdtls imports `initializationOptions.workspaceFolders` by scanning each
// directory. It ignores the LSP workspace folder list for that first import,
// and a `projectConfigurations` list replaces the scan. Extra Maven projects
// are therefore folders here, not an explicit build-file list, so projects
// under the opened root are still discovered.
let folders: Vec<String> =
self.server_workspace_roots(server).iter().map(|root| uri::path_to_uri_string(root)).collect();
if shared || folders.len() > 1 {
init_options["workspaceFolders"] = json!(folders);
}
let triggers: Vec<_> = self
.docs
+4 -4
Переглянути файл
@@ -80,7 +80,7 @@ pub(crate) struct Widened<R> {
impl<R> Widened<R> {
/// An answer from the server's analysis scope alone; `empty`: it found nothing.
pub fn only(first: R, empty: bool, server: &Server) -> Self {
let incomplete = (empty && server.is_busy()).then_some(ReferencesIncomplete::Indexing);
let incomplete = (empty && server.indexing()).then_some(ReferencesIncomplete::Indexing);
Self { first, second: None, incomplete }
}
}
@@ -130,7 +130,7 @@ impl LspManager {
F: Fn() -> Fut,
Fut: Future<Output = IdeResult<R>>,
{
let busy = t.server.is_busy();
let indexing = t.server.indexing();
// gopls searches the view of the file asked about; files opened for it only get views of
// their own (see `go_scope`).
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
@@ -143,7 +143,7 @@ impl LspManager {
};
let (first, candidates) = futures::join!(query(), candidates);
let mut first = first?;
if busy && covered(&first).is_empty() && t.server.wait_idle(INDEX_WAIT, cancel).await {
if indexing && covered(&first).is_empty() && t.server.wait_indexed(INDEX_WAIT, cancel).await {
first = query().await?;
}
if cancel.is_cancelled() {
@@ -151,7 +151,7 @@ impl LspManager {
}
// Servers report analysis progress often; only "nothing found" while it lasts is suspect.
let mut incomplete =
(t.server.is_busy() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing);
(t.server.indexing() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing);
let Some(candidates) = candidates else { return Ok(Widened { first, second: None, incomplete }) };
if candidates.limit_hit {
incomplete = incomplete.or(Some(ReferencesIncomplete::TooManyFiles));
+28 -8
Переглянути файл
@@ -127,7 +127,7 @@ pub struct Server {
published: Mutex<HashMap<PathBuf, Instant>>,
/// Woken on every `publishDiagnostics` (Inspect Code waits on it).
pub published_notify: Notify,
/// Woken when the last `$/progress` task ends.
/// Woken when an empty answer stops being suspect (see [`Server::indexing`]).
idle_notify: Notify,
/// `experimental/serverStatus` (rust-analyzer): `None` until the server sends
/// one, then whether it is still loading the project.
@@ -148,6 +148,8 @@ pub struct Server {
/// The instance whose just registered external project is loading, until an open file's
/// diagnostics arrive (0: none).
pub external_loading: std::sync::atomic::AtomicU64,
/// jdtls sent `ServiceReady` on this instance: the project is imported.
java_ready: std::sync::atomic::AtomicBool,
/// kotlin-lsp's project import as the current instance last reported it.
pub(crate) import_state: watch::Sender<Option<crate::kotlin_lsp::ImportState>>,
}
@@ -195,6 +197,7 @@ impl Server {
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
external_loading: std::sync::atomic::AtomicU64::new(0),
java_ready: std::sync::atomic::AtomicBool::new(false),
import_state: watch::channel(None).0,
}
}
@@ -596,6 +599,22 @@ impl Server {
self.phase_ended();
}
/// Whether an empty search answer may only mean the server has not indexed the project
/// yet. jdtls' searches wait for its index themselves, and the progress it reports once
/// the project is imported is background work (builds, validation): only the import counts.
pub fn indexing(&self) -> bool {
if self.name == "jdtls" {
return self.is_busy() && !self.java_ready.load(std::sync::atomic::Ordering::Acquire);
}
self.is_busy()
}
/// Records jdtls' `ServiceReady`.
pub fn set_java_ready(&self) {
self.java_ready.store(true, std::sync::atomic::Ordering::Release);
self.phase_ended();
}
/// Whether progress token `token` is still running on this instance.
pub fn has_progress(&self, token: &str) -> bool {
self.progress.lock().contains_key(token)
@@ -613,7 +632,7 @@ impl Server {
fn phase_ended(&self) {
self.phase_notify.notify_waiters();
if !self.is_busy() {
if !self.indexing() {
self.idle_notify.notify_waiters();
}
}
@@ -623,17 +642,17 @@ impl Server {
self.phase_notify.notified()
}
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
/// Waits until [`Server::indexing`] ends; false on timeout or cancellation.
pub async fn wait_indexed(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let notified = self.idle_notify.notified();
if !self.is_busy() || self.running().is_none() {
return !self.is_busy();
if !self.indexing() || self.running().is_none() {
return !self.indexing();
}
tokio::select! {
() = notified => {}
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
() = tokio::time::sleep_until(deadline) => return !self.indexing(),
() = cancel.cancelled() => return false,
}
}
@@ -723,6 +742,7 @@ impl Server {
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
*self.loading.lock() = None;
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
self.java_ready.store(false, std::sync::atomic::Ordering::Release);
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
}
@@ -957,7 +977,7 @@ mod tests {
stalled: true,
},
);
assert!(!s.is_busy(), "a stuck task must not hold wait_idle or ReferencesIncomplete::Indexing");
assert!(!s.is_busy(), "a stuck task must not hold wait_indexed or ReferencesIncomplete::Indexing");
s.progress.lock().get_mut("p").unwrap().stalled = false;
assert!(s.is_busy(), "a reporting task keeps the server busy");
}
+67
Переглянути файл
@@ -759,6 +759,40 @@ async fn java_classpath_does_not_retry_permanently_unavailable_servers() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn external_maven_import_joins_the_opened_root_without_replacing_discovery() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let external = tempfile::tempdir().unwrap();
let external = dunce::canonicalize(external.path()).unwrap();
std::fs::write(external.join("pom.xml"), "<project><artifactId>b</artifactId></project>").unwrap();
let outside = external.join("src/Main.java");
std::fs::create_dir_all(outside.parent().unwrap()).unwrap();
std::fs::write(&outside, "class External {}").unwrap();
let inside = h.root.join("src/App.java");
std::fs::create_dir_all(inside.parent().unwrap()).unwrap();
std::fs::write(&inside, "class App {}").unwrap();
// An in-root build with its own marker keeps a separate server.
let nested = h.root.join("nested");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("mock.toml"), "").unwrap();
let nested_file = nested.join("Nested.java");
std::fs::write(&nested_file, "class Nested {}").unwrap();
h.mgr.set_jdtls_import_roots(vec![external.clone()]);
h.mgr.did_open(DocId::next(), &inside, &h.lang, 1, "class App {}");
h.mgr.did_open(DocId::next(), &outside, &h.lang, 1, "class External {}");
h.mgr.did_open(DocId::next(), &nested_file, &h.lang, 1, "class Nested {}");
let state = h.launcher.state("jdtls");
eventually(|| state.requested("initialize").len() == 2).await;
let inits = state.requested("initialize");
let project = inits.iter().find(|init| init["rootUri"] == uri_of(&h.root)).unwrap();
assert_eq!(project["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root), uri_of(&external)]));
assert!(project["initializationOptions"].get("projectConfigurations").is_none());
assert!(state.requested("initialize").iter().any(|init| init["rootUri"] == uri_of(&nested)));
assert_eq!(h.mgr.servers().len(), 2);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn shared_java_external_roots_reach_initialization_queries_and_watches() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
@@ -4925,6 +4959,39 @@ async fn scoped_java_usages_search_the_sources_unless_libraries_are_asked_for()
assert!(seen.lock().is_empty());
}
/// jdtls' searches wait for its index themselves, and it reports background work (builds,
/// validation) for long stretches: a method nothing calls is answered at once meanwhile.
#[tokio::test(flavor = "multi_thread")]
async fn java_usages_of_an_unused_method_do_not_wait_for_background_work() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
s.respond("workspace/executeCommand", json!([]));
let (doc, _) = h.open("a.java", "void unused() {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
s.peer()
.notify("$/progress", json!({ "token": "build", "value": { "kind": "begin", "title": "Building" } }))
.unwrap();
h.wait_status("jdtls", ServerStatus::Busy).await;
// Until the project is imported, finding nothing may only mean it is not indexed yet.
let refs = h.mgr.references(doc, Pos::new(0, 6), true, false, Default::default()).await.unwrap();
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing));
s.peer().notify("language/status", json!({"type": "ServiceReady", "message": "ServiceReady"})).unwrap();
for _ in 0..200 {
let refs = h.mgr.references(doc, Pos::new(0, 6), true, false, Default::default()).await.unwrap();
if refs.incomplete.is_none() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let started = std::time::Instant::now();
let refs = h.mgr.references(doc, Pos::new(0, 6), true, true, Default::default()).await.unwrap();
assert!(started.elapsed() < Duration::from_secs(2), "waited {:?}", started.elapsed());
assert!(refs.locations.is_empty());
assert_eq!(refs.incomplete, None);
}
/// A library method's usages in the sources are shown before the dependencies and the JDK
/// are searched, which takes seconds; scope widening searches the sources alone again.
#[tokio::test(flavor = "multi_thread")]
+1
Переглянути файл
@@ -14,6 +14,7 @@ serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tokio.workspace = true
futures.workspace = true
tracing.workspace = true
parking_lot.workspace = true
globset.workspace = true
+26 -8
Переглянути файл
@@ -10,6 +10,9 @@ use specta::Type;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Remote folders listed (or files checked) at the same time.
pub(crate) const LIST_CONCURRENCY: usize = 16;
/// Mapping eligibility uses a common synthetic server root: mapping and exclusion
/// paths are both relative to that root, so checking never opens a connection.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -199,20 +202,35 @@ impl Deployment<'_> {
}
/// Remote files under `dir` (recursively), excluded ones left out.
/// Files under `dir`, by path. Lists [`LIST_CONCURRENCY`] folders at a time: one round trip
/// per folder after another made large trees take minutes.
async fn remote_files(&self, dir: &str, out: &mut Vec<ide_ssh::RemoteEntry>) -> IdeResult<()> {
for e in self.fs.list(dir).await? {
use futures::stream::{FuturesUnordered, StreamExt};
let start = out.len();
let mut pending = vec![dir.to_owned()];
let mut listing = FuturesUnordered::new();
loop {
while listing.len() < LIST_CONCURRENCY
&& let Some(dir) = pending.pop()
{
listing.push(async move { self.fs.list(&dir).await });
}
let Some(entries) = listing.next().await else { break };
if self.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if self.exclude.remote(&e.path) {
continue;
}
if e.is_dir && !e.is_symlink {
Box::pin(self.remote_files(&e.path, out)).await?;
} else if !e.is_dir {
out.push(e);
for e in entries? {
if self.exclude.remote(&e.path) {
continue;
}
if e.is_dir && !e.is_symlink {
pending.push(e.path);
} else if !e.is_dir {
out.push(e);
}
}
}
out[start..].sort_by(|a, b| a.path.cmp(&b.path));
Ok(())
}
+32 -12
Переглянути файл
@@ -470,13 +470,27 @@ impl RemoteService {
preserve_time: true,
cancel: CancellationToken::new(),
};
let started = std::time::Instant::now();
out(OutputChunk::System { text: format!("Comparing project files with {remote_root}...\n") });
let mut progress = |_: &str, _: usize, _: usize| {};
let report = d.push_changes(&root, &mut progress).await;
let synced = sync_external_libraries(&fs, &libraries, preserve_time).await;
if report.is_ok() && !libraries.is_empty() {
out(OutputChunk::System {
text: format!("Checking {} external classpath entries...\n", libraries.len()),
});
}
let synced = match &report {
Ok(_) => sync_external_libraries(&fs, &libraries, preserve_time).await,
Err(_) => Ok(0),
};
fs.close().await;
let report = report?;
out(OutputChunk::System {
text: format!("Uploaded {} changed file(s) to {remote_root}\n", report.files),
text: format!(
"Uploaded {} changed file(s) to {remote_root} in {:.1}s\n",
report.files,
started.elapsed().as_secs_f64()
),
});
match report.errors.first() {
Some(e) => Err(IdeError::new(
@@ -517,9 +531,8 @@ async fn sync_external_libraries(
fn fail(path: &str, e: IdeError) -> IdeError {
IdeError::new(ide_core::error::ErrorKind::Io, format!("sync of {path} failed: {}", e.message))
}
let mut synced = 0;
let mut files = Vec::new();
for (local, remote_base) in libraries {
let mut files = Vec::new();
if local.is_dir() {
let walk = ignore::WalkBuilder::new(local).standard_filters(false).follow_links(false).build();
for entry in walk.flatten() {
@@ -534,7 +547,11 @@ async fn sync_external_libraries(
} else {
files.push((local.clone(), remote_base.clone()));
}
for (file, remote) in files {
}
// The remote copies are checked several at a time (one round trip each); uploads follow.
use futures::stream::{self, StreamExt, TryStreamExt};
let stale: Vec<(PathBuf, String)> = stream::iter(files)
.map(|(file, remote)| async move {
let size = std::fs::metadata(&file).map_err(|e| fail(&remote, e.into()))?.len();
let local_stamp = FileStamp { size, mtime: mtime_of(&file) };
let remote_stamp = match fs.stat(&remote).await.map_err(|e| fail(&remote, e))? {
@@ -544,14 +561,17 @@ async fn sync_external_libraries(
Some(e) => Some(FileStamp { size: e.size, mtime: e.mtime }),
None => None,
};
if matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same) {
continue;
}
fs.upload(&file, &remote, preserve_time).await.map_err(|e| fail(&remote, e))?;
synced += 1;
}
let same = matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same);
Ok((!same).then_some((file, remote)))
})
.buffered(crate::deploy::LIST_CONCURRENCY)
.try_filter_map(|x| async move { Ok(x) })
.try_collect()
.await?;
for (file, remote) in &stale {
fs.upload(file, remote, preserve_time).await.map_err(|e| fail(remote, e))?;
}
Ok(synced)
Ok(stale.len())
}
fn sanitize(s: &str) -> String {
+14 -2
Переглянути файл
@@ -263,10 +263,15 @@ fn target_command(
RemoteCommand { program, args, cwd, env, shell_line: None }
}
/// How long Stop waits for the channel to close after `KILL`.
const KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
struct RemoteRun {
info: Mutex<RunInfo>,
process: Mutex<Option<Arc<RemoteProcess>>>,
cancel: CancellationToken,
/// Stop gave up waiting for the channel to report the exit: the run finishes locally.
abandon: CancellationToken,
stopping: AtomicBool,
/// The runtime it started on: stdin / stop requests may come from threads without one.
runtime: tokio::runtime::Handle,
@@ -318,6 +323,7 @@ impl RemoteRuns {
info: Mutex::new(info.clone()),
process: Mutex::new(None),
cancel: CancellationToken::new(),
abandon: CancellationToken::new(),
stopping: AtomicBool::new(false),
runtime: tokio::runtime::Handle::current(),
});
@@ -361,7 +367,8 @@ impl RemoteRuns {
Ok(())
}
/// `INT`, then `KILL` (closing the channel) after `grace`.
/// `INT`, then `KILL` (closing the channel) after `grace`; a channel that still reports no
/// exit after another [`KILL_WAIT`] is left behind and the run finishes as stopped.
pub fn stop(&self, id: RunId, grace: std::time::Duration) -> IdeResult<()> {
let run =
self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?;
@@ -374,6 +381,8 @@ impl RemoteRuns {
tokio::time::sleep(grace).await;
if run.info.lock().running {
let _ = process.signal(Sig::KILL).await;
tokio::time::sleep(KILL_WAIT).await;
run.abandon.cancel();
}
});
Ok(())
@@ -420,7 +429,10 @@ async fn run_remote(
.await?;
let process = Arc::new(process);
*run.process.lock() = Some(process.clone());
Ok(process.wait().await)
tokio::select! {
code = process.wait() => Ok(code),
_ = run.abandon.cancelled() => Ok(None),
}
}
#[cfg(test)]
+36 -7
Переглянути файл
@@ -66,16 +66,19 @@ pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<Pat
/// The project interpreter: the configured one, else a project virtualenv
/// (`.venv`, `venv`, …) or pixi environment (`.pixi/envs/<env>`) in the project
/// root, else the Python the project pins for mise / asdf.
/// root, else the Python the project pins for mise / asdf. A configured
/// interpreter whose executable is gone (uninstalled, moved) is skipped like
/// PyCharm's "Invalid" SDKs, so runs fall back instead of failing on a dead path.
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
setting_path(root, &s.languages.python.interpreter)
.filter(|p| p.is_file())
.or_else(|| project_venv_python(root))
.or_else(|| pinned::pinned_python(root))
}
/// An explicit project interpreter wins; otherwise use the closest virtualenv to the file.
pub fn python_interpreter_near(root: &Path, near: Option<&Path>, s: &Settings) -> Option<PathBuf> {
if let Some(python) = setting_path(root, &s.languages.python.interpreter) {
if let Some(python) = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file()) {
return Some(python);
}
let near = near.filter(|dir| dir.starts_with(root)).unwrap_or(root);
@@ -1446,7 +1449,22 @@ pub fn apply_maven_env(c: &mut RunConfig, root: &Path, s: &Settings) {
/// cargo / go configs the selected toolchain, Maven / Gradle their runner JDK
/// (and Maven the configured home and options).
pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings) {
let explicit_python = setting_path(root, &s.languages.python.interpreter);
apply_to_run_configs_in(configs, root, None, s)
}
/// [`apply_to_run_configs`] with an inherited fallback project: a workspace member
/// (`root`) with no Python of its own (no interpreter setting, virtualenv or pin)
/// runs on the primary project's (`fallback`) — the module SDK a member without its
/// own inherits in IDEA. `None` keeps the configuration's own project.
pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback: Option<&Path>, s: &Settings) {
let inherited = fallback.filter(|f| *f != root);
// An interpreter whose executable is gone is skipped (like PyCharm's "Invalid"
// SDKs): the run configuration uses the venv / fallback instead of a dead path.
let explicit_python = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file());
let inherited_python = inherited
.and_then(|f| setting_path(f, &s.languages.python.interpreter))
.filter(|p| p.is_file())
.filter(|_| explicit_python.is_none());
let pm = non_empty(&s.languages.node.package_manager);
let php = setting_path(root, &s.languages.php.interpreter);
let rust = rust_bin(root, s);
@@ -1464,7 +1482,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
// A module outside the project (an external linked build) keeps the project's choices.
let near = if owner.starts_with(root) { owner } else { root };
match tool {
"python" => python_interpreter_near(root, Some(near), s),
// A member with no Python of its own runs on the primary project's.
"python" => python_interpreter_near(root, Some(near), s)
.or_else(|| inherited.and_then(|f| python_interpreter_near(f, Some(f), s))),
"node" if non_empty(&s.languages.node.interpreter).is_none() => {
node_dir(near, s).or_else(|| node_dir(root, s))
}
@@ -1496,7 +1516,10 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
let mut path_dirs: Vec<PathBuf> = Vec::new();
match c.kind.as_str() {
"python" | "pytest" | "django-test" => {
if let Some(py) = &explicit_python {
let python = toolchain("python", &owner);
// The member's explicit interpreter, else the inherited one, else the
// member's own venv / pin.
if let Some(py) = explicit_python.as_ref().or(inherited_python.as_ref()).or(python.as_ref()) {
let runner = PY_RUNNERS.contains(&c.command.as_str());
if (c.kind == "python" || c.kind == "django-test") && (runner || is_python_command(&c.command)) {
if runner && c.args.first().map(String::as_str) == Some("run") {
@@ -1515,7 +1538,6 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
c.args = ["-m".to_owned(), "pytest".to_owned()].into_iter().chain(rest).collect();
}
}
let python = toolchain("python", &owner);
if let Some(venv) = python.as_deref().and_then(venv_of)
&& !c.env.contains_key("VIRTUAL_ENV")
{
@@ -1653,9 +1675,16 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
/// `VIRTUAL_ENV`, `JAVA_HOME`, `GOROOT`, …) or an absolute command its bare name
/// resolves to (the project interpreter, a toolchain's `cargo` / `go`, Maven's `mvn`).
pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConfig {
without_derived_in(config, root, None, s)
}
/// [`without_derived`] with the inherited fallback project the configuration runs with
/// (see [`apply_to_run_configs_in`]): a derived command or environment entry is derived
/// only when re-applying settings with the same fallback reproduces it.
pub fn without_derived_in(config: &RunConfig, root: &Path, fallback: Option<&Path>, s: &Settings) -> RunConfig {
let applied = |c: &RunConfig| {
let mut c = c.clone();
apply_to_run_configs(std::slice::from_mut(&mut c), root, s);
apply_to_run_configs_in(std::slice::from_mut(&mut c), root, fallback, s);
c
};
let mut out = config.clone();
+9 -3
Переглянути файл
@@ -282,10 +282,16 @@ pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on
/// an older JDK the server dies resolving its bundles.
pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 {
let required = crate::jdtls::script_for(cfg)
jdtls_required_java(cfg).unwrap_or(JDTLS_MIN_JAVA)
}
/// [`jdtls_min_java`] when the installed jdtls tells; `None` when none is
/// installed or its bundles can't be read.
pub fn jdtls_required_java(cfg: &LanguageServerConfig) -> Option<u32> {
crate::jdtls::script_for(cfg)
.and_then(|script| Some(script.parent()?.parent()?.to_path_buf()))
.and_then(|home| bundles_min_java(&home));
required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA))
.and_then(|home| bundles_min_java(&home))
.map(|v| v.max(JDTLS_MIN_JAVA))
}
/// Per install: (plugins directory mtime, its requirement).
+121 -15
Переглянути файл
@@ -1,6 +1,7 @@
use crate::apply::{
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
ruff_configuration, terminal_activation, tool_install_environment, tsdk, without_derived,
apply_to_run_configs, apply_to_run_configs_in, configure_server, jdtls_jdk, maven_command, maven_options,
project_jdk, python_interpreter, ruff_configuration, terminal_activation, tool_install_environment, tsdk,
without_derived, without_derived_in,
};
use crate::python::parse_pip_list;
use crate::{bin_dir, exe, venv_of};
@@ -62,6 +63,14 @@ fn run(kind: &str, command: &str, args: &[&str]) -> RunConfig {
}
}
/// An interpreter executable at `rel` under `root` (resolution skips paths that don't exist).
fn fake_interpreter(root: &Path, rel: &str) -> std::path::PathBuf {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "").unwrap();
path
}
#[test]
fn project_venv_is_the_default_interpreter() {
if cfg!(windows) {
@@ -74,8 +83,10 @@ fn project_venv_is_the_default_interpreter() {
assert_eq!(python_interpreter(tmp.path(), &s), Some(py.clone()));
assert_eq!(venv_of(&py), Some(tmp.path().join(".venv")));
let mut s = Settings::default();
s.languages.python.interpreter = "/opt/py/bin/python3".into();
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), Path::new("/opt/py/bin/python3"));
// A configured interpreter wins over the virtualenv while its executable exists.
let configured = fake_interpreter(tmp.path(), "opt/py/bin/python3");
s.languages.python.interpreter = configured.to_string_lossy().into_owned();
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), configured);
}
#[test]
@@ -304,7 +315,9 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
}
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
s.languages.python.interpreter = "/opt/py/bin/python3".into();
let python = fake_interpreter(tmp.path(), "opt/py/bin/python3");
let python = python.to_string_lossy().into_owned();
s.languages.python.interpreter = python.clone();
s.languages.node.package_manager = "pnpm".into();
let mut configs = vec![
run("python", "uv", &["run", "python", "-m", "app"]),
@@ -316,17 +329,17 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
run("django-test", ".venv/bin/python", &["manage.py", "test", "polls"]),
];
apply_to_run_configs(&mut configs, tmp.path(), &s);
assert_eq!(configs[0].command, "/opt/py/bin/python3");
assert_eq!(configs[0].command, python);
assert_eq!(configs[0].args, ["-m", "app"]);
assert_eq!(configs[1].command, "/opt/py/bin/python3");
assert_eq!(configs[1].command, python);
assert_eq!(configs[1].args, ["-m", "pytest"]);
assert_eq!(configs[2].args, ["-m", "pytest", "-x"]);
assert_eq!(configs[3].command, "pnpm");
assert_eq!(configs[4].command, "python3", "shell configs are left alone");
// Django tests run on the selected interpreter, like `manage.py runserver`.
assert_eq!(configs[5].command, "/opt/py/bin/python3");
assert_eq!(configs[5].command, python);
assert_eq!(configs[5].args, ["manage.py", "test"]);
assert_eq!(configs[6].command, "/opt/py/bin/python3");
assert_eq!(configs[6].command, python);
assert_eq!(configs[6].args, ["manage.py", "test", "polls"]);
}
@@ -2231,11 +2244,13 @@ fn nested_python_configs_get_their_own_virtualenv_but_explicit_settings_stay_pro
apply_to_run_configs(std::slice::from_mut(&mut member), root, &Settings::default());
assert_eq!(member.env["VIRTUAL_ENV"], outer.parent().unwrap().parent().unwrap().to_string_lossy());
let mut settings = Settings::default();
settings.languages.python.interpreter = "custom/bin/python".into();
settings.languages.python.interpreter = root.join("custom/bin").join(exe("python")).to_string_lossy().into_owned();
std::fs::create_dir_all(root.join("custom/bin")).unwrap();
std::fs::write(root.join("custom/bin").join(exe("python")), "").unwrap();
let mut config = run("python", "python3", &["main.py"]);
config.extras.module = Some("apps/python".into());
apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
assert_eq!(config.command, root.join("custom/bin/python").to_string_lossy());
assert_eq!(config.command, root.join("custom/bin").join(exe("python")).to_string_lossy());
}
#[test]
@@ -2248,8 +2263,94 @@ fn file_runtime_uses_nearest_virtualenv_and_explicit_project_settings_win() {
let mut settings = Settings::default();
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(inner));
assert_eq!(crate::apply::python_interpreter_near(root, Some(&root.join("apps/other")), &settings), Some(outer));
settings.languages.python.interpreter = "selected/python".into();
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(root.join("selected/python")));
let mut settings = Settings::default();
let selected = root.join("selected").join(exe("python"));
std::fs::create_dir_all(root.join("selected")).unwrap();
std::fs::write(&selected, "").unwrap();
settings.languages.python.interpreter = selected.to_string_lossy().into_owned();
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(selected));
}
#[test]
fn an_interpreter_that_no_longer_exists_falls_back_to_the_project_virtualenv() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let venv = fake_venv(root, ".venv");
let mut settings = Settings::default();
settings.languages.python.interpreter = root.join("gone/python").to_string_lossy().into_owned();
// Resolution skips the missing executable like PyCharm's "Invalid" SDKs.
assert_eq!(crate::apply::python_interpreter(root, &settings), Some(venv.clone()));
assert_eq!(crate::apply::python_interpreter_near(root, None, &settings), Some(venv.clone()));
// Run configurations use the fallback instead of the dead path.
let mut config = run("python", "python3", &["main.py"]);
apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
assert_eq!(config.command, venv.to_string_lossy());
assert_eq!(config.env["VIRTUAL_ENV"], venv.parent().unwrap().parent().unwrap().to_string_lossy());
// A configured interpreter that still exists keeps winning over the venv.
let real = root.join("real");
std::fs::create_dir_all(&real).unwrap();
let real_python = real.join(exe("python"));
std::fs::write(&real_python, "").unwrap();
settings.languages.python.interpreter = real_python.to_string_lossy().into_owned();
assert_eq!(crate::apply::python_interpreter(root, &settings), Some(real_python));
}
#[test]
fn a_member_without_python_falls_back_to_the_primary_project_interpreter() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let primary = root.join("primary");
let bare = root.join("bare");
let outer = fake_venv(&primary, ".venv");
// An attached member with a venv of its own uses it.
let own = fake_venv(&root.join("own"), ".venv");
let settings = Settings::default();
assert_eq!(crate::apply::python_interpreter_near(&root.join("own"), Some(&own), &settings), Some(own));
// A run configuration of the bare member runs on the primary project's venv.
let mut config = run("python", "python3", &["main.py"]);
config.extras.module = Some("src".into());
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &settings);
assert_eq!(config.command, outer.to_string_lossy());
assert_eq!(config.env["VIRTUAL_ENV"], outer.parent().unwrap().parent().unwrap().to_string_lossy());
// Saving strips what settings derived, so the fallback keeps following settings.
let stored = without_derived_in(&config, &bare, Some(&primary), &settings);
assert_eq!(stored.command, exe("python"));
assert!(!stored.env.contains_key("VIRTUAL_ENV"));
// The member's explicit interpreter wins over the primary project's venv —
// when it exists (bare names stay bare; a missing path falls back).
let mut chosen = Settings::default();
chosen.languages.python.interpreter = root.join("bare/python").to_string_lossy().into_owned();
std::fs::create_dir_all(&bare).unwrap();
let bare_python = bare.join(exe("python"));
std::fs::write(&bare_python, "").unwrap();
chosen.languages.python.interpreter = bare_python.to_string_lossy().into_owned();
let mut config = run("python", "python3", &["main.py"]);
config.extras.module = Some("src".into());
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &chosen);
assert_eq!(config.command, bare_python.to_string_lossy());
assert!(!config.env.contains_key("VIRTUAL_ENV"));
// An interpreter inherited from the primary follows a later change of its setting
// (the paths are created here: resolution skips missing executables).
let mut inherited = Settings::default();
for dir in ["primary", "moved"] {
std::fs::create_dir_all(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join(exe("python")), "").unwrap();
}
let primary_python = root.join("primary").join(exe("python"));
let moved_python = root.join("moved").join(exe("python"));
inherited.languages.python.interpreter = primary_python.to_string_lossy().into_owned();
let mut config = run("python", "python3", &["main.py"]);
config.extras.module = Some("src".into());
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &inherited);
assert_eq!(config.command, primary_python.to_string_lossy());
let stored = without_derived_in(&config, &bare, Some(&primary), &inherited);
inherited.languages.python.interpreter = moved_python.to_string_lossy().into_owned();
let mut reloaded = stored;
apply_to_run_configs_in(std::slice::from_mut(&mut reloaded), &bare, Some(&primary), &inherited);
assert_eq!(reloaded.command, moved_python.to_string_lossy());
}
#[test]
@@ -2350,12 +2451,17 @@ fn tool_installs_resolve_project_relative_sdks_and_leave_path_fallback_unconfigu
assert!(!defaults.env.contains_key("GOROOT"));
s.languages.go.goroot = "go-sdk".into();
s.languages.node.interpreter = "node-sdk/bin/node".into();
s.languages.python.interpreter = "python-sdk/bin/python3.14".into();
s.languages.ruby.interpreter = "ruby-sdk/bin/ruby".into();
let runtime = tool_install_environment(root, &s);
assert_eq!(runtime.commands["go"], root.join("go-sdk/bin").join(if cfg!(windows) { "go.exe" } else { "go" }));
assert_eq!(runtime.env["GOROOT"], root.join("go-sdk").to_string_lossy());
assert_eq!(runtime.commands["node"], root.join("node-sdk/bin/node"));
assert_eq!(runtime.commands["python"], root.join("python-sdk/bin/python3.14"));
assert_eq!(runtime.commands["ruby"], root.join("ruby-sdk/bin/ruby"));
// A configured interpreter whose executable is missing is skipped here too:
// the tool install environment only names Pythons that can actually run.
std::fs::create_dir_all(root.join("python-sdk/bin")).unwrap();
let real = root.join("python-sdk/bin").join(exe("python3.14"));
std::fs::write(&real, "").unwrap();
s.languages.python.interpreter = real.to_string_lossy().into_owned();
assert_eq!(tool_install_environment(root, &s).commands["python"], real);
}
+11 -1
Переглянути файл
@@ -230,7 +230,14 @@ impl Session {
let mut on_output = on_output;
tokio::spawn(async move {
let mut code = None;
while let Some(msg) = read.wait().await {
loop {
// After the exit status, output still in flight is read until the channel goes
// quiet: a child that inherited stdout keeps the channel open past the exit.
let msg = match code {
None => read.wait().await,
Some(_) => tokio::time::timeout(EXIT_DRAIN, read.wait()).await.ok().flatten(),
};
let Some(msg) = msg else { break };
match msg {
ChannelMsg::Data { data } => on_output(Output::Stdout(&data)),
ChannelMsg::ExtendedData { data, ext: 1 } => on_output(Output::Stderr(&data)),
@@ -317,6 +324,9 @@ impl Session {
}
}
/// How long a process's channel may stay quiet after its exit status before it counts as finished.
const EXIT_DRAIN: std::time::Duration = std::time::Duration::from_secs(1);
/// A chunk of a remote process's output.
pub enum Output<'a> {
Stdout(&'a [u8]),
+30 -3
Переглянути файл
@@ -59,7 +59,8 @@ impl server::Handler for Sshd {
Ok(())
}
/// `ran: <command>` on stdout, `oops` on stderr, exit status 3.
/// `ran: <command>` on stdout, `oops` on stderr, exit status 3. `linger` exits but keeps
/// the channel open, as when a child it started still holds stdout.
async fn exec_request(
&mut self,
channel: ChannelId,
@@ -70,8 +71,10 @@ impl server::Handler for Sshd {
session.data(channel, format!("ran: {}", String::from_utf8_lossy(data)).into_bytes())?;
session.extended_data(channel, 1, b"oops".to_vec())?;
session.exit_status_request(channel, 3)?;
session.eof(channel)?;
session.close(channel)?;
if data != b"linger" {
session.eof(channel)?;
session.close(channel)?;
}
Ok(())
}
@@ -294,6 +297,30 @@ async fn runs_commands_and_streams_processes() {
assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned()));
}
#[tokio::test]
async fn a_process_that_exits_finishes_even_while_its_channel_stays_open() {
let env = env().await;
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
let seen: Arc<Mutex<String>> = Arc::default();
let s = seen.clone();
let process = session
.spawn(
"linger",
None,
Box::new(move |o| {
if let Output::Stdout(b) = o {
s.lock().push_str(&String::from_utf8_lossy(b));
}
}),
)
.await
.unwrap();
let code = tokio::time::timeout(std::time::Duration::from_secs(10), process.wait())
.await
.expect("the exit status must end the process");
assert_eq!((code, seen.lock().as_str()), (Some(3), "ran: linger"));
}
#[tokio::test]
async fn upload_reports_a_command_that_stops_reading() {
let env = env().await;
+11
Переглянути файл
@@ -407,6 +407,17 @@ pub static TOOLS: &[ToolSpec] = &[
homepage: "https://github.com/graphql/graphiql",
needs: &[],
},
ToolSpec {
name: "prisma-language-server",
display: "Prisma Language Server",
kind: LanguageServer,
commands: &["prisma-language-server"],
method: Method::Npm { packages: &["@prisma/language-server"] },
fallback: None,
version: V,
homepage: "https://github.com/prisma/language-tools",
needs: &[],
},
ToolSpec {
name: "intelephense",
display: "Intelephense (PHP)",
+11
Переглянути файл
@@ -783,6 +783,17 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
Ok(self.blob_of(path, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
}
/// The file's git mode at `rev` as printed by git (`100644`, `100755`, `120000`…),
/// `None` when absent there. `rev` follows `file_at`'s rules, `WT` reads the worktree.
pub fn file_mode_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
if rev == Some("WT") {
return repo::worktree_mode(self.root(), &self.rel_path(path)?);
}
let rel = self.rel_path(path)?;
let repo = self.gix()?;
repo::mode_at(&repo, &rel, rev.unwrap_or("HEAD"))
}
/// Raw revision bytes, for encoding detection when the working file is absent.
pub fn file_bytes_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<Vec<u8>>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
+50
Переглянути файл
@@ -255,6 +255,56 @@ fn index_spelling(index: &gix::index::State, rel: &str) -> Option<String> {
found
}
/// The mode a worktree file would be committed with (`100755` when executable,
/// `100644` otherwise, `120000` for a symlink), `None` when `abs` is not a file.
/// On filesystems without an executable bit git can't record one, so it is `100644`.
pub(crate) fn worktree_mode(root: &Path, rel: &str) -> IdeResult<Option<String>> {
let meta = match std::fs::symlink_metadata(root.join(rel)) {
Ok(m) => m,
Err(_) => return Ok(None),
};
if meta.file_type().is_symlink() {
return Ok(Some("120000".to_owned()));
}
if !meta.is_file() {
return Ok(None);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
Ok(Some(if meta.permissions().mode() & 0o111 != 0 { "100755" } else { "100644" }.to_owned()))
}
#[cfg(not(unix))]
Ok(Some("100644".to_owned()))
}
/// The entry's mode of `rel` at `rev` as git prints it (`100644`, `100755`, `120000`,
/// `160000`…), `None` when the path is absent there. Same `rev` rules as [`blob_at`].
pub(crate) fn mode_at(repo: &gix::Repository, rel: &str, rev: &str) -> IdeResult<Option<String>> {
if let Some(stage) = rev.strip_prefix(':') {
let stage = match stage {
"" | "0" => gix::index::entry::Stage::Unconflicted,
"1" => gix::index::entry::Stage::Base,
"2" => gix::index::entry::Stage::Ours,
"3" => gix::index::entry::Stage::Theirs,
other => return Err(IdeError::invalid(format!("invalid index stage :{other}"))),
};
let index = repo.index_or_empty().map_err(gx)?;
let Some(entry) = index.entry_by_path_and_stage(BStr::new(rel.as_bytes()), stage) else {
return Ok(None);
};
return Ok(entry.mode.to_tree_entry_mode().map(|m| format!("{:o}", m.value())));
}
let Some(tree) = tree_of(repo, rev)? else { return Ok(None) };
let Some(entry) = tree.lookup_entry_by_path(Path::new(rel)).map_err(gx)? else {
return Ok(None);
};
if !entry.mode().is_blob_or_symlink() {
return Ok(None);
}
Ok(Some(format!("{:o}", entry.mode().value())))
}
/// Blob content of `rel` at `rev`. `rev` is a revision (`HEAD`, a branch, an id) or
/// `:N` for index stage N (`:` / `:0` = the merged index entry).
pub(crate) fn blob_at(repo: &gix::Repository, rel: &str, rev: &str) -> IdeResult<Option<Vec<u8>>> {
+36
Переглянути файл
@@ -125,6 +125,8 @@ fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
ChangeKind::Conflicted
}
EntryStatus::Change(WtChange::Removed) => ChangeKind::Deleted,
// `content_change` is `None` when only the executable bit changed:
// still a modification `git status` lists and `git commit` records.
EntryStatus::Change(_) => ChangeKind::Modified,
EntryStatus::IntentToAdd => ChangeKind::Added,
EntryStatus::NeedsUpdate(_) => continue,
@@ -402,6 +404,40 @@ mod tests {
assert_eq!(from_git.1, ["conflict.txt"]);
}
/// A chmod-only change is a modification `git status` lists and `git commit`
/// records, so status keeps it (#2853): hidden, the Commit view could not show
/// or commit what a push would carry. `core.fileMode=false` reports nothing.
#[cfg(unix)]
#[test]
fn a_mode_only_change_is_reported_only_when_git_tracks_modes() {
use std::os::unix::fs::PermissionsExt;
for file_mode in ["true", "false"] {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
git(dir, &["config", "user.name", "Test User"]);
git(dir, &["config", "user.email", "[email protected]"]);
git(dir, &["config", "commit.gpgsign", "false"]);
git(dir, &["config", "core.fileMode", file_mode]);
std::fs::write(dir.join("script.sh"), "#!/bin/sh\n").unwrap();
commit_all(dir, "base");
std::fs::set_permissions(dir.join("script.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
let repo = Repo::discover(dir).unwrap().expect("repository found");
let status = repo.status().unwrap();
let entry = status.iter().find(|f| f.rel_path == "script.sh");
if file_mode == "true" {
assert_eq!(
entry.and_then(|f| f.unstaged),
Some(ChangeKind::Modified),
"a mode-only change is modified"
);
} else {
assert!(entry.is_none(), "core.fileMode=false hides mode changes");
}
}
}
/// git answers status when gitoxide can't read a directory; in Restricted Mode that run
/// must not execute the repository's filters.
#[cfg(unix)]
+9 -3
Переглянути файл
@@ -235,7 +235,7 @@ async fn serve(mut sock: TcpStream, root: &Root) -> std::io::Result<()> {
let head_only = req.method == "HEAD";
let response = if !matches!(req.method.as_str(), "GET" | "HEAD") {
Response::Error(405, "Method Not Allowed")
} else if !host_allowed(req.host.as_deref(), root.port) {
} else if !host_allowed(req.host.as_deref()) {
Response::Error(403, "Forbidden")
} else {
resolve(root, &req.target).await
@@ -274,8 +274,11 @@ async fn serve(mut sock: TcpStream, root: &Root) -> std::io::Result<()> {
sock.shutdown().await
}
fn host_allowed(host: Option<&str>, port: u16) -> bool {
host.is_some_and(|h| h == format!("127.0.0.1:{port}") || h == format!("localhost:{port}"))
/// Loopback names only (DNS rebinding). Any port: a remote host's server is reached through
/// a forwarded port of the client, and a browser names the port it connected to anyway.
fn host_allowed(host: Option<&str>) -> bool {
host.and_then(|h| h.rsplit_once(':'))
.is_some_and(|(name, port)| matches!(name, "127.0.0.1" | "localhost") && port.parse::<u16>().is_ok())
}
async fn resolve(root: &Root, target: &str) -> Response {
@@ -424,6 +427,9 @@ mod tests {
let other = url.replacen(token, "0123", 1);
assert!(get(&other, None).await.starts_with("HTTP/1.1 404"));
assert!(get(&url, Some("evil.example:80")).await.starts_with("HTTP/1.1 403"));
assert!(get(&url, Some("127.0.0.1.evil.example:80")).await.starts_with("HTTP/1.1 403"));
// Through a forwarded port of a remote client.
assert!(get(&url, Some("127.0.0.1:61234")).await.starts_with("HTTP/1.1 200"));
}
#[cfg(unix)]
+16
Переглянути файл
@@ -565,6 +565,22 @@ mod tests {
);
}
#[test]
fn set_console_log_colors() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join("cfg");
let proj = tmp.path().join("proj");
std::fs::create_dir_all(&cfg).unwrap();
let store = SettingsStore::load(&cfg, Some(&proj));
let s = store.set(SettingsScope::Global, "console.logColors.error", json!("#ff0000")).unwrap();
assert_eq!(s.console.log_colors.error, "#ff0000");
assert!(!s.console.log_colors.enabled, "off by default");
let s = store.set(SettingsScope::Global, "console.logColors.enabled", json!(true)).unwrap();
assert!(s.console.log_colors.enabled);
assert_eq!(s.console.log_colors.error, "#ff0000");
}
#[test]
fn set_preserves_comments_and_validates() {
let tmp = tempfile::tempdir().unwrap();
+1
Переглянути файл
@@ -156,6 +156,7 @@ Each launch opens a tab in the **Run** tool window (bottom). The toolbar has **R
- File paths and stack trace frames in the output are links.
- Repeated lines can be folded; configure the patterns in **Settings › Build, Execution, Deployment › Console**, along with the buffer size and the console encoding.
- Log lines can take the colour of their level (`ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE`); colours the program prints itself are kept. Turn it on and pick the colours in **Settings › Build, Execution, Deployment › Console**.
- For test runs the tab shows a test tree next to the output. See [Testing](/en/guide/testing).
**Settings › Build, Execution, Deployment › Run/Debug** controls whether the tool window opens when a process starts, whether rerun and closing a running tab ask first, and the time limit for a process to exit after Stop.
+1 -1
Переглянути файл
@@ -67,7 +67,7 @@ All keys are kebab-case. Only `id` and `name` are required; a definition with no
| `formatter` | table | External formatter; see below. |
| `scratch-template` | string | Content of a new scratch file of this language. `$END$` marks where the caret goes. |
Bundled tree-sitter grammars: `bash`, `c`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `elixir`, `gdscript`, `go`, `graphql`, `haskell`, `hcl`, `html`, `java`, `javascript`, `json`, `kotlin`, `lua`, `make`, `markdown`, `nix`, `ocaml`, `ocaml_interface`, `php`, `proto`, `python`, `ruby`, `rust`, `scala`, `swift`, `toml`, `tsx`, `typescript`, `xml`, `yaml`, `zig`. A new grammar has to be compiled into the IDE; a plugin can provide highlighting and structure for a language without a grammar instead (see [Language features](/en/plugins/guides/language-features)).
Bundled tree-sitter grammars: `bash`, `c`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `elixir`, `gdscript`, `go`, `graphql`, `haskell`, `hcl`, `html`, `java`, `javascript`, `json`, `kotlin`, `lua`, `make`, `markdown`, `nix`, `ocaml`, `ocaml_interface`, `php`, `prisma`, `proto`, `python`, `ruby`, `rust`, `scala`, `swift`, `toml`, `tsx`, `typescript`, `xml`, `yaml`, `zig`. A new grammar has to be compiled into the IDE; a plugin can provide highlighting and structure for a language without a grammar instead (see [Language features](/en/plugins/guides/language-features)).
### `[[framework-ui-modes]]`
+1
Переглянути файл
@@ -151,6 +151,7 @@ The table lists every built-in language definition. Server and debugger names in
| PHP | `php` | `.php`, `.phtml`, `.php3`, `.php4`, `.php5`, … | Yes | Yes | **intelephense** → phpactor + **laravel-lsp** | **php-debug** |
| Plain Text | `plaintext` | `.txt`, `.text` | — | — | — | — |
| PowerShell | `powershell` | `.ps1`, `.psm1`, `.psd1`, `.pssc`, `.psrc` | Yes | — | powershell-editor-services | — |
| Prisma | `prisma` | `.prisma` | Yes | Yes | **prisma-language-server** | — |
| Properties | `properties` | `.properties`, `.prefs` | Yes | — | — | — |
| Protocol Buffers | `proto` | `.proto` | Yes | Yes | **buf** | — |
| Python | `python` | `.py`, `.pyi`, `.pyw` | Yes | Yes | **basedpyright** → **pyright** → **ty** → **pyrefly** + **ruff** | **debugpy** |
+2 -1
Переглянути файл
@@ -238,7 +238,7 @@ The full table is `UI_KEYS` in
| `--syn-rainbow-1` … `--syn-rainbow-5` (Semantic highlighting) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
@@ -504,6 +504,7 @@ conventionally go in the theme file, groups marked _editor scheme_ in the scheme
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-search-result` | `#2d543f` | `#ccccff` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
+1
Переглянути файл
@@ -152,6 +152,7 @@ Maven 模块在 **自动** 和 **构建(Make)** 时由 jdtls(ECJ)构建
- 输出里的文件路径和堆栈帧都可以点击跳转。
- 重复的行可以折叠。折叠规则、缓冲区大小和控制台编码在 **设置 › 构建、执行、部署 › 控制台** 中配置。
- 日志行可按级别着色(`ERROR`、`WARN`、`INFO`、`DEBUG`、`TRACE`);程序自行输出的颜色保持不变。默认关闭,可在 **设置 › 构建、执行、部署 › 控制台** 开启并修改颜色。
- 测试运行时,标签页会在输出旁显示测试树,参见[测试](/zh/guide/testing)。
**设置 › 构建、执行、部署 › 运行/调试** 控制进程启动时是否打开工具窗口、重新运行或关闭正在运行的标签页前是否确认,以及按停止后等待进程退出的时间。
+1 -1
Переглянути файл
@@ -67,7 +67,7 @@ install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Lang
| `formatter` | 表 | 外部格式化工具,见下文。 |
| `scratch-template` | 字符串 | 新建该语言草稿文件时的内容,`$END$` 表示光标位置。 |
内置的 tree-sitter 语法:`bash`、`c`、`cmake`、`cpp`、`csharp`、`css`、`dart`、`elixir`、`gdscript`、`go`、`graphql`、`haskell`、`hcl`、`html`、`java`、`javascript`、`json`、`kotlin`、`lua`、`make`、`markdown`、`nix`、`ocaml`、`ocaml_interface`、`php`、`proto`、`python`、`ruby`、`rust`、`scala`、`swift`、`toml`、`tsx`、`typescript`、`xml`、`yaml`、`zig`。新的语法需要编译进 IDE;没有语法的语言可以改由插件提供高亮和结构(见 [语言功能](/zh/plugins/guides/language-features))。
内置的 tree-sitter 语法:`bash`、`c`、`cmake`、`cpp`、`csharp`、`css`、`dart`、`elixir`、`gdscript`、`go`、`graphql`、`haskell`、`hcl`、`html`、`java`、`javascript`、`json`、`kotlin`、`lua`、`make`、`markdown`、`nix`、`ocaml`、`ocaml_interface`、`php`、`prisma`、`proto`、`python`、`ruby`、`rust`、`scala`、`swift`、`toml`、`tsx`、`typescript`、`xml`、`yaml`、`zig`。新的语法需要编译进 IDE;没有语法的语言可以改由插件提供高亮和结构(见 [语言功能](/zh/plugins/guides/language-features))。
### `[[framework-ui-modes]]`
+1
Переглянути файл
@@ -146,6 +146,7 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
| PHP | `php` | `.php`, `.phtml`, `.php3`, `.php4`, `.php5`, … | 是 | 是 | **intelephense** → phpactor + **laravel-lsp** | **php-debug** |
| Plain Text | `plaintext` | `.txt`, `.text` | — | — | — | — |
| PowerShell | `powershell` | `.ps1`, `.psm1`, `.psd1`, `.pssc`, `.psrc` | 是 | — | powershell-editor-services | — |
| Prisma | `prisma` | `.prisma` | 是 | 是 | **prisma-language-server** | — |
| Properties | `properties` | `.properties`, `.prefs` | 是 | — | — | — |
| Protocol Buffers | `proto` | `.proto` | 是 | 是 | **buf** | — |
| Python | `python` | `.py`, `.pyi`, `.pyw` | 是 | 是 | **basedpyright** → **pyright** → **ty** → **pyrefly** + **ruff** | **debugpy** |
+2 -1
Переглянути файл
@@ -210,7 +210,7 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
| `--syn-rainbow-1` … `--syn-rainbow-5`(语义高亮) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES`(效果颜色) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT`(`CONSOLE_DARKGRAY_OUTPUT` 为亮黑,`CONSOLE_GRAY_OUTPUT` 为白色) |
@@ -469,6 +469,7 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-search-result` | `#2d543f` | `#ccccff` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
+47
Переглянути файл
@@ -4,6 +4,7 @@
* by `crates/ide-lsp/tests`.
*/
import type { Locator, Page } from "@playwright/test";
import type { CompletionItem } from "../src/bindings";
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
@@ -78,6 +79,52 @@ test("completion lists the server's items and inserts the chosen one", async ({
expect((await ipcCalls(page, "lsp_completion")).length).toBeGreaterThan(0);
});
for (const key of ["Enter", "Tab"]) {
for (const prefix of ["", "v"]) {
test(`${key} leaves the caret after a field snippet at root.${prefix}`, async ({ workbench: page }, info) => {
const editor = await openSample(page);
await page.evaluate(() => {
const item = (label: string): CompletionItem => ({
label,
labelDetail: null,
labelDescription: "int",
detail: "int",
kind: "field",
documentation: null,
sortText: null,
filterText: null,
preselect: label === "val",
deprecated: false,
insertText: label,
isSnippet: true,
replaceRange: null,
replaceRangeTab: null,
additionalEdits: [],
commitCharacters: [],
command: null,
data: null,
server: 1,
});
window.__ipc.handlers.lsp_completion = () => ({ isIncomplete: false, items: [item("val"), item("value")] });
});
await editor.locator(".cm-line", { hasText: "return order.toDto()" }).click();
await page.keyboard.press("End");
await page.keyboard.press("Enter");
await page.keyboard.type(`int a = root.${prefix}`);
await page.keyboard.press("Control+Space");
const popup = page.locator(".cm-tooltip-autocomplete");
await expect(popup.getByText("val", { exact: true })).toBeVisible();
// CodeMirror ignores acceptance during its 75 ms lookup interaction delay.
await page.waitForTimeout(100);
await page.keyboard.press(key);
await expect(popup).toBeHidden();
await page.screenshot({ path: info.outputPath("accepted-completion.png"), animations: "disabled" });
await page.keyboard.type(";");
await expect(editor.locator(".cm-activeLine")).toHaveText(/^\s*int a = root\.val;$/);
});
}
}
test("⌘B goes to the declaration in another file", async ({ workbench: page }) => {
const editor = await openSample(page);
await page.evaluate(
+29
Переглянути файл
@@ -256,3 +256,32 @@ test("single-line selection stays on its own row when font glyphs exceed line he
expect(Math.abs(selBox!.y - lineBox!.y)).toBeLessThanOrEqual(1);
expect(Math.abs(selBox!.height - lineBox!.height)).toBeLessThanOrEqual(1);
});
test("vertical tabs leave a narrow editor group most of its width", async ({ workbench: page }) => {
await page.setViewportSize({ width: 900, height: 600 });
await openFromTree(page, "settings.gradle.kts");
await page.evaluate(async () => {
const projectModule = "/src/platform/project.ts";
const schemaModule = "/src/features/settings/model/schema.ts";
const controllerModule = "/src/editor/core/controller.ts";
const [{ useProject }, { DEFAULT_SETTINGS }, { editorController, useTabs }] = await Promise.all([
import(/* @vite-ignore */ projectModule),
import(/* @vite-ignore */ schemaModule),
import(/* @vite-ignore */ controllerModule),
]);
const base = useProject.getState().settings ?? DEFAULT_SETTINGS;
useProject.setState({
settings: { ...base, editor: { ...base.editor, tabs: { ...base.editor.tabs, placement: "left" } } },
});
editorController().split(useTabs.getState().activePane, "row");
});
const panes = page.locator("[data-pane-id]");
await expect(panes).toHaveCount(2);
for (const pane of await panes.all()) {
const strip = pane.getByRole("tablist", { name: "Editor tabs" });
await expect(strip).toHaveAttribute("aria-orientation", "vertical");
const [paneBox, contentBox] = await Promise.all([pane.boundingBox(), pane.getByRole("tabpanel").boundingBox()]);
expect(contentBox!.width).toBeGreaterThanOrEqual(paneBox!.width * 0.6 - 1);
}
});
+36
Переглянути файл
@@ -0,0 +1,36 @@
import { expect, test } from "@playwright/test";
/** Near the window edge CodeMirror caps a hover tooltip's height (#2910). */
test("hover sections scroll inside a height-capped tooltip", async ({ page }) => {
await page.goto("/");
const r = await page.evaluate(async () => {
const viewMod = await import("/node_modules/.vite/deps/@codemirror_view.js" as string);
const stateMod = await import("/node_modules/.vite/deps/@codemirror_state.js" as string);
const { lspTheme } = await import("/src/editor/lsp/theme.ts" as string);
const host = document.createElement("div");
host.style.cssText = "position:fixed;inset:0;z-index:99999;background:white";
document.body.append(host);
const view = new viewMod.EditorView({
state: stateMod.EditorState.create({ doc: "x", extensions: lspTheme }),
parent: host,
});
const tip = document.createElement("div");
tip.className = "cm-tooltip cm-tooltip-hover";
tip.style.cssText = "position:absolute;top:10px;left:10px;height:200px";
const sec = document.createElement("div");
sec.className = "cm-tooltip-section cm-lsp-hover";
sec.innerHTML = Array.from({ length: 40 }, (_, i) => `<p>line ${i}</p>`).join("");
tip.append(sec);
view.dom.append(tip);
const t = tip.getBoundingClientRect(),
s = sec.getBoundingClientRect();
return {
tip: Math.round(t.height),
secBottom: Math.round(s.bottom),
tipBottom: Math.round(t.bottom),
scrolls: sec.scrollHeight > sec.clientHeight,
};
});
expect(r.secBottom).toBeLessThanOrEqual(r.tipBottom);
expect(r.scrolls).toBe(true);
});
+14
Переглянути файл
@@ -105,6 +105,20 @@ test("Find in Files streams matches by file and opens the chosen one at its line
});
const preview = dialog.getByRole("region", { name: "Preview", exact: true }).locator(".cm-content");
await expect(preview).toHaveAttribute("contenteditable", "true");
// Matches take the editor scheme's search result background (IDEA's SEARCH_RESULT_ATTRIBUTES) and keep the syntax colours.
const match = preview.locator(".cm-openidea-searchResult").first();
await expect(match).toHaveText("OrderService");
expect(
await match.evaluate((el) => {
const probe = document.createElement("span");
probe.style.backgroundColor = "var(--bg-search-result)";
el.parentElement!.append(probe);
const expected = getComputedStyle(probe).backgroundColor;
probe.remove();
const own = getComputedStyle(el);
return own.backgroundColor === expected && own.color === getComputedStyle(el.parentElement!).color;
}),
).toBe(true);
const originalPreview = await preview.textContent();
await preview.focus();
await page.keyboard.press("Meta+a");
+2
Переглянути файл
@@ -41,3 +41,5 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
+1 -1
Переглянути файл
@@ -27,7 +27,7 @@ install-hint = "Install Microsoft java-debug (Settings › Languages & Framework
name = "jdtls"
command = "jdtls"
root-markers = ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "build.xml", ".project"]
install-hint = "Install Eclipse JDT Language Server (brew install jdtls, or https://github.com/eclipse-jdtls/eclipse.jdt.ls); requires JDK 21+"
install-hint = "Install Eclipse JDT Language Server (brew install jdtls, or https://github.com/eclipse-jdtls/eclipse.jdt.ls); runs on a JDK new enough for the installed release, chosen or downloaded automatically"
[language-servers.initialization-options]
bundles = []
+2
Переглянути файл
@@ -75,6 +75,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+2
Переглянути файл
@@ -74,6 +74,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+17
Переглянути файл
@@ -0,0 +1,17 @@
id = "prisma"
name = "Prisma"
extensions = ["prisma"]
line-comment = "//"
indent = { tab-size = 2, insert-spaces = true }
tree-sitter = "prisma"
[[language-servers]]
name = "prisma-language-server"
command = "prisma-language-server"
args = ["--stdio"]
root-markers = ["package.json", "schema.prisma"]
install-hint = "npm i -g @prisma/language-server"
# Asked for as section `prisma` before every validation; a missing section crashes the server.
[language-servers.settings.prisma]
enableDiagnostics = true
+2
Переглянути файл
@@ -43,3 +43,5 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
+2
Переглянути файл
@@ -89,6 +89,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+2
Переглянути файл
@@ -74,6 +74,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+2
Переглянути файл
@@ -68,3 +68,5 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
+1
Переглянути файл
@@ -89,6 +89,7 @@
"--fg-editor-selection": "inherit",
"--bg-text-search-result": "#114957",
"--bg-text-search-result-current": "#165e70",
"--bg-search-result": "#2d543f",
"--bg-matched-brace": "#38424f",
"--matched-brace-weight": "700",
"--bg-matched-tag": "#353e4a",
+3 -2
Переглянути файл
@@ -231,7 +231,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../src/
| `--syn-rainbow-1` … `--syn-rainbow-5` (Semantic highlighting, rainbow indent guides) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
@@ -329,7 +329,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
263 themeable tokens: 125 in the UI theme file, 138 in the editor colour scheme file.
264 themeable tokens: 125 in the UI theme file, 139 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -509,6 +509,7 @@ a token.
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-search-result` | `#2d543f` | `#ccccff` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
+10
Переглянути файл
@@ -219,6 +219,16 @@ describe("IntelliJ theme import", () => {
});
});
it("keeps the search result colour apart from the text search result and the lists' search match", () => {
const bg = (c: string) => `<value><option name="BACKGROUND" value="${c}"/></value>`;
const s = convertIcls(`<scheme name="Search" parent_scheme="Darcula"><attributes>
<option name="SEARCH_RESULT_ATTRIBUTES">${bg("2d543f")}</option>
<option name="TEXT_SEARCH_RESULT_ATTRIBUTES">${bg("114957")}</option>
</attributes></scheme>`);
expect(s.tokens).toMatchObject({ "--bg-search-result": "#2d543f", "--bg-text-search-result": "#114957" });
expect(s.tokens).not.toHaveProperty("--bg-search-match");
});
it("produces an installable package", () => {
const light = stripJsonComments(themeJson.replace('"dark": true,', ""));
const c = convertIntellijTheme(light, icls.replace("Darcula", "Default"), "sea");
+17 -4
Переглянути файл
@@ -3,8 +3,9 @@
use crate::state::AppState;
use ide_agent::registry::AgentRegistry;
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpServer, McpServerInput, NewAgentTask,
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
McpServer, McpServerInput, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath, RequestId};
use ide_host::ops::agent;
@@ -370,7 +371,18 @@ pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: Str
agent::changes(&state.host, window.label(), task).await
}
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
/// The task's uncommitted changes and the commits made since it started.
#[tauri::command]
#[specta::specta]
pub async fn agent_grouped_changes(
window: Window,
state: State<'_, AppState>,
task: String,
) -> IdeResult<AgentChanges> {
agent::grouped_changes(&state.host, window.label(), task).await
}
/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them.
#[tauri::command]
#[specta::specta]
pub async fn agent_rollback_changes(
@@ -378,8 +390,9 @@ pub async fn agent_rollback_changes(
state: State<'_, AppState>,
task: String,
paths: Option<Vec<String>>,
view: Option<AgentChangesView>,
) -> IdeResult<()> {
agent::rollback_changes(&state.host, window.label(), task, paths).await
agent::rollback_changes(&state.host, window.label(), task, paths, view).await
}
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
+3
Переглянути файл
@@ -154,6 +154,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
agent::agent_rename,
agent::agent_delete,
agent::agent_changes,
agent::agent_grouped_changes,
agent::agent_base_text,
agent::agent_rollback_changes,
agent::agent_accept_changes,
@@ -303,6 +304,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_branch_info,
vcs::vcs_line_hunks,
vcs::vcs_file_at,
vcs::vcs_file_mode,
vcs::vcs_blame,
vcs::vcs_block_history,
vcs::vcs_log,
@@ -455,6 +457,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::jdk_download_vendors,
tools::jdk_download_releases,
tools::jdk_download_location,
tools::jdtls_required_java,
tools::jdk_download,
scaffold::scaffold_catalog,
scaffold::scaffold_options,
+2 -4
Переглянути файл
@@ -111,10 +111,8 @@ pub fn create_host(app: &AppHandle, data_dir: &Path, project_host: &Arc<ide_host
ide_language::set_external_syntax(Some(host.syntax_provider()));
ide_run::set_external_detection(Some(host.run_detection()));
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(project_host)))));
// Released builds list the official plugins (published with each release) until the user picks another index.
if !cfg!(debug_assertions) {
host.set_default_market_url(Some(ide_host::plugins::OFFICIAL_MARKET_INDEX.into()));
}
// The official plugins (published with each release) are listed until the user picks another index.
host.set_default_market_url(Some(ide_host::plugins::OFFICIAL_MARKET_INDEX.into()));
#[cfg(target_os = "macos")]
host.set_secret_store(Arc::new(KeychainSecrets));
ide_host::plugins::set_editor_providers(Some(&host));
+7
Переглянути файл
@@ -66,6 +66,13 @@ pub fn jdk_download_location(state: State<'_, AppState>) -> IdeResult<ide_core::
ide_host::ops::tools::jdk_download_location(&state.host, "")
}
/// The Java version the window's jdtls needs; `None` while it isn't installed or doesn't tell.
#[tauri::command]
#[specta::specta]
pub async fn jdtls_required_java(window: Window, state: State<'_, AppState>) -> IdeResult<Option<u32>> {
ide_host::ops::tools::jdtls_required_java(&state.host, window.label()).await
}
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
/// [`jdk_download_location`]), streaming progress through `on_event`; cancellable
/// with `cancel_request(request)`. The JDK is then detected like an installed one,
+13
Переглянути файл
@@ -51,6 +51,19 @@ pub async fn vcs_file_at(
ide_host::ops::vcs::vcs_file_at(&state.host, window.label(), path, rev).await
}
/// The file's git mode at `rev` (`100644`, `100755`, `120000`…), `"WT"` for the
/// worktree; `None` when absent there. Lets the diff viewer report a mode-only change.
#[tauri::command]
#[specta::specta]
pub async fn vcs_file_mode(
window: Window,
state: State<'_, AppState>,
path: PathBuf,
rev: Option<String>,
) -> IdeResult<Option<String>> {
ide_host::ops::vcs::vcs_file_mode(&state.host, window.label(), path, rev).await
}
/// History for Selection: commits that changed `lines` (0-based, inclusive) of `path`'s
/// editor text. Cancellable with `cancel_request(request)`.
#[tauri::command]
+1 -2
Переглянути файл
@@ -17,8 +17,7 @@ pub async fn web_browsers() -> IdeResult<Vec<Browser>> {
#[tauri::command]
#[specta::specta]
pub async fn web_url(window: Window, state: State<'_, AppState>, path: IpcPath) -> IdeResult<String> {
let root = state.project(window.label()).ok().map(|p| p.root.clone());
state.web.url_for(root.as_deref(), &path).await
ide_host::ops::web::url(&state.host, window.label(), &path).await
}
/// Opens `url` in `browser` (an id from `web_browsers`), or the default browser.
+8 -1
Переглянути файл
@@ -409,7 +409,14 @@ pub fn spec(os: Os) -> Vec<Entry> {
sub("statusBarWidgets", "Status Bar Widgets", vec![Entry::Dynamic(DYNAMIC_STATUS_BAR_WIDGETS)]),
],
),
sub("activeEditor", "Active Editor", vec![act("editor.toggleSoftWrap", "Soft-Wrap")]),
sub(
"activeEditor",
"Active Editor",
vec![
act("editor.toggleSoftWrap", "Soft-Wrap"),
act("editor.toggleRenderedDocs", "Render Documentation Comments"),
],
),
SEP,
act("code.quickDefinition", "Quick Definition"),
act("code.quickTypeDefinition", "Quick Type Definition"),
+36 -1
Переглянути файл
@@ -76,6 +76,8 @@ pub struct Connection {
/// Remote ports forwarded to this machine (Ports tool window), by host (`None`: the remote
/// host itself) and remote port.
forwards: Mutex<Vec<(ForwardKey, Forward)>>,
/// Ports of the remote built-in web server forwarded here, by remote port.
web_forwards: Mutex<Vec<(u16, Forward)>>,
}
/// A forwarded port's host (`None`: the remote host itself) and remote port.
@@ -544,6 +546,7 @@ pub async fn remote_connect(
next_channel: std::sync::atomic::AtomicU64::new(1),
session,
forwards: Mutex::new(Vec::new()),
web_forwards: Mutex::new(Vec::new()),
exec,
});
if let RemoteTarget::DevContainer { folder, config } = &target {
@@ -749,6 +752,34 @@ async fn open_forward(c: &Connection, host: Option<&str>, remote_port: u16) -> I
Err(IdeError::unavailable("Forwarding ports is not supported in a WSL project yet"))
}
/// `web_url` answers with the remote host's built-in web server, on its loopback: the URL's
/// port is forwarded here. A WSL distribution shares this machine's loopback already.
async fn forward_web_url(c: &Connection, url: &str) -> IdeResult<String> {
let Some((port, path)) = url.strip_prefix("http://127.0.0.1:").and_then(|rest| rest.split_once('/')) else {
return Ok(url.to_owned());
};
let Ok(remote) = port.parse::<u16>() else { return Ok(url.to_owned()) };
let existing = c.web_forwards.lock().iter().find(|(p, _)| *p == remote).map(|(_, f)| f.port());
let local = match existing {
Some(local) => local,
None => {
let forward = if let Some(session) = c.session.clone() {
Forward::Ssh(ide_ssh::Tunnel::listen_on(session, Some(remote), "127.0.0.1", remote).await?)
} else if let Some(exec) = c.exec.clone() {
Forward::Container(
ide_remote::container_tunnel::Tunnel::listen(exec, remote, "127.0.0.1", remote).await?,
)
} else {
return Ok(url.to_owned());
};
let local = forward.port();
c.web_forwards.lock().push((remote, forward));
local
}
};
Ok(format!("http://127.0.0.1:{local}/{path}"))
}
/// Forwards the `forwardPorts` of the dev container's configuration (the local folder's).
async fn forward_configured_ports(c: &Connection, folder: &str, config: &str) {
let local = std::path::PathBuf::from(folder);
@@ -858,8 +889,12 @@ pub async fn remote_invoke(
} else {
None
};
let web_url = cmd == "web_url";
let call = Invoke { window: label, cmd, args };
let result = invoke_reply(&c.peer, call, std::time::Duration::from_secs(30)).await?;
let mut result = invoke_reply(&c.peer, call, std::time::Duration::from_secs(30)).await?;
if web_url && let Some(url) = result.as_str() {
result = Value::String(forward_web_url(&c, url).await?);
}
if let (Some((peers, edit)), Some(version)) =
(mirrored, result.as_u64().and_then(|version| u32::try_from(version).ok()))
{
-3
Переглянути файл
@@ -19,8 +19,6 @@ pub struct AppState {
pub recent: RecentProjects,
/// Plugins (`ide_host::plugins`), shared by all windows.
pub plugins: Arc<ide_host::plugins::PluginHost>,
/// The built-in web server (Open In › Browser, the editor's browser bar).
pub web: ide_web::WebServer,
/// Agents › MCP Server: built-in IDE tools for agents.
pub mcp: crate::commands::mcp::McpService,
/// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start).
@@ -108,7 +106,6 @@ impl AppState {
askpass: start_askpass(&app),
sink: global_sink,
plugins: crate::commands::plugin::create_host(&app, &data_dir, &host)?,
web: ide_web::WebServer::new(),
mcp: Default::default(),
remote_dev: crate::remote_dev::Connections::default(),
host,
+83 -5
Переглянути файл
@@ -214,10 +214,16 @@ export const commands = {
agentRename: (task: string, title: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rename", { task, title })),
agentDelete: (task: string, removeWorktree: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_delete", { task, removeWorktree })),
agentChanges: (task: string) => typedError<AgentChange[], IdeError>(__TAURI_INVOKE("agent_changes", { task })),
/** The task's uncommitted changes and the commits made since it started. */
agentGroupedChanges: (task: string) => typedError<AgentChanges, IdeError>(__TAURI_INVOKE("agent_grouped_changes", { task })),
/** Content of `path` when the task started (left side of its diff). */
agentBaseText: (task: string, path: IpcPath) => typedError<string | null, IdeError>(__TAURI_INVOKE("agent_base_text", { task, path })),
/** Rollback: the task's changed files among `paths` (all without) back as they were when it started. */
agentRollbackChanges: (task: string, paths: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rollback_changes", { task, paths })),
/** Rollback: the task's changed files among `paths` (all without) back as `view` compares them. */
agentRollbackChanges: (task: string, paths: string[] | null, view:
/** Everything since the commit the task started from. */
"sinceStart" |
/** The uncommitted files against `HEAD`; commits made since the start stay. */
"group" | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rollback_changes", { task, paths, view })),
/**
* Accept: copies a worktree task's changed files among `paths` (all without) into the
* local checkout, uncommitted; files the checkout changed itself are left alone and reported.
@@ -610,6 +616,11 @@ export const commands = {
/** Gutter change markers for a file, computed against its current (unsaved) text. */
vcsLineHunks: (path: string) => typedError<LineHunk[], IdeError>(__TAURI_INVOKE("vcs_line_hunks", { path })),
vcsFileAt: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_at", { path, rev })),
/**
* The file's git mode at `rev` (`100644`, `100755`, `120000`…), `"WT"` for the
* worktree; `None` when absent there. Lets the diff viewer report a mode-only change.
*/
vcsFileMode: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_mode", { path, rev })),
vcsBlame: (path: string, text: string | null, revision: string | null) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path, text, revision })),
/**
* History for Selection: commits that changed `lines` (0-based, inclusive) of `path`'s
@@ -931,6 +942,8 @@ export const commands = {
jdkDownloadReleases: (vendor: string, request: RequestId) => typedError<JdkReleases, IdeError>(__TAURI_INVOKE("jdk_download_releases", { vendor, request })),
/** Where Download JDK puts JDKs unless told otherwise. */
jdkDownloadLocation: () => typedError<IpcPath, IdeError>(__TAURI_INVOKE("jdk_download_location")),
/** The Java version the window's jdtls needs; `None` while it isn't installed or doesn't tell. */
jdtlsRequiredJava: () => typedError<number | null, IdeError>(__TAURI_INVOKE("jdtls_required_java")),
/**
* Downloads the latest `vendor` JDK of Java `version` into `location` (default:
* [`jdk_download_location`]), streaming progress through `on_event`; cancellable
@@ -1730,15 +1743,38 @@ export type AgentCapabilities = {
backgroundTasks?: boolean,
};
/** A file the task changed, relative to [`AgentTask::base_commit`]. */
/**
* A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
* grouped view.
*/
export type AgentChange = {
path: IpcPath,
relPath: string,
kind: ChangeKind,
added: number,
removed: number,
/** Path before a rename; the left side of its diff reads this path. */
oldPath: string | null,
};
/** A task's uncommitted files and the commits made since it started. */
export type AgentChanges = {
/** Files changed against `HEAD`, less those changed before the task started and untouched since. */
uncommitted: AgentChange[],
/**
* Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty
* for tasks working in the project's own checkout.
*/
commits: AgentCommit[],
};
/** Which comparison a rollback of the task's changes uses. */
export type AgentChangesView =
/** Everything since the commit the task started from. */
"sinceStart" |
/** The uncommitted files against `HEAD`; commits made since the start stay. */
"group";
export type AgentCommand = {
/** Without the leading `/`. */
name: string,
@@ -1753,6 +1789,18 @@ export type AgentCommandReadOnlyReason = "packageRunner" | "registry" | "customA
export type AgentCommandSource = "command" | "skill";
/** A commit made while the task ran, with the files it changed against its first parent. */
export type AgentCommit = {
id: string,
shortId: string,
summary: string,
message: string,
timeMs: number | null,
/** First parent, or `None` for a root commit. */
parent: string | null,
files: AgentChange[],
};
/** A setting chosen for a new task, by [`AgentConfigOption::id`]. */
export type AgentConfigChoice = {
id: string,
@@ -3126,6 +3174,8 @@ export type ConsoleSettings = {
encoding?: string,
/** Consecutive lines containing any of these are folded. */
foldPatterns?: string[],
/** Log lines take the colour of their level (Run/Debug consoles). */
logColors?: LogLevelColors,
};
export type ContainerOp = "start" | "stop" | "restart" | "pause" | "unpause" | "kill";
@@ -4311,6 +4361,12 @@ export type EditorAppearanceSettings = {
/** Right margin and visual guides from Code Style. */
hardWrapGuides?: boolean,
intentionBulb?: boolean,
/**
* Draw doc comments (Javadoc, KDoc, JSDoc, rustdoc) as rendered documentation
* instead of their source, as IDEA's option of the same name. View › Active
* Editor › Render Documentation Comments overrides this for one editor.
*/
renderDocComments?: boolean,
/** Distraction Free Mode: width of the centred text column, in characters. */
distractionFreeWidth?: number,
};
@@ -6635,6 +6691,20 @@ export type LogDetails = {
export type LogLevel = "debug" | "info" | "warn" | "error";
/**
* Per-level colours for console log lines: a line whose level is detected
* (`ERROR`, `WARN`, …) takes the level's colour; ANSI colours the program
* prints itself win over it. Values are CSS colours, usually scheme tokens.
*/
export type LogLevelColors = {
enabled?: boolean,
error?: string,
warn?: string,
info?: string,
debug?: string,
trace?: string,
};
export type LogLocation = {
dir: IpcPath,
/** Current log file, if one was written yet. */
@@ -6825,7 +6895,11 @@ export type MavenProject = {
profiles: string[],
/** Java feature version the project compiles for (`maven.compiler.release`, `java.version`…). */
javaVersion: number | null,
/** Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`. */
/**
* Maven launcher: the nearest `mvnw` (absolute), else `mvn`. Inside the opened
* project the search stops at its root; outside it, the search stops before
* that root so a linked project keeps its own wrapper.
*/
command: string,
/** Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings. */
options: string[],
@@ -6839,7 +6913,11 @@ export type MavenSettings = {
runnerJre?: string,
/** Project-relative root POMs removed from the Maven project model. */
unlinkedProjects?: string[],
/** Project-relative POMs outside the root reactor added as independent top-level projects. */
/**
* POMs added as independent top-level projects. Inside the opened project the path is
* project-relative (`tools/pom.xml`); outside it, the canonical absolute path. Linking
* does not edit either POM.
*/
linkedProjects?: string[],
/** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */
home?: string,
+14
Переглянути файл
@@ -49,6 +49,7 @@ import {
} from "./cm/folding";
import { reverseLines, sortLines } from "./cm/lines";
import { customRegions } from "./cm/regions";
import { toggleRenderedViewAtCaret } from "./cm/renderedDocs";
import { enclosingHeaders } from "./cm/stickyScroll";
import { moveElementLeft, moveElementRight } from "./cm/moveElement";
import { caretAnchor, openPopup, updatePopup, useLspPopup, type LspPopup } from "@/editor/lsp/popupStore";
@@ -748,6 +749,19 @@ export function registerEditorActions(): () => void {
else activeSlot()?.model.toggleSoftWrap();
},
},
{
id: "editor.toggleRenderedDocs",
title: "Render Documentation Comments",
category: "View",
keywords: ["render documentation comments", "javadoc", "rendered doc", "comments"],
when: "textEditorActive",
toggled: () => activeSlot()?.model.renderedDocsOn() ?? false,
run: () => activeSlot()?.model.toggleRenderedDocs(),
},
text("editor.toggleRenderedView", "Toggle Rendered View", (v) => toggleRenderedViewAtCaret(v), {
category: "View",
keywords: ["reader mode", "render documentation comment", "javadoc", "edit comment"],
}),
text("code.foldAll", "Collapse All", (v) => collapseAll(v), { category: "Folding", icon: "collapse-all" }),
text("code.unfoldAll", "Expand All", (v) => expandAll(v), { category: "Folding", icon: "expand-all" }),
text("code.collapseRecursively", "Collapse Recursively", (v) => collapseRecursively(v), {
+185
Переглянути файл
@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import { extractInlineTags, hasContent, renderDoc, restoreInlineTags, splitBody, stripMarkers } from "./docRender";
describe("hasContent", () => {
it("agrees with renderDoc on which comments render to nothing", () => {
const cases: Array<[string, "block" | "line"]> = [
["/** */", "block"],
["/**\n *\n */", "block"],
["/** Books a room. */", "block"],
["/**\n * @param id the room\n */", "block"],
["/** @hide */", "block"],
["///", "line"],
["/// Frees a room.", "line"],
["//!", "line"],
];
for (const [text, style] of cases)
expect(hasContent(text, style), text).toBe(renderDoc(text, style, (md) => `<p>${md}</p>`) !== null);
});
});
/** Stands in for the Markdown renderer: wraps a paragraph, leaves the text alone. */
const markdown = (md: string) => `<p>${md}</p>`;
describe("stripMarkers", () => {
it("drops the markers of a block comment", () => {
const text = ["/**", " * One.", " * Two.", " */"].join("\n");
expect(stripMarkers(text, "block")).toBe("One.\nTwo.");
});
it("keeps the indent that follows the marker, so code blocks survive", () => {
const text = ["/**", " * Text:", " *", " * code();", " */"].join("\n");
expect(stripMarkers(text, "block")).toBe("Text:\n\n code();");
});
it("handles a one-line comment", () => {
expect(stripMarkers("/** Just this. */", "block")).toBe("Just this.");
});
it("does not eat a line that only closes the comment", () => {
expect(stripMarkers("/**\n * Text.\n */", "block")).toBe("Text.");
});
it("drops /// and //! of line comments", () => {
expect(stripMarkers("/// One.\n/// Two.", "line")).toBe("One.\nTwo.");
expect(stripMarkers("//! Crate docs.", "line")).toBe("Crate docs.");
});
});
describe("splitBody", () => {
it("separates the description from block tags", () => {
const { description, tags } = splitBody("Does a thing.\n\n@param id the id\n@return the thing");
expect(description).toBe("Does a thing.");
expect(tags).toEqual([
{ tag: "param", name: "id", type: null, text: "the id" },
{ tag: "return", name: null, type: null, text: "the thing" },
]);
});
it("lets a tag's text wrap onto the following lines", () => {
const { tags } = splitBody("@param id the id,\nwhich wraps");
expect(tags).toEqual([{ tag: "param", name: "id", type: null, text: "the id,\nwhich wraps" }]);
});
it("leaves an @ inside a sentence alone", () => {
const { description, tags } = splitBody("Mail to [email protected] for help.");
expect(description).toBe("Mail to [email protected] for help.");
expect(tags).toEqual([]);
});
it("keeps an unknown tag whole rather than splitting off a name", () => {
expect(splitBody("@apiNote Call it twice.").tags).toEqual([
{ tag: "apiNote", name: null, type: null, text: "Call it twice." },
]);
});
it("reads a line starting with @ inside a code block as code, not as a tag", () => {
const pre = splitBody("Use it:\n<pre>\n@Override\nvoid run() {}\n</pre>\nDone.");
expect(pre.tags).toEqual([]);
expect(pre.description).toBe("Use it:\n<pre>\n@Override\nvoid run() {}\n</pre>\nDone.");
const fenced = splitBody("Use it:\n```\n@Test\nfun works() {}\n```\n@param id the id");
expect(fenced.description).toContain("@Test");
expect(fenced.tags.map((t) => t.tag)).toEqual(["param"]);
});
it("splits off the type of a JSDoc or PHPDoc tag, so it is not taken for the name", () => {
expect(splitBody("@param {number} a - the first").tags).toEqual([
{ tag: "param", name: "a", type: "number", text: "the first" },
]);
expect(splitBody("@param {Map<string, {id: number}>} m the map").tags[0]).toMatchObject({
name: "m",
type: "Map<string, {id: number}>",
});
expect(splitBody("@param int $a the first").tags).toEqual([
{ tag: "param", name: "$a", type: "int", text: "the first" },
]);
expect(splitBody("@throws {RangeError} when out of range").tags).toEqual([
{ tag: "throws", name: "RangeError", type: null, text: "when out of range" },
]);
expect(splitBody("@returns {string} the name").tags).toEqual([
{ tag: "returns", name: null, type: "string", text: "the name" },
]);
});
});
describe("extractInlineTags", () => {
it("renders {@code} as escaped code", () => {
const { text, html } = extractInlineTags("Use {@code a < b}.");
expect(html).toEqual(["<code>a &#60; b</code>"]);
expect(restoreInlineTags(text, html)).toBe("Use <code>a &#60; b</code>.");
});
it("keeps a code span out of the Markdown, so a_b_c is not italicised", () => {
const { text } = extractInlineTags("{@code a_b_c}");
expect(text).not.toContain("a_b_c");
});
it("shows a link's label when it has one, the target otherwise", () => {
expect(extractInlineTags("{@link Foo#bar(int) the bar}").html[0]).toContain("the bar");
expect(extractInlineTags("{@link Foo#bar(int)}").html[0]).toContain("Foo#bar(int)");
});
it("counts nesting, so a brace inside {@code} does not end it", () => {
const { html } = extractInlineTags("{@code Map<K, {V}>} rest");
expect(html).toEqual(["<code>Map&#60;K, {V}&#62;</code>"]);
});
it("leaves an unclosed tag as written", () => {
const text = "{@code never closed";
expect(extractInlineTags(text)).toEqual({ text, html: [] });
});
it("drops {@inheritDoc}, which has nothing of its own to show", () => {
expect(extractInlineTags("{@inheritDoc}").html).toEqual([""]);
});
});
describe("renderDoc", () => {
it("renders the description and groups the tags into sections", () => {
const comment = [
"/**",
" * Books a room.",
" *",
" * @param id the room",
" * @param when the day",
" * @return the booking",
" * @throws Busy if it is taken",
" */",
].join("\n");
const doc = renderDoc(comment, "block", markdown);
expect(doc?.description).toBe("<p>Books a room.</p>");
expect(doc?.sections.map((s) => s.title)).toEqual(["Params", "Returns", "Throws"]);
expect(doc?.sections[0]?.rows.map((r) => r.name)).toEqual(["id", "when"]);
expect(doc?.sections[2]?.rows[0]?.name).toBe("Busy");
});
it("puts @return and @returns in one section", () => {
const doc = renderDoc("/**\n * @return a\n * @returns b\n */", "block", markdown);
expect(doc?.sections).toHaveLength(1);
expect(doc?.sections[0]?.rows).toHaveLength(2);
});
it("shows every tag: those it knows under their titles, any other under its own name", () => {
const doc = renderDoc(
"/**\n * Adds.\n * @example\n * add(1, 2)\n * @property id the id\n * @hide\n * @custom it\n */",
"block",
(md) => md,
);
expect(doc?.sections.map((s) => [s.title, s.translate])).toEqual([
["Properties", true],
["Examples", true],
["@hide", false],
["@custom", false],
]);
expect(doc?.sections[1]?.rows[0]?.html).toBe("add(1, 2)");
expect(doc?.sections[3]?.rows[0]?.html).toBe("it");
});
it("returns null for a comment with nothing to show", () => {
expect(renderDoc("/**\n *\n */", "block", markdown)).toBeNull();
});
it("renders a tagless comment", () => {
expect(renderDoc("/** Just prose. */", "block", markdown)?.description).toBe("<p>Just prose.</p>");
});
});
+332
Переглянути файл
@@ -0,0 +1,332 @@
/**
* Doc comment → HTML, for rendered doc comments (`renderedDocs.ts`) and anything else that
* wants a comment as prose.
*
* One path serves Javadoc, KDoc, JSDoc, PHPDoc and rustdoc: the body is handed to
* the Markdown renderer, which passes the raw HTML of a Javadoc body (`<p>`, `<b>`,
* `<code>`) straight through while rendering the Markdown of a KDoc or rustdoc one.
* Inline tags (`{@code}`, `{@link}`…) and block tags (`@param`, `@return`…) are
* ours to expand, and neither Markdown nor HTML knows them.
*
* Inline tags become placeholders before the Markdown runs and their HTML goes back
* in afterwards: their text is code, not Markdown, and `{@code a_b_c}` must not come
* out italicised. The HTML put back is built here from escaped text, so it is safe
* although it never passes through the sanitizer.
*/
/** A doc comment's text, by the markers that open it. */
export type DocStyle = "block" | "line";
export interface DocComment {
/** Document offset of the first character of the comment. */
from: number;
/** Document offset just past its last character. */
to: number;
style: DocStyle;
/** The comment verbatim, markers included. */
text: string;
}
/**
* Block tags given their own section, in the order IDEA lists them. Named tags start
* with what they document (`@param name`, `@throws Type`). Any other tag still shows,
* after these, under its own name.
*/
const BLOCK_SECTIONS: Array<{ tag: string; title: string; named: boolean }> = [
{ tag: "param", title: "Params", named: true },
{ tag: "tparam", title: "Type params", named: true },
{ tag: "template", title: "Type params", named: true },
{ tag: "property", title: "Properties", named: true },
{ tag: "receiver", title: "Receiver", named: false },
{ tag: "constructor", title: "Constructor", named: false },
{ tag: "return", title: "Returns", named: false },
{ tag: "returns", title: "Returns", named: false },
{ tag: "throws", title: "Throws", named: true },
{ tag: "exception", title: "Throws", named: true },
{ tag: "see", title: "See also", named: false },
{ tag: "since", title: "Since", named: false },
{ tag: "author", title: "Author", named: false },
{ tag: "version", title: "Version", named: false },
{ tag: "deprecated", title: "Deprecated", named: false },
{ tag: "apiNote", title: "API Note", named: false },
{ tag: "implSpec", title: "Implementation Requirements", named: false },
{ tag: "implNote", title: "Implementation Note", named: false },
{ tag: "example", title: "Examples", named: false },
{ tag: "sample", title: "Samples", named: false },
];
const SECTION_BY_TAG = new Map(BLOCK_SECTIONS.map((s) => [s.tag, s]));
/** `@tag` at the start of a stripped line opens a block tag; `@` inside a sentence does not. */
const BLOCK_TAG = /^@(\w+)[ \t]*([\s\S]*)$/;
export function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}
/**
* The comment's content without its markers: the opening and closing ones, and the
* leading `*` of each line, or the `///` of each line. The indent that follows a
* marker is kept, so an indented code block inside the comment still is one.
*/
export function stripMarkers(text: string, style: DocStyle): string {
const lines = text.split("\n");
if (style === "line") {
return lines.map((l) => l.replace(/^[ \t]*\/\/[/!][ \t]?/, "")).join("\n");
}
const body = lines.map((line, i) => {
let l = line;
if (i === 0) l = l.replace(/^[ \t]*\/\*+[ \t]?/, "");
if (i === lines.length - 1) l = l.replace(/[ \t]*\*+\/[ \t]*$/, "");
if (i > 0) l = l.replace(/^[ \t]*\*(?!\/)[ \t]?/, "");
return l;
});
// A comment opening on its own line leaves a blank first line, and closing on its own a blank last one.
while (body.length && !body[0]!.trim()) body.shift();
while (body.length && !body.at(-1)!.trim()) body.pop();
return body.join("\n");
}
export interface BlockTag {
tag: string;
/** The first word for `@param name text` and `@throws Type text`; null otherwise. */
name: string | null;
/** The type a JSDoc (`@param {number} a`) or PHPDoc (`@param int $a`) tag gives; null otherwise. */
type: string | null;
text: string;
}
/** A leading `{type}` (JSDoc), braces balanced, split from the rest of the tag's text. */
function leadingType(text: string): { type: string; rest: string } | null {
if (!text.startsWith("{")) return null;
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === "{") depth++;
else if (text[i] === "}" && --depth === 0) return { type: text.slice(1, i).trim(), rest: text.slice(i + 1).trim() };
}
return null;
}
/** A tag's text after its name: JSDoc separates the two with ` - `, which is not part of it. */
const afterName = (text: string) => text.replace(/^-\s+/, "").trim();
/** Splits a named tag's text into what it documents, its type when given, and the rest. */
function namedTag(tag: string, text: string): Omit<BlockTag, "tag"> {
const typed = leadingType(text);
if (typed) {
// `@throws {Error} when…`: the type is what the tag names.
if (tag === "throws" || tag === "exception") {
return { name: typed.type, type: null, text: afterName(typed.rest) };
}
const m = /^(\S+)\s*([\s\S]*)$/.exec(typed.rest);
return { name: m?.[1] ?? null, type: typed.type, text: afterName(m?.[2] ?? "") };
}
// PHPDoc puts the type first and the variable second: `@param int $a`.
const php = /^([^\s$]\S*)\s+(\$\S+)\s*([\s\S]*)$/.exec(text);
if (php) return { name: php[2]!, type: php[1]!, text: afterName(php[3]!) };
const m = /^(\S+)[ \t]*([\s\S]*)$/.exec(text);
return { name: m?.[1] ?? null, type: null, text: afterName(m?.[2] ?? "") };
}
/** A code block's opening or closing line: a Markdown fence, or HTML `<pre>`. */
const FENCE = /^(```|~~~)/;
/** How the `<pre>` blocks a line opens and closes change the depth of code it leaves. */
function preDepthChange(line: string): number {
return (line.match(/<pre[\s>]/gi)?.length ?? 0) - (line.match(/<\/pre>/gi)?.length ?? 0);
}
export interface DocBody {
description: string;
tags: BlockTag[];
}
/**
* Splits the stripped body into its description and block tags. A tag's text runs
* to the next block tag, so a `@param` may wrap over several lines.
*/
export function splitBody(body: string): DocBody {
const lines = body.split("\n");
const description: string[] = [];
const tags: BlockTag[] = [];
let current: { tag: string; lines: string[] } | null = null;
const flush = () => {
if (!current) return;
const section = SECTION_BY_TAG.get(current.tag);
const text = current.lines.join("\n").trim();
if (section?.named) {
tags.push({ tag: current.tag, ...namedTag(current.tag, text) });
} else {
// `@returns {string} the name`: JSDoc types the value too.
const typed = leadingType(text);
tags.push({
tag: current.tag,
name: null,
type: typed?.type ?? null,
text: typed ? afterName(typed.rest) : text,
});
}
current = null;
};
// Inside a code block, a line starting with `@` is code (`@Override`), not a block tag.
let fenced = false;
let pre = 0;
for (const line of lines) {
const trimmed = line.trim();
const m = fenced || pre > 0 ? null : BLOCK_TAG.exec(trimmed);
if (FENCE.test(trimmed)) fenced = !fenced;
pre = Math.max(0, pre + preDepthChange(line));
if (m) {
flush();
current = { tag: m[1]!, lines: [m[2] ?? ""] };
} else if (current) {
current.lines.push(line);
} else {
description.push(line);
}
}
flush();
return { description: description.join("\n").trim(), tags };
}
/** The label an inline tag shows: `{@link Foo#bar(int) the label}` shows the label when it has one. */
function linkLabel(body: string): string {
const trimmed = body.trim();
const space = /\s/.exec(trimmed);
if (!space) return trimmed;
const target = trimmed.slice(0, space.index);
const label = trimmed.slice(space.index).trim();
return label || target;
}
/**
* Replaces each `{@tag …}` with a placeholder, collecting the HTML it stands for.
* Nesting is counted so `{@code Map<K, {@literal V}>}` ends at the right brace.
*/
export function extractInlineTags(text: string): { text: string; html: string[] } {
const html: string[] = [];
let out = "";
let i = 0;
while (i < text.length) {
const start = text.indexOf("{@", i);
if (start < 0) {
out += text.slice(i);
break;
}
const m = /^\{@(\w+)[ \t]*/.exec(text.slice(start));
if (!m) {
out += text.slice(i, start + 2);
i = start + 2;
continue;
}
let depth = 1;
let j = start + m[0].length;
for (; j < text.length && depth > 0; j++) {
if (text[j] === "{") depth++;
else if (text[j] === "}") depth--;
}
if (depth > 0) {
// Unclosed: leave it as written rather than swallowing the rest of the comment.
out += text.slice(i);
break;
}
out += text.slice(i, start);
out += placeholder(html.length);
html.push(inlineTagHtml(m[1]!, text.slice(start + m[0].length, j - 1)));
i = j;
}
return { text: out, html };
}
/**
* U+E000 is in the private use area: no comment contains it, and Markdown leaves it
* alone. The digits are delimited so `…0…` and `…01…` cannot be confused.
*/
function placeholder(index: number): string {
return `${index}`;
}
const PLACEHOLDER = /(\d+)/g;
function inlineTagHtml(tag: string, body: string): string {
switch (tag) {
case "code":
case "systemProperty":
return `<code>${escapeHtml(body)}</code>`;
case "literal":
return escapeHtml(body);
case "link":
case "linkplain":
return `<code class="cm-openidea-docLink">${escapeHtml(linkLabel(body))}</code>`;
case "value":
return `<code>${escapeHtml(linkLabel(body))}</code>`;
case "inheritDoc":
return "";
default:
return escapeHtml(body);
}
}
/** Puts the inline tags' HTML back where their placeholders are. */
export function restoreInlineTags(html: string, tags: string[]): string {
return html.replace(PLACEHOLDER, (all, index: string) => tags[Number(index)] ?? all);
}
export interface RenderedDoc {
/** The description, as HTML. */
description: string;
/**
* One entry per block-tag section: those of `BLOCK_SECTIONS` in its order, then any
* other tag in the order it comes, titled by the tag as written (`@hide`), which is
* code and is not translated.
*/
sections: Array<{
title: string;
translate: boolean;
rows: Array<{ name: string | null; type: string | null; html: string }>;
}>;
}
/**
* Whether `comment` has anything to render, without rendering it: `renderDoc` returns
* null exactly when it has neither text nor tags. Cheap enough to ask of every comment
* in a file, which rendering is not.
*/
export function hasContent(comment: string, style: DocStyle): boolean {
const { description, tags } = splitBody(stripMarkers(comment, style));
return description.trim() !== "" || tags.length > 0;
}
/**
* Renders `comment` with `markdown`, the Markdown-to-safe-HTML function (so this
* module stays free of the parser and its 40 KB). Returns null when the comment
* carries nothing to show.
*/
export function renderDoc(comment: string, style: DocStyle, markdown: (md: string) => string): RenderedDoc | null {
const { description, tags } = splitBody(stripMarkers(comment, style));
const render = (text: string) => {
if (!text.trim()) return "";
const extracted = extractInlineTags(text);
return restoreInlineTags(markdown(extracted.text), extracted.html);
};
const descriptionHtml = render(description);
const sections: RenderedDoc["sections"] = [];
const row = (t: BlockTag) => ({ name: t.name, type: t.type, html: render(t.text) });
for (const section of BLOCK_SECTIONS) {
const rows = tags.filter((t) => t.tag === section.tag).map(row);
if (!rows.length) continue;
// `@return` and `@returns` are the same section: extend the one already there.
const existing = sections.find((s) => s.translate && s.title === section.title);
if (existing) existing.rows.push(...rows);
else sections.push({ title: section.title, translate: true, rows });
}
for (const t of tags) {
if (SECTION_BY_TAG.has(t.tag)) continue;
const title = `@${t.tag}`;
const existing = sections.find((s) => !s.translate && s.title === title);
if (existing) existing.rows.push(row(t));
else sections.push({ title, translate: false, rows: [row(t)] });
}
if (!descriptionHtml && !sections.length) return null;
return { description: descriptionHtml, sections };
}
+2 -1
Переглянути файл
@@ -1,7 +1,8 @@
import { StreamLanguage, type StreamParser } from "@codemirror/language";
import { logWarning } from "./highlight";
const LEVELS =
/** Level vocabulary, shared with the console line colouring (`ui/logColors`). */
export const LEVELS =
"FATAL|CRITICAL|SEVERE|EMERG|ALERT|ERROR|ERR|WARNING|WARN|NOTICE|INFO|DEBUG|TRACE|VERBOSE|FINEST|FINER|FINE";
// Upper case anywhere; any case only where a level is unmistakable: `[warn]`, `<Info>`, `level=error`.
const LEVEL_UPPER = new RegExp(`^(?:${LEVELS})\\b`);
+4
Переглянути файл
@@ -50,6 +50,7 @@ import {
} from "./editing";
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
import { enterInDocComment } from "./docComment";
import { renderDocComments } from "./renderedDocs";
import { skipClosingQuote, smartAngleBrackets, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
import { stringClipboard } from "./stringClipboard";
import { filterCopiedText } from "./clipboard";
@@ -485,6 +486,8 @@ export interface PrefsOptions {
softWrap: boolean | undefined;
/** Soft wraps toggled for the file, overriding `softWrap` and the file patterns. */
wrapOverride?: boolean | null;
/** Rendered doc comments toggled for the file, overriding `appearance.renderDocComments`. */
renderedDocsOverride?: boolean | null;
showWhitespace: boolean | undefined;
/** Hard wrap column for this file's language. */
hardWrapAt: number;
@@ -578,6 +581,7 @@ export function prefsExtension(o: PrefsOptions): Extension {
out.push(marginGuides([o.hardWrapAt, ...prefs.codeStyle.visualGuides]));
if (prefs.appearance.methodSeparators)
out.push(EditorView.editorAttributes.of({ class: "cm-openidea-methodSeparators" }));
if (o.renderedDocsOverride ?? prefs.appearance.renderDocComments) out.push(renderDocComments.of(true));
const hidden = GUTTER_ICON_GROUPS.filter(
(grp) => !prefs.showGutterIcons || prefs.gutterIconsDisabled.includes(grp.id),
);
+276
Переглянути файл
@@ -0,0 +1,276 @@
import { afterEach, expect, it, vi } from "vitest";
import { act } from "react";
import { fireEvent } from "@testing-library/react";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { java } from "@codemirror/lang-java";
import { useI18n } from "@/platform/i18n";
import { renderDocComments, renderedDocsExtension, renderedRanges, toggleRenderedViewAtCaret } from "./renderedDocs";
let view: EditorView | null = null;
const i18n = useI18n.getState();
afterEach(() => {
view?.destroy();
view = null;
useI18n.setState(i18n);
});
const DOC = ["/**", " * Books a room.", " * @param n nights", " */", "class Rooms {}"].join("\n");
function open(reader: boolean, doc = DOC) {
view = new EditorView({
state: EditorState.create({
doc,
extensions: [java(), renderedDocsExtension, renderDocComments.of(reader)],
}),
parent: document.body,
});
return view;
}
const toggles = (v: EditorView) => [...v.dom.querySelectorAll<HTMLElement>(".cm-openidea-docToggle")];
/** Moves the mouse over the line of `line` (1-based). jsdom lays nothing out, so the block under it is given. */
async function hover(v: EditorView, line: number) {
vi.spyOn(v, "lineBlockAtHeight").mockReturnValue(v.lineBlockAt(v.state.doc.line(line).from));
await act(async () => fireEvent.mouseMove(v.dom, { clientY: v.documentTop }));
}
const pressed = (v: EditorView) => toggles(v).map((t) => t.getAttribute("aria-pressed"));
/** Whether the gutter gives the toggles a column: its spacer has their width, else none. */
const hasColumn = (v: EditorView) =>
[...v.dom.querySelectorAll<HTMLElement>(".cm-openidea-docToggles .cm-gutterElement span")].some(
(el) => el.style.width === "14px",
);
/** Opens the file rendering its comments, then brings this one's source back with its pencil. */
async function openFlippedToSource() {
const v = open(true);
await hover(v, 1);
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(0);
return v;
}
it("shows the pencil beside a rendered comment, and clicking it brings back the source", async () => {
const v = open(true);
expect(renderedRanges(v.state)).toHaveLength(1);
await hover(v, 1);
const [pencil] = toggles(v);
// A rendered comment is a block widget: the gutter must still give it its toggle.
expect(pencil?.getAttribute("aria-pressed")).toBe("true");
await act(async () => fireEvent.mouseDown(pencil!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(0);
expect(toggles(v)[0]?.getAttribute("aria-pressed")).toBe("false");
});
it("shows the lines icon beside a source comment, and clicking it renders the comment", async () => {
const v = await openFlippedToSource();
await hover(v, 2);
const [lines] = toggles(v);
expect(lines?.getAttribute("aria-pressed")).toBe("false");
await act(async () => fireEvent.mouseDown(lines!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(1);
expect(toggles(v)[0]?.getAttribute("aria-pressed")).toBe("true");
});
it("shows a source comment's icon only while the mouse is over it, as IDEA does", async () => {
const v = await openFlippedToSource();
await act(async () => fireEvent.mouseLeave(v.dom));
expect(toggles(v)).toHaveLength(0);
// The column stays, so the text does not shift as the icon comes and goes.
expect(hasColumn(v)).toBe(true);
await hover(v, 3);
expect(pressed(v)).toEqual(["false"]);
await hover(v, 5);
expect(toggles(v)).toHaveLength(0);
await hover(v, 1);
await act(async () => fireEvent.mouseLeave(v.dom));
expect(toggles(v)).toHaveLength(0);
});
it("shows a rendered comment's pencil only while the mouse is over it", async () => {
const v = open(true);
expect(toggles(v)).toHaveLength(0);
await hover(v, 1);
expect(pressed(v)).toEqual(["true"]);
await hover(v, 5);
expect(toggles(v)).toHaveLength(0);
});
it("leaves the icons to the mouse, not the caret", async () => {
const v = await openFlippedToSource();
await act(async () => fireEvent.mouseLeave(v.dom));
await act(async () => v.dispatch({ selection: { anchor: v.state.doc.line(2).from + 4 } }));
expect(toggles(v)).toHaveLength(0);
});
it("while off, shows the lines icon over a comment, and clicking it renders that one, as IDEA does", async () => {
const v = open(false);
expect(hasColumn(v)).toBe(true);
expect(toggles(v)).toHaveLength(0);
await hover(v, 2);
expect(pressed(v)).toEqual(["false"]);
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(1);
await hover(v, 1);
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(0);
expect(hasColumn(v)).toBe(true);
});
it("while off, gives a file without doc comments no column, and finds a new one once typing pauses", async () => {
vi.useFakeTimers();
try {
const v = open(false, "class Rooms {}");
expect(hasColumn(v)).toBe(false);
await hover(v, 1);
expect(toggles(v)).toHaveLength(0);
await act(async () => v.dispatch({ changes: { from: 0, insert: "/** Books a room. */\n" } }));
// Not scanned on the keystroke: only once typing pauses.
expect(hasColumn(v)).toBe(false);
await act(async () => void vi.advanceTimersByTime(300));
expect(hasColumn(v)).toBe(true);
await hover(v, 1);
expect(pressed(v)).toEqual(["false"]);
} finally {
vi.useRealTimers();
}
});
it("while off, Toggle Rendered View renders one comment, and its pencil is the way back", async () => {
const v = open(false);
await act(async () => v.dispatch({ selection: { anchor: v.state.doc.line(2).from } }));
await act(async () => void toggleRenderedViewAtCaret(v));
expect(renderedRanges(v.state)).toHaveLength(1);
expect(hasColumn(v)).toBe(true);
await hover(v, 1);
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(0);
// The file still has its comment, so its column stays for the icon.
expect(hasColumn(v)).toBe(true);
});
it("renders an edited comment anew, and an unchanged one as before", async () => {
const v = open(true);
const text = () => v.dom.querySelector(".cm-openidea-renderedDoc")?.textContent ?? "";
expect(text()).toContain("Books a room.");
const at = v.state.doc.toString().indexOf("room");
await act(async () => v.dispatch({ changes: { from: at, to: at + 4, insert: "suite" } }));
expect(text()).toContain("Books a suite.");
await act(async () => v.dispatch({ changes: { from: at, to: at + 5, insert: "room" } }));
expect(text()).toContain("Books a room.");
});
it("makes the toggle a labelled button with IDEA's icons", async () => {
const v = open(true);
await hover(v, 1);
const [pencil] = toggles(v);
expect(pencil?.tagName).toBe("BUTTON");
expect(pencil?.getAttribute("aria-label")).toBe("Toggle Rendered View");
expect(pencil?.querySelector("svg[data-icon='gutter-javadoc-edit']")).not.toBeNull();
await act(async () => fireEvent.mouseDown(pencil!, { button: 0 }));
await hover(v, 2);
expect(toggles(v)[0]?.querySelector("svg[data-icon='gutter-javadoc-read']")).not.toBeNull();
});
it("shows the source of a rendered comment the caret is put inside, as by Find", async () => {
const v = open(true);
expect(renderedRanges(v.state)).toHaveLength(1);
// Its edges are where arrow keys stop on their way past: still rendered there.
await act(async () => v.dispatch({ selection: { anchor: 0 } }));
await act(async () => v.dispatch({ selection: { anchor: v.state.doc.line(4).to } }));
expect(renderedRanges(v.state)).toHaveLength(1);
const at = v.state.doc.toString().indexOf("room");
await act(async () => v.dispatch({ selection: { anchor: at, head: at + 4 } }));
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("indents by the code's character width, so a font size change keeps it in line", () => {
const v = open(true, ["class A {", "\t/**", "\t * Books.", "\t */", "\tvoid book() {}", "}"].join("\n"));
const body = v.dom.querySelector<HTMLElement>(".cm-openidea-renderedDoc")!;
// A tab is a tab size of columns, not one character.
expect(body.style.marginLeft).toContain(`${v.state.tabSize} * var(--openidea-doc-char`);
});
it("forgets a flip whose comment is deleted, so the feature turns off again", async () => {
const v = open(
false,
["/** Books a room. */", "class Rooms {}", "/** Lists the rooms. */", "class List {}"].join("\n"),
);
await act(async () => v.dispatch({ selection: { anchor: 0 } }));
await act(async () => void toggleRenderedViewAtCaret(v));
expect(renderedRanges(v.state)).toHaveLength(1);
// Delete the rendered comment, its line and all: the other comment stays as source.
await act(async () => v.dispatch({ changes: { from: 0, to: v.state.doc.line(2).from } }));
expect(renderedRanges(v.state)).toHaveLength(0);
// A flip left behind would keep the feature on: the remaining comment, now where the
// deleted one began, would render after its next edit.
await act(async () => v.dispatch({ changes: { from: v.state.doc.length, insert: " " } }));
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("does not flip a comment with nothing to render, nor give it an icon", async () => {
const v = open(false, ["/** */", "class Rooms {}"].join("\n"));
expect(toggleRenderedViewAtCaret(v)).toBe(false);
expect(hasColumn(v)).toBe(false);
await hover(v, 1);
expect(toggles(v)).toHaveLength(0);
});
it("draws the section titles and tooltips again in a new UI language", async () => {
const v = open(true);
const title = () => v.dom.querySelector(".cm-openidea-renderedDocSections dt")?.textContent;
expect(title()).toBe("Params:");
await act(async () =>
useI18n.setState({ locale: "zh", texts: { Params: "参数", "Toggle Rendered View": "切换渲染视图" } }),
);
expect(title()).toBe("参数:");
await hover(v, 1);
expect(toggles(v)[0]?.getAttribute("aria-label")).toBe("切换渲染视图");
});
/** The declarations the editor's theme gives `selector` (ending the rule's selector). */
function themeRule(selector: string): CSSStyleDeclaration | null {
for (const sheet of document.styleSheets)
for (const rule of sheet.cssRules)
if (rule instanceof CSSStyleRule && rule.selectorText.split(",").some((s) => s.trim().endsWith(selector)))
return rule.style;
return null;
}
it("gives the rendered comment no vertical margin, which would push the lines below off their numbers", () => {
open(true);
// The editor measures a block widget by its border box: a margin is height it never sees.
const rule = themeRule(".cm-openidea-renderedDoc")!;
expect(rule.marginTop || "0").toMatch(/^0(px)?$/);
expect(rule.marginBottom || "0").toMatch(/^0(px)?$/);
});
it("reflows the rendered text, so the source's line breaks do not carry over", () => {
open(true);
// The content is `white-space: pre`, which the widget inherits unless it says otherwise;
// jsdom does not compute inheritance, so the rule itself is what is checked.
expect(themeRule(".cm-openidea-renderedDoc")?.whiteSpace).toBe("normal");
// Code blocks keep theirs.
expect(themeRule(".cm-openidea-renderedDoc pre")?.whiteSpace).toBe("pre");
});
it("gives a {@link} the link colour, over the code colour", () => {
open(true);
expect(themeRule(".cm-openidea-renderedDoc code.cm-openidea-docLink")?.color).toBe("var(--accent)");
});
it("wraps the rendered text at the visible width, not the longest line's", () => {
const v = open(true);
const body = v.dom.querySelector<HTMLElement>(".cm-openidea-renderedDoc")!;
expect(body.style.maxWidth).toContain("var(--openidea-doc-width");
});
it("leaves the comment as it is when its rendered text is clicked", async () => {
const v = open(true);
const body = v.dom.querySelector<HTMLElement>(".cm-openidea-renderedDoc")!;
await act(async () => fireEvent.mouseDown(body, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(1);
});
+221
Переглянути файл
@@ -0,0 +1,221 @@
import { describe, expect, it } from "vitest";
import { Compartment, EditorSelection, EditorState } from "@codemirror/state";
import { java } from "@codemirror/lang-java";
import { javascript } from "@codemirror/lang-javascript";
import { ensureSyntaxTree } from "@codemirror/language";
import { findLanguageDescription, loadSupport } from "./languages";
import {
commentAt,
docComments,
renderCacheSize,
renderDocComments,
renderedDocsExtension,
renderedRanges,
toggleRenderedView,
} from "./renderedDocs";
const defaultMode = new Compartment();
function state(doc: string, { reader = true, language = java(), at = 0 } = {}) {
return EditorState.create({
doc,
selection: EditorSelection.cursor(at),
extensions: [language, renderedDocsExtension, defaultMode.of(renderDocComments.of(reader))],
});
}
/** The replaced ranges as 1-based `[firstLine, lastLine]` pairs. */
function replaced(s: EditorState): Array<[number, number]> {
return renderedRanges(s).map(([from, to]) => [s.doc.lineAt(from).number, s.doc.lineAt(to).number]);
}
const JAVA = [
"package p;", // 1
"", // 2
"/**", // 3
" * Books a room.", // 4
" *", // 5
" * @param id the room", // 6
" */", // 7
"public class Rooms {", // 8
" /** Frees a room. */", // 9
" public void free() {}", // 10
"}", // 11
].join("\n");
const BOOK = JAVA.indexOf("/**");
const FREE = JAVA.indexOf("/** Frees");
describe("docComments", () => {
it("finds each Javadoc block and its exact range", () => {
const s = state(JAVA);
const found = docComments(s);
expect(found.map((c) => [s.doc.lineAt(c.from).number, s.doc.lineAt(c.to).number])).toEqual([
[3, 7],
[9, 9],
]);
expect(found[0]!.style).toBe("block");
});
it("ignores an ordinary block comment", () => {
expect(docComments(state("/* not docs */\nclass A {}"))).toHaveLength(0);
});
it("ignores an empty block comment that only looks like one", () => {
expect(docComments(state("/**/\nclass A {}"))).toHaveLength(0);
});
it("ignores a doc comment sharing its line with code", () => {
expect(docComments(state("int a; /** docs */\n"))).toHaveLength(0);
});
it("joins a run of /// lines into one comment", () => {
const found = docComments(state("/// One.\n/// Two.\nfunction f() {}", { language: javascript() }));
expect(found).toHaveLength(1);
expect(found[0]!.style).toBe("line");
expect(found[0]!.text).toBe("/// One.\n/// Two.");
});
it("keeps two /// runs apart when a blank line separates them", () => {
expect(docComments(state("/// One.\n\n/// Two.\nfunction f() {}", { language: javascript() }))).toHaveLength(2);
});
it("ignores a plain // comment", () => {
expect(docComments(state("// just a note\nfunction f() {}", { language: javascript() }))).toHaveLength(0);
});
});
describe("the file's default", () => {
it("renders every doc comment when on", () => {
expect(replaced(state(JAVA))).toEqual([
[3, 7],
[9, 9],
]);
});
it("renders none when off", () => {
expect(replaced(state(JAVA, { reader: false }))).toEqual([]);
});
it("does not depend on the caret: one inside a comment leaves it rendered", () => {
expect(replaced(state(JAVA, { at: JAVA.indexOf("Books") }))).toEqual([
[3, 7],
[9, 9],
]);
});
it("does not change as the caret moves, so nothing flickers", () => {
const s = state(JAVA);
const moved = s.update({ selection: EditorSelection.cursor(JAVA.indexOf("public class")) }).state;
expect(replaced(moved)).toEqual(replaced(s));
});
it("leaves an empty doc comment as source, having nothing to show", () => {
expect(replaced(state("/**\n *\n */\nclass A {}"))).toEqual([]);
});
it("renders no comment until the editor draws it, so opening a large file stays cheap", () => {
const doc = Array.from({ length: 40 }, (_, i) => `/** Lazy comment ${i} of a large file. */\nclass L${i} {}`).join(
"\n",
);
const before = renderCacheSize();
const s = state(doc);
// Every comment is decided rendered: its source is replaced ...
expect(replaced(s)).toHaveLength(40);
// ... but none was rendered yet: no editor has drawn them.
expect(renderCacheSize()).toBe(before);
});
});
describe("per-comment toggle", () => {
it("switches one comment to source and leaves the others rendered", () => {
const s = state(JAVA).update({ effects: toggleRenderedView.of(BOOK) }).state;
expect(replaced(s)).toEqual([[9, 9]]);
});
it("renders one comment while the file's default is source", () => {
const s = state(JAVA, { reader: false }).update({ effects: toggleRenderedView.of(FREE) }).state;
expect(replaced(s)).toEqual([[9, 9]]);
});
it("toggling twice restores the default", () => {
const s = state(JAVA)
.update({ effects: toggleRenderedView.of(BOOK) })
.state.update({ effects: toggleRenderedView.of(BOOK) }).state;
expect(replaced(s)).toEqual([
[3, 7],
[9, 9],
]);
});
it("follows its comment through an edit above it", () => {
const s = state(JAVA)
.update({ effects: toggleRenderedView.of(BOOK) })
.state.update({ changes: { from: 0, insert: "// header\n" } }).state;
expect(replaced(s)).toEqual([[10, 10]]);
});
it("is cleared when the whole file switches, so every comment ends up the same", () => {
const flipped = state(JAVA).update({ effects: toggleRenderedView.of(BOOK) }).state;
const off = flipped.update({ effects: defaultMode.reconfigure(renderDocComments.of(false)) }).state;
expect(replaced(off)).toEqual([]);
const on = off.update({ effects: defaultMode.reconfigure(renderDocComments.of(true)) }).state;
expect(replaced(on)).toEqual([
[3, 7],
[9, 9],
]);
});
});
describe("commentAt", () => {
it("finds the comment around the caret", () => {
const s = state(JAVA);
expect(commentAt(s, JAVA.indexOf("Books"))?.from).toBe(BOOK);
});
it("finds the comment right above the caret's line", () => {
const s = state(JAVA);
expect(commentAt(s, JAVA.indexOf("public class"))?.from).toBe(BOOK);
expect(commentAt(s, JAVA.indexOf("public void"))?.from).toBe(FREE);
});
it("finds nothing on a line no comment documents", () => {
expect(commentAt(state(JAVA), 0)).toBeNull();
});
});
describe("languages", () => {
const BLOCK = "/**\n * Hello.\n * @param a the a\n */\n";
const LINES = "/// Hello.\n/// World.\n";
// Lezer grammars give a comment one node; the line-based modes (Kotlin, Scala, Groovy,
// Dart, C#, Swift) give each of its lines one. Both must come out as one comment.
it.each([
["Java", "A.java", BLOCK + "class A {}\n", [1, 4]],
["Kotlin", "A.kt", BLOCK + "class A\n", [1, 4]],
["Scala", "A.scala", BLOCK + "class A\n", [1, 4]],
["Groovy", "A.groovy", BLOCK + "class A {}\n", [1, 4]],
["Dart", "a.dart", BLOCK + "class A {}\n", [1, 4]],
["C#", "A.cs", LINES + "class A {}\n", [1, 2]],
["JavaScript", "a.js", BLOCK + "function a() {}\n", [1, 4]],
["TypeScript", "a.ts", BLOCK + "function a() {}\n", [1, 4]],
["PHP", "a.php", "<?php\n" + BLOCK + "function a() {}\n", [2, 5]],
["Rust", "a.rs", LINES + "fn a() {}\n", [1, 2]],
["C++", "a.cpp", BLOCK + "void a();\n", [1, 4]],
["Swift", "a.swift", LINES + "func a() {}\n", [1, 2]],
] as const)("finds a whole doc comment in %s", async (name, path, doc, [first, last]) => {
const support = await loadSupport(findLanguageDescription(name, `/p/${path}`)!);
const s = EditorState.create({ doc, extensions: support! });
ensureSyntaxTree(s, s.doc.length, 5000);
expect(docComments(s).map((c) => [s.doc.lineAt(c.from).number, s.doc.lineAt(c.to).number])).toEqual([
[first, last],
]);
});
it("drops a line-based comment that is never closed", async () => {
const support = await loadSupport(findLanguageDescription("Kotlin", "/p/A.kt")!);
const s = EditorState.create({ doc: "/**\n * Hello.\nclass A\n", extensions: support! });
ensureSyntaxTree(s, s.doc.length, 5000);
expect(docComments(s)).toEqual([]);
});
});
+850
Переглянути файл
@@ -0,0 +1,850 @@
/**
* Rendered doc comments: drawn as the documentation they are, in place of their
* source, as IDEA's "Render documentation comments".
*
* A rendered comment's lines are replaced by one block widget holding its HTML
* (`docRender.ts`). Which comments are rendered is decided in two layers, as in IDEA:
*
* - The file's default: Settings › Editor › General › Appearance › Render
* documentation comments, or what View › Active Editor › Render Documentation
* Comments made of it for this file (the document model keeps that override, as it
* does the soft-wrap one). It arrives as the `renderDocComments` facet.
* - Each comment's own gutter icon (and Toggle Rendered View at the caret), which
* flips that one comment away from the default. Changing the default clears them,
* so switching the whole file always leaves every comment in the same mode.
*
* Clicking the rendered text leaves the comment as it is: the text can be selected and
* copied like any prose, and the gutter icon is the way to the source. The caret can
* only get into a rendered comment some other way (Find, Go to Line, undo), and then,
* as in IDEA, the comment shows its source: else its text would be edited unseen.
*
* As in IDEA, a comment's icon shows while the mouse is over the comment, its source
* lines or its rendered block, or over the icon's own place in the gutter. So a file of
* comments is not a column of icons.
*
* Off, which is the default, the icon still shows over a comment, so one comment can be
* read rendered, as in IDEA. That costs typing nothing: while the file renders no
* comments and none is flipped, its comments are found only once typing pauses
* (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column
* only if the file has doc comments.
*/
import {
EditorState,
Facet,
countColumn,
MapMode,
RangeSet,
StateEffect,
StateField,
type Extension,
type Transaction,
} from "@codemirror/state";
import {
Decoration,
EditorView,
GutterMarker,
ViewPlugin,
WidgetType,
gutter,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
import { gutterIconMarkup } from "@/ui/icons";
import { emptyGutterSpacer } from "@/editor/api";
import { tx, useI18n } from "@/platform/i18n";
import { loadMarkdown, markupToHtml } from "@/editor/lsp/markdown";
import { hasContent, renderDoc, type DocComment, type DocStyle, type RenderedDoc } from "./docRender";
import { LINE_INSET } from "./lineInset";
/** The file's default: render its doc comments or not. */
export const renderDocComments = Facet.define<boolean, boolean>({
combine: (values) => values[0] ?? false,
});
/** Flips the comment starting at this position away from the file's default. */
export const toggleRenderedView = StateEffect.define<number>();
/** The mouse is over the comment starting here, or (null) over none. */
const hoverDoc = StateEffect.define<number | null>();
/** Announces that the Markdown parser has loaded and the comments can be rendered again. */
const markdownLoaded = StateEffect.define<null>();
/** Announces a new UI language: titles and tooltips are drawn again in it. */
const relabel = StateEffect.define<null>();
// --- finding the comments -----------------------------------------------------
/** A doc comment opens with `/**`, except when those are an empty block comment's four characters. */
function blockDocAt(text: string): boolean {
return text.startsWith("/**") && !text.startsWith("/**/");
}
/** rustdoc and C# `///`, and the `//!` that documents the enclosing item. */
function lineDocAt(text: string): boolean {
return /^\/\/[/!](?!\/)/.test(text);
}
/** Comment nodes, whatever the grammar calls them: `BlockComment`, `LineComment`, `docComment`, `comment`. */
const COMMENT_NODE = /comment/i;
/** Whether only whitespace lies between two document positions. */
function adjoins(state: EditorState, from: number, to: number): boolean {
return !state.doc.sliceString(from, to).trim();
}
/**
* The doc comments of `state`, in document order. Only comments that have their lines
* to themselves are listed: one sharing a line with code cannot be replaced by a block.
*
* A comment is assembled from the comment nodes rather than taken as one: a Lezer
* grammar (Java, JavaScript, PHP, Rust…) gives a block comment one node, but the
* line-based modes (Kotlin, Scala, Groovy, Dart, C#, Swift) give each of its lines a
* node of its own. So a `/**` node opens a comment that takes in the nodes after it
* until one closes it, and consecutive `///` lines join into one, as they are one doc
* block in every language that has them.
*/
export function docComments(state: EditorState): DocComment[] {
const out: DocComment[] = [];
/** The block comment being assembled, until a node ends it with the closing marker. */
let open: DocComment | null = null;
const finish = (comment: DocComment) => {
const first = state.doc.lineAt(comment.from);
const last = state.doc.lineAt(comment.to);
if (first.text.slice(0, comment.from - first.from).trim()) return;
if (last.text.slice(comment.to - last.from).trim()) return;
comment.text = state.doc.sliceString(comment.from, comment.to);
out.push(comment);
};
syntaxTree(state).iterate({
enter(node) {
if (!COMMENT_NODE.test(node.name)) return;
const text = state.doc.sliceString(node.from, node.to);
if (open) {
if (adjoins(state, open.to, node.from)) {
open.to = node.to;
if (text.trimEnd().endsWith("*/")) {
finish(open);
open = null;
}
return false;
}
// Something other than comment came first: it was never closed, so it is not one.
open = null;
}
if (blockDocAt(text)) {
const comment: DocComment = { from: node.from, to: node.to, style: "block", text };
if (text.length > 3 && text.trimEnd().endsWith("*/")) finish(comment);
else open = comment;
return false;
}
if (!lineDocAt(text)) return false;
const previous = out.at(-1);
if (
previous?.style === "line" &&
state.doc.lineAt(previous.to).number === state.doc.lineAt(node.from).number - 1 &&
adjoins(state, previous.to, node.from)
) {
// The next line of the same run: extend it rather than starting a comment.
previous.to = node.to;
previous.text = state.doc.sliceString(previous.from, node.to);
return false;
}
finish({ from: node.from, to: node.to, style: "line", text });
return false;
},
});
return out;
}
/**
* The comment Toggle Rendered View acts on from `pos`: the one around it, else the one
* right above its line (the documentation of the declaration the caret is on).
*/
export function commentAt(state: EditorState, pos: number): DocComment | null {
const line = state.doc.lineAt(pos).number;
for (const comment of docComments(state)) {
if (pos >= comment.from && pos <= comment.to) return comment;
if (state.doc.lineAt(comment.to).number === line - 1) return comment;
}
return null;
}
// --- per-comment flips ---------------------------------------------------------
/**
* Start positions of the comments flipped away from the file's default, mapped through
* edits so a flip follows its comment. A position that no longer starts a comment is
* simply never matched.
*/
const flipped = StateField.define<readonly number[]>({
create: () => [],
update(value, tr) {
if (tr.startState.facet(renderDocComments) !== tr.state.facet(renderDocComments)) return [];
// A flip whose comment lost its opening character is gone with it; one left behind
// would keep the feature active (scanning, its column) with nothing to show.
let next = tr.docChanged
? value.flatMap((pos) => {
const mapped = tr.changes.mapPos(pos, 1, MapMode.TrackAfter);
return mapped === null ? [] : [mapped];
})
: value;
for (const e of tr.effects) {
if (!e.is(toggleRenderedView)) continue;
next = next.includes(e.value) ? next.filter((pos) => pos !== e.value) : [...next, e.value];
}
return next;
},
});
/** Whether the file renders its comments, or has one flipped: else there is nothing to find or draw. */
function active(state: EditorState): boolean {
return state.facet(renderDocComments) || state.field(flipped).length > 0;
}
/** Whether the comment starting at `from` is rendered now. */
function isRendered(state: EditorState, from: number): boolean {
return state.facet(renderDocComments) !== state.field(flipped).includes(from);
}
// --- the widget ---------------------------------------------------------------
/** A section's title as shown: translated, unless it is a tag as written (`@hide`). */
const sectionTitle = (section: RenderedDoc["sections"][number]) =>
section.translate ? tx(section.title) : section.title;
/** Bumped when the UI language changes: widgets made before draw their titles in the old one. */
let labelEpoch = 0;
/** Whether the Markdown parser has loaded: widgets made before drew their text as plain text. */
let markdownIsLoaded = false;
/** Height of one rendered line, for the estimate of a widget not drawn yet. */
const ESTIMATED_LINE_HEIGHT = 20;
/**
* A rendered comment. It keeps the comment's source and renders it only when the editor
* draws it, which is when it scrolls into view: opening a file with a thousand comments
* renders the dozen on screen. Until then its height is estimated from the source.
*/
class DocWidget extends WidgetType {
/** Everything the DOM depends on, made once: the editor compares widgets on every update. */
readonly key: string;
constructor(
readonly style: DocStyle,
readonly text: string,
/** The comment's indent, in columns. */
readonly indent: number,
) {
super();
this.key = `${indent}\u0000${labelEpoch}\u0000${markdownIsLoaded ? 1 : 0}\u0000${style}\u0000${text}`;
}
override eq(other: DocWidget): boolean {
return this.key === other.key;
}
/** The source's lines less its opening and closing ones: about what the rendered text takes. */
override get estimatedHeight(): number {
let lines = 1;
for (let i = this.text.indexOf("\n"); i >= 0; i = this.text.indexOf("\n", i + 1)) lines++;
return Math.max(1, this.style === "block" ? lines - 2 : lines) * ESTIMATED_LINE_HEIGHT;
}
override toDOM(): HTMLElement {
const doc = renderCached(this.style, this.text) ?? { description: "", sections: [] };
const titles = doc.sections.map(sectionTitle);
const dom = document.createElement("div");
dom.className = "cm-openidea-renderedDoc";
// In the code's character width, which `docMetrics` keeps current: the DOM stays
// right through a font size change or zoom, which does not draw the widget again.
const left = `(${this.indent} * var(--openidea-doc-char, 1ch) + ${LINE_INSET}px)`;
dom.style.marginLeft = `calc${left}`;
// The content is as wide as its longest line; the comment wraps at the visible width instead.
dom.style.maxWidth = `calc(var(--openidea-doc-width, 100%) - ${left} - ${DOC_RIGHT_GAP}px)`;
dom.setAttribute("role", "note");
if (doc.description) {
const body = document.createElement("div");
body.className = "cm-openidea-renderedDocBody";
body.innerHTML = doc.description;
dom.appendChild(body);
}
if (doc.sections.length) {
const list = document.createElement("dl");
list.className = "cm-openidea-renderedDocSections";
doc.sections.forEach((section, s) => {
section.rows.forEach((row, i) => {
const title = document.createElement("dt");
// The section's title heads its first row only, as IDEA lists `Params:`.
if (i === 0) title.textContent = `${titles[s]}:`;
list.appendChild(title);
const value = document.createElement("dd");
if (row.name !== null) {
const name = document.createElement("code");
name.textContent = row.name;
value.appendChild(name);
}
if (row.type !== null) {
if (row.name !== null) value.appendChild(document.createTextNode(" "));
const type = document.createElement("code");
type.className = "cm-openidea-docType";
type.textContent = row.type;
value.appendChild(type);
}
if ((row.name !== null || row.type !== null) && row.html) value.appendChild(document.createTextNode(" – "));
const text = document.createElement("span");
text.className = "cm-openidea-renderedDocText";
text.innerHTML = row.html;
value.appendChild(text);
list.appendChild(value);
});
});
dom.appendChild(list);
}
return dom;
}
/**
* The editor leaves every event inside the widget to the browser: its text selects
* and copies like any prose, and a click does not move the caret or switch the mode.
*/
override ignoreEvent(): boolean {
return true;
}
}
// --- decorations and gutter markers -------------------------------------------
/**
* The comment's mode switch, with IDEA's icons: lines while it is source (to render
* it), a pencil while rendered (to edit it). The gutter takes it with the mouse, so the
* button is not in the tab order.
*/
class ToggleMarker extends GutterMarker {
constructor(
readonly rendered: boolean,
readonly label: string,
) {
super();
}
override eq(other: GutterMarker): boolean {
return other instanceof ToggleMarker && other.rendered === this.rendered && other.label === this.label;
}
override toDOM(): Node {
const button = document.createElement("button");
button.type = "button";
button.tabIndex = -1;
button.className = "cm-openidea-docToggle";
button.title = this.label;
button.setAttribute("aria-label", this.label);
button.setAttribute("aria-pressed", String(this.rendered));
button.innerHTML = gutterIconMarkup(this.rendered ? "gutter-javadoc-edit" : "gutter-javadoc-read") ?? "";
return button;
}
}
/** The toggle in the UI language of the moment: a new language makes a new marker, which the gutter draws again. */
function toggleMarker(rendered: boolean): ToggleMarker {
return new ToggleMarker(rendered, tx("Toggle Rendered View"));
}
/** Reserves the toggles' width. Not a `ToggleMarker`: the spacer is in the DOM, hidden, and must not read as a button. */
class ToggleSpacer extends GutterMarker {
override toDOM(): Node {
const el = document.createElement("span");
el.style.display = "inline-block";
el.style.width = "14px";
return el;
}
}
const toggleSpacer = new ToggleSpacer();
/** A doc comment, with where its first line starts and its last line ends, and whether it is rendered now. */
type PlacedComment = DocComment & { line: number; end: number; rendered: boolean };
interface DocsView {
decorations: DecorationSet;
/** Every doc comment, rendered or not. */
comments: ReadonlyArray<PlacedComment>;
}
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [] };
/**
* Rendered comments by their source, shared by every editor, least recently used first.
* A widget drawn again (scrolled back into view, or after an edit elsewhere) finds its
* comment here instead of rendering it again. Past `RENDER_CACHE_SIZE` the least recently
* used is dropped, so a large file does not empty the cache for the next one. Emptied
* when the Markdown parser loads: what was rendered before it is plain text.
*/
const renderCache = new Map<string, RenderedDoc | null>();
const RENDER_CACHE_SIZE = 2000;
function renderCached(style: DocStyle, text: string): RenderedDoc | null {
const key = style + "\u0000" + text;
let rendered = renderCache.get(key);
if (rendered === undefined) {
rendered = renderDoc(text, style, (md) => markupToHtml({ kind: "markdown", value: md }));
if (renderCache.size >= RENDER_CACHE_SIZE) renderCache.delete(renderCache.keys().next().value!);
} else {
// Move it to the most recently used end.
renderCache.delete(key);
}
renderCache.set(key, rendered);
return rendered;
}
/**
* Whether a comment has anything to render, by its source. Each edit rebuilds the view
* and asks it of every comment in the file; this keeps that to a lookup for all but the
* comment being edited.
*/
const contentCache = new Map<string, boolean>();
function hasContentCached(style: DocStyle, text: string): boolean {
const key = style + "\u0000" + text;
let content = contentCache.get(key);
if (content === undefined) {
content = renderCache.has(key) ? renderCache.get(key) !== null : hasContent(text, style);
if (contentCache.size >= RENDER_CACHE_SIZE * 5) contentCache.delete(contentCache.keys().next().value!);
contentCache.set(key, content);
}
return content;
}
/** Exported for tests: the number of comments rendered and cached. */
export function renderCacheSize(): number {
return renderCache.size;
}
function build(state: EditorState): DocsView {
if (!active(state)) return INACTIVE;
const decorations = [];
const comments: PlacedComment[] = [];
for (const comment of docComments(state)) {
const first = state.doc.lineAt(comment.from);
const last = state.doc.lineAt(comment.to);
// A comment with nothing to render keeps its source, and its icon says so. Telling
// which those are does not render anything: the widget renders when it is drawn.
const rendered = isRendered(state, comment.from) && hasContentCached(comment.style, comment.text);
comments.push({ ...comment, line: first.from, end: last.to, rendered });
if (!rendered) continue;
const indent = countColumn(first.text.slice(0, first.text.length - first.text.trimStart().length), state.tabSize);
decorations.push(
Decoration.replace({ widget: new DocWidget(comment.style, comment.text, indent), block: true }).range(
first.from,
last.to,
),
);
}
return { decorations: Decoration.set(decorations), comments };
}
/** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */
const hovered = StateField.define<number | null>({
create: () => null,
update(value, tr) {
for (const e of tr.effects) if (e.is(hoverDoc)) value = e.value;
return tr.docChanged && !tr.effects.some((e) => e.is(hoverDoc)) ? null : value;
},
});
/**
* The comments of a file that renders none, as source, with where their lines start and
* end. Those that would render to nothing are left out: they get no icon, and a file of
* only those no column.
*/
function placeComments(state: EditorState): PlacedComment[] {
return docComments(state)
.filter((c) => hasContentCached(c.style, c.text))
.map((c) => ({
...c,
line: state.doc.lineAt(c.from).from,
end: state.doc.lineAt(c.to).to,
rendered: false,
}));
}
/** Replaces the comments found while the file renders none. */
const setPassive = StateEffect.define<readonly PlacedComment[]>();
/**
* The doc comments of a file that renders none, for their icons. Rescanning the file on
* each keystroke would make typing in a large file several times slower for a feature
* that is off, so `passiveScanner` rescans once typing pauses; meanwhile the comments
* found are mapped through the edits. Empty while the file renders comments: `docsField`
* has them then.
*/
const passiveDocs = StateField.define<readonly PlacedComment[]>({
create: (state) => (active(state) ? [] : placeComments(state)),
update(value, tr) {
if (active(tr.state)) return value.length ? [] : value;
// Switched off: the comments rendered a moment ago are known only to the last scan.
if (active(tr.startState)) return placeComments(tr.state);
for (const e of tr.effects) if (e.is(setPassive)) return e.value;
if (!tr.docChanged) return value;
const map = (pos: number, assoc: number) => tr.changes.mapPos(pos, assoc);
return value.map((c) => ({
...c,
from: map(c.from, 1),
to: map(c.to, -1),
line: map(c.line, 1),
end: map(c.end, -1),
}));
},
});
/** Scans a file that renders no comments once the edits and the parser have paused. */
const passiveScanner = ViewPlugin.fromClass(
class {
timer: ReturnType<typeof setTimeout> | null = null;
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {
if (active(update.state)) return this.cancel();
if (update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) {
this.cancel();
this.timer = setTimeout(() => {
this.timer = null;
if (!active(this.view.state)) this.view.dispatch({ effects: setPassive.of(placeComments(this.view.state)) });
}, PASSIVE_SCAN_DELAY);
}
}
cancel() {
if (this.timer !== null) clearTimeout(this.timer);
this.timer = null;
}
destroy() {
this.cancel();
}
},
);
/** How long edits and parsing must pause before a file that renders no comments is scanned. */
const PASSIVE_SCAN_DELAY = 300;
/** Every doc comment of the file, as last found. */
function placedComments(state: EditorState): ReadonlyArray<PlacedComment> {
return active(state) ? state.field(docsField).comments : state.field(passiveDocs);
}
/**
* The source-mode toggle, beside the source comment the mouse is over, if it is over one
* that has something to render: one that would render to nothing stays source anyway.
*/
function sourceToggle(state: EditorState): RangeSet<GutterMarker> {
const at = state.field(hovered);
const comment = at === null ? null : placedComments(state).find((c) => c.from === at && !c.rendered);
if (!comment || (!active(state) && !hasContentCached(comment.style, comment.text))) return RangeSet.empty;
return RangeSet.of([toggleMarker(false).range(comment.line)]);
}
/** The comment whose lines, source or rendered, make up part of the line block `[from, to]`. */
function commentOverlapping(state: EditorState, from: number, to: number): number | null {
return placedComments(state).find((c) => from <= c.to && to >= c.line)?.from ?? null;
}
/**
* Follows the mouse over the editor, text and gutters alike, by the line block under it,
* so a comment's icon shows wherever on its lines the mouse is.
*/
const hoverTracker = ViewPlugin.fromClass(
class {
constructor(readonly view: EditorView) {
view.dom.addEventListener("mousemove", this.move);
view.dom.addEventListener("mouseleave", this.leave);
}
move = (event: MouseEvent) => {
const { view } = this;
if (!placedComments(view.state).length) return;
const block = view.lineBlockAtHeight(event.clientY - view.documentTop);
const inside = event.clientY - view.documentTop <= block.bottom && event.clientY >= view.documentTop;
this.set(inside ? commentOverlapping(view.state, block.from, block.to) : null);
};
leave = () => this.set(null);
set(at: number | null) {
if (this.view.state.field(hovered) !== at) this.view.dispatch({ effects: hoverDoc.of(at) });
}
destroy() {
this.view.dom.removeEventListener("mousemove", this.move);
this.view.dom.removeEventListener("mouseleave", this.leave);
}
},
);
/** The comment ranges rendered in place of their source, as `[from, to]` offsets. Exported for tests. */
export function renderedRanges(state: EditorState): Array<[number, number]> {
const out: Array<[number, number]> = [];
state.field(docsField).decorations.between(0, state.doc.length, (from, to) => void out.push([from, to]));
return out;
}
const docsField = StateField.define<DocsView>({
create: build,
update(value, tr) {
if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear();
const rebuild =
tr.docChanged ||
tr.reconfigured ||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel)) ||
// The parser finishing more of the file can bring comments it had not reached.
syntaxTree(tr.startState) !== syntaxTree(tr.state);
return rebuild ? build(tr.state) : value;
},
provide: (f) => EditorView.decorations.from(f, (v) => v.decorations),
});
const toggleGutter = gutter({
class: "cm-openidea-docToggles",
// Comments left as source: their first line is a text line, which reads these markers.
// Asked again on every update, so the icon follows the mouse.
markers: (view) => sourceToggle(view.state),
// Rendered comments: a block widget is not a text line, and the gutter never looks at
// `markers` for one, only at this.
widgetMarker: (view, widget, block) =>
widget instanceof DocWidget && view.state.field(hovered) === commentOverlapping(view.state, block.from, block.to)
? toggleMarker(true)
: null,
// The widget markers are asked for only when the gutter redraws, which it must when the
// mouse moves on, or the language changes.
lineMarkerChange: (update) =>
update.startState.field(hovered) !== update.state.field(hovered) ||
update.transactions.some((tr) => tr.effects.some((e) => e.is(relabel))),
// A file without doc comments gets no column at all; one with them keeps its column
// while the icon comes and goes, so the text does not shift.
initialSpacer: (view) => (placedComments(view.state).length ? toggleSpacer : emptyGutterSpacer),
updateSpacer: (_spacer, update) => (placedComments(update.state).length ? toggleSpacer : emptyGutterSpacer),
domEventHandlers: {
mousedown(view, block, event) {
const target = event.target instanceof Element ? event.target.closest(".cm-openidea-docToggle") : null;
if ((event as MouseEvent).button !== 0 || !target) return false;
// A file that renders none may not have been scanned since its last edit: the flip
// must land on where its comment starts now.
const comment = active(view.state)
? commentOverlapping(view.state, block.from, block.to)
: (placeComments(view.state).find((c) => block.from <= c.to && block.to >= c.line)?.from ?? null);
if (comment === null) return false;
event.preventDefault();
view.dispatch({ effects: toggleRenderedView.of(comment) });
return true;
},
},
});
/**
* Until the Markdown parser has loaded, a comment renders as plain text. It loads the
* first time the editor renders anything, so an editor that never does never loads it,
* and each that does waits for it once and then rebuilds.
*/
const markdownReady = ViewPlugin.fromClass(
class {
alive = true;
started = false;
constructor(readonly view: EditorView) {
this.start();
}
update(update: ViewUpdate) {
if (!this.started && (update.docChanged || update.transactions.length)) this.start();
}
start() {
if (!active(this.view.state)) return;
this.started = true;
void loadMarkdown()
.then(() => {
markdownIsLoaded = true;
if (this.alive) this.view.dispatch({ effects: markdownLoaded.of(null) });
})
.catch(() => {});
}
destroy() {
this.alive = false;
}
},
);
/**
* A selection that ends inside a rendered comment shows that comment's source. A click
* cannot put it there (the widget keeps its events); Find, Go to Line or undo can, and
* the text they land on must be visible to be edited. The edges are outside: arrow keys
* stop there on their way past.
*/
const showSourceAtCaret = EditorState.transactionExtender.of((tr: Transaction) => {
if (!tr.selection) return null;
const docs = tr.startState.field(docsField, false);
if (!docs?.comments.length) return null;
const map = (pos: number, assoc: number) => (tr.docChanged ? tr.changes.mapPos(pos, assoc) : pos);
const effects = [];
for (const c of docs.comments) {
if (!c.rendered) continue;
const from = map(c.line, 1);
const to = map(c.end, -1);
if (tr.selection.ranges.some((r) => r.head > from && r.head < to))
effects.push(toggleRenderedView.of(map(c.from, 1)));
}
return effects.length ? { effects } : null;
});
/** Draws titles and tooltips again when the UI language changes. */
const languageWatcher = ViewPlugin.fromClass(
class {
readonly unsubscribe: () => void;
constructor(view: EditorView) {
this.unsubscribe = useI18n.subscribe((next, previous) => {
if (next.locale !== previous.locale || next.texts !== previous.texts) {
labelEpoch++;
view.dispatch({ effects: relabel.of(null) });
}
});
}
destroy() {
this.unsubscribe();
}
},
);
/** Toggle Rendered View at the caret: the comment around it or right above its line. */
export function toggleRenderedViewAtCaret(view: EditorView): boolean {
const comment = commentAt(view.state, view.state.selection.main.head);
if (!comment) return false;
// One with nothing to render would stay source, and its flip would linger to no end.
if (!isRendered(view.state, comment.from) && !hasContentCached(comment.style, comment.text)) return false;
view.dispatch({ effects: toggleRenderedView.of(comment.from) });
return true;
}
/**
* Colours come from the colour scheme's doc comment attributes, as IDEA's rendered view
* takes them: the text is the comment's colour (`--syn-doc-comment`) and code and names
* are a tag value's (`--syn-doc-comment-tag-value`). So the rendered comment reads like
* the source one, and follows the theme and any Color Scheme edit.
*/
/** Space kept right of a rendered comment, before the visible edge. */
const DOC_RIGHT_GAP = 24;
/**
* Publishes on the content the visible width of the text area, `--openidea-doc-width`,
* and the code's character width, `--openidea-doc-char`. A block widget is as wide as
* the content, which without soft wraps is as wide as the longest line, so a rendered
* comment needs the first to wrap where the reader can see, as IDEA's does; it indents
* by the second, which a font size change or zoom changes. Horizontal scrolling leaves
* both unchanged.
*/
const docMetrics = ViewPlugin.fromClass(
class {
constructor(readonly view: EditorView) {
this.measure();
}
update(update: ViewUpdate) {
if (update.geometryChanged) this.measure();
}
measure() {
this.view.requestMeasure({
key: this,
read: (view) => ({
width: view.scrollDOM.clientWidth - view.contentDOM.offsetLeft,
char: view.defaultCharacterWidth,
}),
write: ({ width, char }, view) => {
const style = view.contentDOM.style;
const widthValue = `${Math.max(0, Math.floor(width))}px`;
if (style.getPropertyValue("--openidea-doc-width") !== widthValue)
style.setProperty("--openidea-doc-width", widthValue);
const charValue = `${char}px`;
if (style.getPropertyValue("--openidea-doc-char") !== charValue)
style.setProperty("--openidea-doc-char", charValue);
},
});
}
},
);
const docsTheme = EditorView.theme({
".cm-openidea-renderedDoc": {
// No vertical margin: the editor measures a block widget by its border box, so a
// margin would push every line below it away from its line number.
margin: "0",
// The content keeps its whitespace (`pre`); prose reflows instead, so the comment's
// own line breaks do not carry over and its paragraphs wrap at the visible width.
whiteSpace: "normal",
overflowWrap: "break-word",
padding: "4px 0 6px 12px",
borderLeft: "2px solid var(--border)",
color: "var(--syn-doc-comment)",
fontFamily: "var(--font-ui)",
fontSize: "var(--fs-ui)",
lineHeight: "1.55",
cursor: "auto",
WebkitUserSelect: "text",
userSelect: "text",
},
".cm-openidea-renderedDoc p": { margin: "0 0 6px" },
".cm-openidea-renderedDoc p:last-child": { marginBottom: "0" },
".cm-openidea-renderedDoc code": {
fontFamily: "var(--font-mono)",
fontSize: "0.92em",
color: "var(--syn-doc-comment-tag-value)",
padding: "0 4px",
borderRadius: "var(--radius-small)",
background: "var(--bg-elev)",
border: "1px solid var(--border)",
},
".cm-openidea-renderedDoc pre": {
margin: "0 0 6px",
whiteSpace: "pre",
padding: "6px 8px",
overflowX: "auto",
borderRadius: "var(--radius-small)",
background: "var(--bg-elev)",
},
".cm-openidea-renderedDoc pre code": { padding: "0", background: "none", border: "none" },
".cm-openidea-renderedDoc ul, .cm-openidea-renderedDoc ol": { margin: "0 0 6px", paddingLeft: "20px" },
// As specific as the code rule it must beat, or a link takes the plain code colour.
".cm-openidea-renderedDoc code.cm-openidea-docLink": { color: "var(--accent)" },
".cm-openidea-renderedDocSections": {
display: "grid",
gridTemplateColumns: "max-content 1fr",
gap: "2px 8px",
margin: "8px 0 0",
},
".cm-openidea-renderedDoc code.cm-openidea-docType": { color: "var(--syn-doc-comment)" },
".cm-openidea-renderedDocSections dt": { color: "var(--syn-doc-comment)" },
".cm-openidea-renderedDocSections dd": { margin: "0" },
".cm-openidea-renderedDocText p": { display: "inline", margin: "0" },
".cm-openidea-docToggles .cm-gutterElement": {
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
padding: "0 2px",
},
".cm-openidea-docToggle": {
display: "inline-flex",
alignItems: "center",
height: "var(--openidea-editor-line-height, var(--lh-code))",
padding: "0",
border: "none",
background: "none",
color: "var(--fg-muted)",
cursor: "pointer",
},
".cm-openidea-docToggle:hover": { color: "var(--fg-strong)" },
});
/** Installed always; a file without doc comments gets no column and no decorations. */
export const renderedDocsExtension: Extension = [
flipped,
docsField,
passiveDocs,
passiveScanner,
hovered,
hoverTracker,
showSourceAtCaret,
toggleGutter,
markdownReady,
languageWatcher,
docMetrics,
docsTheme,
];
+7
Переглянути файл
@@ -42,6 +42,7 @@ import { rainbowBrackets } from "./rainbowBrackets";
import { stickyMaxLines, stickyScroll } from "./stickyScroll";
import { findExtension } from "./find";
import { codeLensExtension } from "./codeLens";
import { renderedDocsExtension } from "./renderedDocs";
import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapActive } from "./prefs";
import { commentTokens } from "./languages";
import { multiCaretMouse } from "./multiCaret";
@@ -275,12 +276,14 @@ export function prefsFor(o: {
info: LanguageInfo | undefined;
readOnly: boolean;
wrapOverride?: boolean | null;
renderedDocsOverride?: boolean | null;
}): Extension {
return prefsExtension({
prefs: prefsOf(o.settings),
path: o.path,
softWrap: o.settings?.softWrap,
wrapOverride: o.wrapOverride,
renderedDocsOverride: o.renderedDocsOverride,
showWhitespace: o.settings?.showWhitespace,
hardWrapAt: hardWrapAt(o.settings, o.info),
readOnly: o.readOnly,
@@ -314,6 +317,8 @@ export interface SetupOptions {
formatIndent?: EffectiveIndent | null;
/** Soft wraps toggled for this file (View › Active Editor › Soft-Wrap). */
wrapOverride?: boolean | null;
/** Rendered doc comments toggled for this file (View › Active Editor › Render Documentation Comments). */
renderedDocsOverride?: boolean | null;
/** Extra extensions owned by the slot (update listeners, focus handlers). */
extra?: Extension;
/** Standalone value editors supply their own extensions without file-backed contributions. */
@@ -384,6 +389,7 @@ export function baseExtensions(o: SetupOptions): Extension {
overviewLane,
cornerInset,
codeLensExtension,
renderedDocsExtension,
EditorView.contentAttributes.of({
spellcheck: "false",
autocorrect: "off",
@@ -406,6 +412,7 @@ export function baseExtensions(o: SetupOptions): Extension {
info: o.info,
readOnly: o.ctx.readOnly,
wrapOverride: o.wrapOverride,
renderedDocsOverride: o.renderedDocsOverride,
}),
),
compartments.contributions.of(o.contributions ?? contributionExtensions(o.ctx)),
+2
Переглянути файл
@@ -332,6 +332,8 @@ export const openideaTheme = EditorView.theme({
// Find in file (cm/find.ts)
...findStyles,
// Find in Files preview matches (search/find/parts.tsx)
".cm-openidea-searchResult": { backgroundColor: "var(--bg-search-result)" },
// Overlays
".cm-tooltip": {
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more