Compare commits

...
Author SHA1 Message Date
l0ng-ai c9362247d0 ci: run the Maven launch classpath smoke for the runner JDK (#3273)
maven_launch_classpath_uses_the_runner_jdk_classifier (#2929) is an ignored project smoke test that CI never ran: add it to the java job's project_smoke step.
2026-11-14 11:33:04 +08:00
Muuandmuutot 8ab9c688ac fix(vcs): make the line-separator check read bytes, and drop the unused check input (#3281)
The CRLF commit check compared the editor's text, which the host LF-normalises (open buffers and `text_of` alike), so it never fired. It now reads the file's bytes through fs_read_range, carrying the last byte across ranges so a CRLF that straddles two is found, and skips files fs_probe classifies as binary. The commit check context drops the `files` list no check read; checks get the paths that still exist.

Co-authored-by: muutot <[email protected]>
2026-11-14 11:23:40 +08:00
Deng Liuanddengliu ba0b3d9c7d fix(completion): clear feature backoff on plugin updates and explicit completion (#3277)
Explicit completion (Ctrl+Space) now bypasses the per-document completion backoff, and a successful completion clears it. Plugin enable/disable or UI changes clear all backoffs, so a newly installed completion provider is asked again. On the host, an empty plugin result only counts as an answer when a completion or injection plugin actually covers the document; otherwise the request stays Unavailable and falls through to the language server.

Co-authored-by: dengliu <[email protected]>
2026-11-14 11:21:43 +08:00
Muuandmuutot 96f8ee27dd fix(vcs): read the scoped status through git again, with the pathspec spelled right (#3280)
The scoped status read after a watcher batch handed gitoxide's in-process pathspec the paths as the platform spells them. On Windows a backslash path matched nothing, silently, so the merged answer dropped the file from the change list until a full refresh; a path that is the repository root became an empty pattern and failed the read. The scoped read is git status with the pathspec again, with /-separated patterns and the root meaning the full status, and Restricted Mode answers with the full gitoxide status since it starts no program.

Co-authored-by: muutot <[email protected]>
2026-11-14 11:19:10 +08:00
l0ng-ai 5529db667c feat(database): test a data source's SSH tunnel on its own (#3279)
The SSH/SSL tab gets Test SSH Tunnel: it logs in to the SSH host (shared SSH configuration or the settings typed in the dialog) and checks that the SSH host reaches the database host and port, without the database password or login. Success shows user@host:port and the database host:port reached; failures name the step (connecting, authenticating, reaching the database). An unknown host key offers Trust Host and Retry. The in-process SSH test server now forwards direct-tcpip channels.
2026-11-14 11:17:18 +08:00
l0ng-ai 9c55c28d5b feat(run): run, test and debug Zig projects from build.zig (#3278)
A build.zig project gets a configuration per build step (zig build, zig build run, zig build test and the project's own steps) and one per executable it declares, which runs zig build first and then the installed binary, debuggable with CodeLLDB. The build script is parsed, never run, to find them.

Zig files get gutter markers: pub fn main runs the executable built from the file (or zig run for a file on its own), a test block runs zig test <file> --test-filter <name>, a file with tests only runs them all. Debugging a single file or test builds it into a binary first (build-exe, test --test-no-exec) and launches that.

Adds fixtures/zig-app (Zig 0.17). Part of #3260.
2026-11-14 11:04:44 +08:00
l0ng-ai 009773c0d4 fix(debugger): debug Kotlin with kotlin-lsp without asking for kotlin-debug-adapter (#3274)
Since #3172, debugging a Maven / Gradle run of a module with Kotlin sources stopped before the
build and asked to install kotlin-debug-adapter whenever it was missing, although the backend
debugs Kotlin with kotlin-lsp's own debugger when kotlin-lsp is installed and only falls back
to kotlin-debug-adapter when it can't. The check now offers kotlin-debug-adapter only when
neither is installed.
2026-11-14 11:03:32 +08:00
l0ng-ai ba0f4329ea fix(vcs): refresh the diff navigator's handlers before paint (#3275)
DiffEditor's registered navigator refreshed its handlers in a passive effect, so a shortcut pressed right after the toolbar changed (Jump to Source once the working copy is found) ran the previous handler and did nothing. The handlers are refreshed in a layout effect now. This was also why the diffToolbar F4 / Cmd+Down test failed about one run in eight.
2026-11-14 10:50:31 +08:00
Deng Liuanddengliu 4d88d44616 feat(language): add a Helm Template language for chart templates (#3261)
Files in a Helm chart's templates folder (yaml, yml, tpl, txt under a Chart.yaml / Chart.yml root) and *.gotmpl files are detected as Helm Template instead of YAML or plain text. They contain Go text/template + Sprig actions that are not valid YAML until rendered, so the YAML language server, formatter and tree-sitter syntax check no longer attach to them and flood the editor with errors.

A CodeMirror stream mode highlights YAML outside {{ … }} and keywords, variables, dot paths, functions and comments (including {{- /* … */ -}}) inside. .tpl outside a chart keeps its current type, since Smarty, Terraform and other engines use it too.

Co-authored-by: dengliu <[email protected]>
2026-11-14 10:46:56 +08:00
Muuandmuutot 5e5d804b89 feat(vcs): refresh the working tree status incrementally (#3267)
A watcher batch used to re-read the whole working tree status, so a save in a large repository walked every file. The batch's paths now go to a scoped read (vcs_status_paths, gitoxide's status with a pathspec; git status with a pathspec as the fallback) and are merged into the loaded status: a path that is no longer changed drops out, a removed directory drops what is below it, new entries land in path order.

The full status still answers when the scoped one could be wrong: batches over 200 paths, a status that is not loaded yet, ignore-rule changes, a failed read, and batches that can split a rename. Git pairs a deletion with an addition across the whole index, so a pathspec read of a staged rename's target reports it as added and drops the source; batches that touch a rename, read one, or read an addition or deletion while the other side exists elsewhere take the full status. Restricted Mode keeps starting no program: the scoped read is in-process.

Co-authored-by: muutot <[email protected]>
2026-11-14 10:44:11 +08:00
Muuandmuutot 83b9744a36 feat(vcs): annotate the file as of an earlier revision (#3265)
The annotation gutter's menu gains IDEA's annotate family:

- Annotate Previous Revision opens the file at the first parent of the annotated line's commit next to the commit's version, with the previous side annotated; on a file's first commit it says there is no previous revision.
- Annotate at Revision… opens the file's revision chooser and shows the picked revision annotated next to the working copy, following renames through historyPath.

One diff viewer provider annotates the left pane once per diff, so annotations the user closes stay closed when the panes rebuild. The diff opens at its first change, since the annotated line's number belongs to the working copy.

Co-authored-by: muutot <[email protected]>
2026-11-14 10:38:39 +08:00
l0ng-ai 4ab8959bac ci: run the Kotlin smoke against kotlin-lsp 271.2085.0 (#3270)
263.7084.0 is a JetBrains pre-release build that has expired: it now exits at startup ("This build of kotlin-server has expired"), so Language smoke (kotlin) failed on every branch.
2026-11-14 10:35:44 +08:00
GuoYuandguoyucode d715643ff2 feat(mcp): a listen address for the IDE MCP server, 127.0.0.1 by default (#3146)
Agents › IDE MCP Server gets a Listen address setting (mcpServer.address): 127.0.0.1 by default, or another address of this computer (LAN / VPN) or 0.0.0.0 / :: so agents on other computers can connect. Requests must still carry the bearer token, and the Host check still refuses host names other than localhost (DNS rebinding). An address that isn't an IP address, or isn't on this computer, stops the server with an error naming it. Clients on this computer keep connecting through loopback; the discovery file records the address. The settings hint and docs warn that anyone who can reach the address and has the token can use the tools.

Co-authored-by: guoyucode <[email protected]>
2026-11-14 10:32:24 +08:00
Muuandmuutot a5214cae38 feat(vcs): make before-commit checks a registry (#3268)
Before-commit checks register through one extension point (CommitCheck, registerCommitCheck, getCommitChecks), ordered by order then id. Each check reports a severity: an error holds the commit (the next click is Commit Anyway), a warning is shown without stopping it. Every built-in check reports error, so the Commit view, messages and the Commit Anyway flow are unchanged. The before-commit modifications (reformat, optimize imports, cleanup) run as one step ahead of the checks, so the analysis, TODO and CRLF checks read the rewritten files. A check that throws is reported instead of breaking the commit.

Co-authored-by: muutot <[email protected]>
2026-11-14 10:31:25 +08:00
Vadim LazukandInf1mum 54dd3a167f fix(database,http-client,graphql): keep exact numeric text in aggregates, charts and JSON paths (#3175)
Large or very precise numbers were rounded through JavaScript doubles in database result aggregates (sum / average / min / max) and charts, in HTTP response formatting and in GraphQL variables and responses. Aggregates and charts now compute on exact decimal text, and a shared lossless JSON parser/serialiser (src/platform/losslessJson.ts) keeps number literals as written for HTTP and GraphQL.

Refs #3105

Co-authored-by: Inf1mum <[email protected]>
2026-11-14 10:28:39 +08:00
l0ng-ai 0938a507da fix(macos): require macOS 13, the first release whose system engine runs the app (#3271)
On macOS 11 and 12 other apps keep using the system's built-in WebKit (Safari 14.1 / 15.x) even after Safari is updated, so the Safari 16 interface could not start there. The bundle now declares macOS 13 as its minimum, the install docs say macOS 13, and the startup failure page no longer suggests updating Safari.

Fixes #2008
2026-11-14 10:23:17 +08:00
Daniel AdámekandDanielADK a05a40e0a3 fix(editor): end a <<- heredoc at its tab-indented delimiter in shell highlighting (#3259)
The stock CodeMirror shell mode ends a heredoc body only on a line equal to the delimiter and drops the `-` of `<<-`, so a tab-indented closing delimiter highlighted the rest of the file as heredoc text. shellInterpolation now tracks heredocs itself: it reads the delimiter as bash does (quotes removed, ends at `|`, `;`, `>`…), strips leading tabs for `<<-`, queues several heredocs on one line, starts bodies on the next line, and leaves `<<` in `$(( ))` and `<<<` here-strings as operators.

Co-authored-by: DanielADK <[email protected]>
2026-11-14 09:08:41 +08:00
acaoandamphetamlne 3b978721e3 test(plugins): exercise remote diagnostics through the host snapshot (#3254)
The remote plugin API test still filled the UI Problems reports after #3103 moved plugin diagnostics to the host-owned snapshot, so it failed on main with an empty result. It now publishes through the project's recording event sink and checks the snapshot's per-file / per-producer semantics, project close and RPC disconnect.

Co-authored-by: amphetamlne <[email protected]>
2026-11-14 09:07:46 +08:00
l0ng-ai 9793857024 fix(plugins): rebuild the diagnostics test fixture after formatting its source (#3264)
#3263 ran cargo fmt on the fixture's src/lib.rs, which changes its source fingerprint, so plugins/build.sh --check reported dist/plugin.wasm as stale.
2026-11-14 09:05:35 +08:00
l0ng-ai 8fb3b7b084 fix(vcs): keep F7 working in the diff it opens for the next file (#3263)
F7 / Shift+F7 past the last difference opened the neighbouring file with focus on the editor tab's host, so the diff's keymap no longer applied; it had only worked through a stale navigator registration that #3137 removed. The diff now forwards the host's focus to its focused pane, as the image and hex viewers do. Also fixes the htmlFrame test's race with its listener effect and formats the diagnostics plugin fixture, so main's Frontend and Plugins jobs pass.
2026-11-14 09:01:07 +08:00
Deng Liuanddengliu 22fe1702be feat(plugins): dynamic file and folder icons by path (API 1.8) (#3257)
Static [[contributes.fileIcons]] entries match file names only, so a plugin could not tell a Helm chart folder, its templates or a Kustomize overlay from any other YAML file or folder. API 1.8 adds decorations.set-file-icon and the batched set-file-icons (up to 10,000 paths per set, requires project.read): a plugin assigns one of its declared file icons to an exact file or plain-folder path, shown in the project tree and editor tabs after the user's own file type icons. Under engines.openidea = "^1.8" a fileIcons entry may omit patterns, for icons used only this way. Both WIT copies, the SDK (host functions and mock), the plugins README and the bundled components are updated.

Co-authored-by: dengliu <[email protected]>
2026-11-14 07:20:33 +08:00
János Kranczlerandjanoskranczler ad434af78c feat(agents): import sessions from other apps from the Agents tool window (#3255)
Importing Claude Code / Codex sessions started outside OpenIDEA was only reachable from the history popup of a docked session and imported one row at a time. A new Import Sessions from Other Apps… action is listed in the Agents tool window's options menu and in the empty task list. It opens a chooser that rescans on open, filters by title or folder, selects several sessions (modifier-click, Shift-click, Cmd/Ctrl-A, Select All) and imports them in one go: a single session opens as before, several are added to the list without opening tabs, failures are reported together, and Cancel stops the batch after the import in flight.

The set of sessions being imported now lives in the store, so neither the chooser nor the docked popup can import a session twice, and late scan results after a project switch are dropped.

Co-authored-by: janoskranczler <[email protected]>
2026-11-14 05:56:28 +08:00
l0ng-ai 6d103df5cf feat(run): show what each Java test printed, for Maven and Gradle (#3256)
Selecting a test in the Run tool window's test tree shows only that test's
output, and a class shows its own output followed by its tests'.

- Maven: Surefire's console names failures but not what passed tests printed.
  The JUnit XML reports carry each test case's <system-out>/<system-err>;
  they are now read into a separate `stdout` field and shown for every test,
  before the failure details. Failures already reported on the console get
  the printed lines in front of their stack trace.
- Gradle: output of a class's own setup and teardown (@BeforeAll, @AfterAll)
  arrived under the class descriptor and showed up as an extra child named
  after the class. The init script now sends it without a test name and it
  belongs to the class node.
2026-11-14 05:51:58 +08:00
LunarFellerandLunarFeller e2d089a0c4 fix(agents): preserve prompt text during native image paste (#3250)
When the clipboard event carried no data (WebKitGTK pasting a screenshot), the composer started reading the image from the native clipboard but returned false, so CodeMirror still handled the paste and replaced the selected prompt text with an empty string. The handler now prevents the default paste and reports it as handled; the image is still attached when the native read returns one, and the prompt and selection stay as they were in every other outcome. The clipboard fixtures in the tests now implement getData so they go through CodeMirror's own paste path. This is the only native-clipboard paste fallback in the frontend.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 05:41:38 +08:00
l0ng-ai 47fa0e6f79 feat(typescript): run TypeScript 7's own language server when it is the selected TypeScript (#3252)
TypeScript 7 has no tsserver or JavaScript API; its platform package's native executable serves LSP itself. When the TypeScript setting selects one (the project's or a chosen directory), vtsls / typescript-language-server launch that executable with --lsp --stdio instead. The project's TypeScript 7 is listed as "TypeScript 7.x (native)".
2026-11-14 04:39:59 +08:00
233 changed files with 5341 additions and 566 deletions

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=263.7084.0
KOTLIN_LSP=271.2085.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
+1 -1
View File
@@ -119,7 +119,7 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
+7 -1
View File
@@ -291,7 +291,7 @@ pub enum NewTaskApprovals {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs.
/// Serve the tools on [`Self::address`] while the IDE runs.
pub enabled: bool,
/// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
@@ -300,6 +300,9 @@ pub struct McpServerSettings {
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
/// The IP address to listen on: `127.0.0.1` (this computer only, the default), another
/// address of this computer, or `0.0.0.0` / `::` for every address.
pub address: String,
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
/// port forwards keep working after a restart.
pub fixed_port: bool,
@@ -312,6 +315,8 @@ pub struct McpServerSettings {
/// The default of [`McpServerSettings::port`].
pub const DEFAULT_MCP_PORT: u16 = 26371;
/// The default of [`McpServerSettings::address`]: this computer only.
pub const DEFAULT_MCP_ADDRESS: &str = "127.0.0.1";
impl Default for McpServerSettings {
fn default() -> Self {
@@ -320,6 +325,7 @@ impl Default for McpServerSettings {
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
address: DEFAULT_MCP_ADDRESS.to_owned(),
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
+46 -12
View File
@@ -324,6 +324,17 @@ fn group_foreign_keys(rows: Vec<Vec<Option<String>>>, tables: &[CompletionTable]
/// Connection target: a data source id (its configured database), or
/// `<id>:<database>` for another database of the same server. Data source ids
/// never contain `:`.
/// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
fn tunneled(driver: DbDriver) -> bool {
!matches!(driver, DbDriver::Sqlite | DbDriver::Snowflake)
}
/// The database host and port the SSH host forwards to.
fn tunnel_target(source: &DataSource) -> (&str, u16) {
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
(if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }, port)
}
pub fn split_target(target: &str) -> (&str, Option<&str>) {
match target.split_once(':') {
Some((id, db)) if !db.is_empty() => (id, Some(db)),
@@ -655,9 +666,7 @@ impl DbManager {
ssh_secret: Option<String>,
shared: bool,
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
return Ok((source.clone(), None));
};
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
@@ -668,8 +677,25 @@ impl DbManager {
return Ok((local(&t), Some(t)));
}
}
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (target_host, target_port) = tunnel_target(source);
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// The SSH session the tunnel of `source` goes through: its shared SSH configuration's, or one
/// opened with the settings typed in the data source.
async fn ssh_session(
&self,
source: &DataSource,
ssh: &DbSsh,
ssh_secret: Option<String>,
) -> IdeResult<Arc<ide_ssh::Session>> {
let config = ssh.config.trim();
let session = if config.is_empty() {
Ok(if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
@@ -688,14 +714,22 @@ impl DbManager {
self.ssh.set_secret(config, Some(s), save);
}
self.ssh.session(config).await?
};
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
})
}
/// Data Source › SSH/SSL › Test SSH Tunnel: logs in to the SSH host and checks that it reaches
/// the database host and port, without logging in to the database.
pub async fn test_tunnel(&self, source: &DataSource, ssh_secret: Option<String>) -> IdeResult<String> {
let source = &source.resolved()?;
let ssh = source
.ssh
.as_ref()
.filter(|s| s.enabled && tunneled(source.driver))
.ok_or_else(|| IdeError::invalid("This data source does not use an SSH tunnel"))?;
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (host, port) = tunnel_target(source);
drop(Tunnel::open(session.clone(), host, port).await?);
Ok(format!("{} reaches {host}:{port}", session.label()))
}
/// Connects with `source` as given (not necessarily saved): Test Connection.
+96
View File
@@ -0,0 +1,96 @@
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
//! the SSH host reaching the database host and port, with no database login.
use ide_core::error::ErrorKind;
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
use ide_ssh::{SshFiles, SshManager};
use std::sync::Arc;
use tokio::net::TcpListener;
struct Env {
_tmp: tempfile::TempDir,
db: DbManager,
ssh_port: u16,
}
async fn env() -> Env {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
root.join("ssh-secrets.json"),
SshFiles { known_hosts: root.join("known_hosts"), config: None },
));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
Env { _tmp: tmp, db, ssh_port }
}
impl Env {
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
fn source(&self, db_port: u16) -> DataSource {
DataSource {
id: "pg".into(),
name: "pg".into(),
driver: DbDriver::Postgres,
host: "127.0.0.1".into(),
port: Some(db_port),
user: "app".into(),
ssh: Some(DbSsh {
enabled: true,
host: "127.0.0.1".into(),
port: Some(self.ssh_port),
user: "dev".into(),
auth: DbSshAuth::Password,
..Default::default()
}),
..Default::default()
}
}
}
/// A port something listens on (and never speaks a database protocol).
async fn listening() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_tunnel_is_tested_without_a_database_login() {
let env = env().await;
let (_db, db_port) = listening().await;
let source = env.source(db_port);
// First contact: the host key has to be trusted, as with Test Connection.
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
env.db.trust_ssh_host("pg").await.unwrap();
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
let env = env().await;
let (listener, closed) = listening().await;
drop(listener);
let source = env.source(closed);
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
env.db.trust_ssh_host("pg").await.unwrap();
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
}
#[tokio::test]
async fn a_source_without_a_tunnel_has_nothing_to_test() {
let env = env().await;
let mut source = env.source(5432);
source.ssh.as_mut().unwrap().enabled = false;
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
}
+15
View File
@@ -42,6 +42,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
test(&c.host, &c.window, source, password, ssh_secret).await
});
d.add("db_test_tunnel", |c| async move {
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
test_tunnel(&c.host, &c.window, source, ssh_secret).await
});
d.add("db_set_ssh_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
@@ -212,6 +216,17 @@ pub async fn test(
db.test(&source, password, ssh_secret).await
}
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
pub async fn test_tunnel(
host: &Host,
window: &str,
source: DataSource,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.test_tunnel(&source, ssh_secret).await
}
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
+8 -2
View File
@@ -74,9 +74,15 @@ pub async fn lsp_completion(
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
.await
.unwrap_or_default();
if items.is_empty() {
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
return if covered {
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
} else {
result
};
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
+21
View File
@@ -22,6 +22,27 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
.await
}
/// Changes of `paths` only: the dirty-scope refresh after a watcher batch. `paths` that belong
/// to no repository are ignored, and an empty list is the full status.
pub async fn status_paths(host: &Host, window: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<Vec<FileStatus>> {
let project = host.project(window)?;
host.blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
}
for r in repos.all() {
r.set_ignored_names(settings.ignored_names());
}
repos.status_paths(&paths)
})
.await
}
mod commands;
pub use commands::*;
+77 -13
View File
@@ -1642,6 +1642,9 @@ impl HostApi for PluginApi {
) -> Result<(), HostError> {
self.set_decorations(path, set, Vec::new(), item, true)
}
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
self.set_file_icon_decorations(set, items)
}
fn vcs_log(
&self,
root: &Path,
@@ -4124,6 +4127,9 @@ impl PluginApi {
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
if let Some(icon) = file.as_ref().and_then(|f| f.icon.as_ref()) {
ide_extension::validate_item_id(icon, "file icon id").map_err(HostError::Invalid)?;
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4132,24 +4138,14 @@ impl PluginApi {
// The decoration follows the file, not the window's project: editors render
// whatever they have open, including files outside every project root. The
// set's root stays the path's own (deepest open) project for tree filtering.
let root = self
.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()));
let root = self.decoration_root(path);
Some(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path.to_path_buf()),
set_id: set.into(),
tree,
items,
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4177,6 +4173,60 @@ impl PluginApi {
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
fn decoration_root(&self, path: &Path) -> PathBuf {
self.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()))
}
fn set_file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
if set.is_empty() || set.len() > 128 {
return Err(HostError::Invalid("set id must be 1-128 bytes".into()));
}
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10000 file icons per set".into()));
}
let mut by_path = BTreeMap::new();
for (path, icon) in items {
ide_extension::validate_item_id(&icon, "file icon id").map_err(HostError::Invalid)?;
by_path.insert(path, icon);
}
let mut all = self.shared.decorations.lock();
let mut sets = all.get(self.id()).cloned().unwrap_or_default();
sets.retain(|s| !(s.tree && s.set_id == set));
for (path, icon) in by_path {
let root = self.decoration_root(&path);
sets.push(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path),
set_id: set.into(),
tree: true,
items: Vec::new(),
file: Some(ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: None,
note: None,
icon: Some(icon),
}),
});
}
let names: BTreeSet<_> = sets.iter().filter(|s| s.tree).map(|s| &s.set_id).collect();
if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 {
return Err(HostError::Invalid("decoration sets exceed plugin limits".into()));
}
all.insert(self.id().into(), sets.clone());
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
}
fn decoration_style(style: &str) -> bool {
matches!(
@@ -4330,7 +4380,8 @@ mod workbench_tests {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
note: None,
icon: None,
})
)
.is_err()
@@ -4340,6 +4391,7 @@ mod workbench_tests {
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
icon: None,
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
@@ -4353,6 +4405,18 @@ mod workbench_tests {
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.file_icon_decorations(
"icons",
vec![(path.clone(), "helm-chart".into()), (root.join("chart"), "helm-dir".into())],
)
.unwrap();
let icons = api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "icons")
.filter_map(|s| s.file.as_ref().and_then(|f| f.icon.clone()))
.collect::<Vec<_>>();
assert_eq!(icons.len(), 2);
assert!(api.file_icon_decorations("icons", vec![(path.clone(), "BAD ID".into())]).is_err());
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
+25 -2
View File
@@ -396,15 +396,37 @@ impl PluginHost {
}
}
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
self.provider_plugins(kind, &doc.language)
.into_iter()
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
}
pub fn provide_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
self.feature_documents(doc, Some(at))
self.provide_completion_for_editor(doc, at, trigger).1
}
pub(crate) fn provide_completion_for_editor(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> (bool, Vec<(String, CompletionItem)>) {
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
let items = self
.feature_documents(doc, Some(at))
.into_iter()
.flat_map(|(doc, bounds)| {
if self.has_document_providers(ProviderKind::Completion, &doc) {
covered = true;
}
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
@@ -419,7 +441,8 @@ impl PluginHost {
Some((p, i))
})
})
.collect()
.collect();
(covered, items)
}
pub fn provide_code_actions(
+20 -5
View File
@@ -743,6 +743,7 @@ pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileIconDecl {
id: String,
#[serde(default)]
patterns: Vec<String>,
icon: PathBuf,
#[serde(default)]
@@ -754,13 +755,19 @@ pub fn file_icon_asset(id: &str, dark: bool) -> String {
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
}
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
/// whatever icon theme is in use (the user's own file type icons still win).
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns` (or
/// assigned dynamically through `decorations.set-file-icon` when `patterns` is
/// omitted in API 1.8), whatever icon theme is in use (the user's own file type
/// icons still win).
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
validate_item_id(&d.id, "file icon id")?;
let what = format!("file icon {:?}", d.id);
file_patterns(&d.patterns, &what)?;
if d.patterns.is_empty() {
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
} else {
file_patterns(&d.patterns, &what)?;
}
let mut files = Vec::new();
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
@@ -769,8 +776,16 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
let bytes = serde_json::to_vec(&assets).map_err(|e| e.to_string())?;
let revision = blake3::hash(&bytes).to_hex().to_string();
let json = serde_json::json!({
"id": d.id,
"patterns": d.patterns,
"dark": d.icon_dark.is_some(),
"revision": revision,
});
let label = if d.patterns.is_empty() { d.id.clone() } else { d.patterns.join(", ") };
let mut out = UiDecl::new(d.id.clone(), label, json);
out.files = files;
out.assets = assets;
Ok(out)
+2 -1
View File
@@ -270,7 +270,8 @@ impl Dispatcher {
.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")?) })
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await })
.add("vcs_status_paths", |c| async move { ops::vcs::status_paths(&c.host, &c.window, c.arg("paths")?).await });
ops::register_all(&mut d);
d
}
+1
View File
@@ -475,6 +475,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_clone"
| "vcs_init"
| "vcs_status"
| "vcs_status_paths"
| "vcs_roots"
| "vcs_branch_info"
| "vcs_line_hunks"
@@ -99,6 +99,21 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
// `a.toml` has neither a language server, a completion plugin, nor an injection plugin, so `Unavailable` is preserved.
let toml_path = root.join("a.toml");
std::fs::write(&toml_path, "k = 1\n").unwrap();
let toml_doc = ops::doc::open(&host, "main", toml_path).await.unwrap();
assert_eq!(
ops::lsp::lsp_completion(&host, "main", RequestId(89), toml_doc.id, Pos::default(), None)
.await
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Unavailable
);
let sql_path = root.join("q.sql");
std::fs::write(&sql_path, "SELECT 1;").unwrap();
let sql_doc = ops::doc::open(&host, "main", sql_path).await.unwrap();
assert!(ops::lsp::lsp_completion(&host, "main", RequestId(90), sql_doc.id, Pos::default(), None).await.is_ok());
assert_eq!(
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
.parameters,
+37
View File
@@ -3760,6 +3760,43 @@ path = "templates/new.draw"
assert!(f.host.web_page("web-demo", "draw").is_err());
}
#[test]
fn file_icons_without_patterns_require_api_1_8_and_project_assets() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("dynamic-icons");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
std::fs::write(pkg.join("icons/helm-dir.svg"), r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>"#)
.unwrap();
let write_manifest = |api: &str| {
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "dynamic-icons"
name = "Dynamic Icons"
version = "0.1.0"
engines.openidea = "{api}"
[[contributes.fileIcons]]
id = "helm-dir"
icon = "icons/helm-dir.svg"
"#
),
)
.unwrap();
};
write_manifest("^1.7");
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("1.8"), "{err:?}");
write_manifest("^1.8");
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let assets = f.host.assets("dynamic-icons", "fileIcon/helm-dir/").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<circle"));
}
#[test]
fn file_templates_fixture_installs_without_write_capabilities() {
let f = fixture(Limits::default());
+49 -16
View File
@@ -367,6 +367,7 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
use ide_core::{Diagnostic, Pos, Range, Severity};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let tmp = tempfile::tempdir().unwrap();
let desktop = Arc::new(
@@ -426,42 +427,70 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
"severity": "warning", "message": message, "source": "remote-check", "code": null
}]})
};
let publish = |path: &Path, source: &str, message: Option<&str>| {
let items = message
.map(|message| Diagnostic {
range: Range::new(Pos::new(0, 1), Pos::new(0, 3)),
severity: Severity::Warning,
message: message.into(),
source: Some(source.into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
})
.into_iter()
.collect();
host.project("main").unwrap().sink.emit(IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: source.into(),
items,
});
};
let files = json!([
problem(&remote_file, "remote warning"),
problem(&other_file, "other warning"),
problem(&outside_file, "outside warning")
]);
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
// UI reports are not the source of the plugin's host-owned snapshot.
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
publish(&remote_file, "remote-check", Some("remote warning"));
publish(&other_file, "remote-check", Some("other warning"));
publish(&outside_file, "remote-check", Some("outside warning"));
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("other warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
assert!(problems.contains("outside warning"), "{problems}");
for path in ["remote.txt", remote_file.to_str().unwrap()] {
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(!problems.contains("other warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
}
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
.await
.unwrap();
publish(&remote_file, "second-check", Some("second warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("second warning"));
publish(&remote_file, "remote-check", None);
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(!problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("second warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
invoke(
&peer,
"mcp_report_problems",
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
)
.await
.unwrap();
publish(&remote_file, "second-check", Some("replacement warning"));
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("replacement warning"));
assert!(!problems.as_str().unwrap().contains("other warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
assert!(!problems.as_str().unwrap().contains("second warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
publish(&remote_file, "second-check", None);
publish(&other_file, "remote-check", None);
publish(&outside_file, "remote-check", None);
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
@@ -513,11 +542,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
stream.read_to_string(&mut response).await.unwrap();
assert!(response.contains("You can close this tab"));
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
publish(&remote_file, "remote-check", Some("remote warning"));
invoke(&peer, "close_project", json!({})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
publish(&remote_file, "remote-check", Some("remote warning"));
peer.kill();
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
let plugin_host = plugins.host().clone();
@@ -537,7 +566,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
})
.await
.unwrap();
assert_eq!(result.as_deref(), Some("[]"));
// Disconnect clears desktop reports, not the still-live host project snapshot.
let problems = result.as_deref().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("remote-check"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
plugins.host().shutdown();
desktop.shutdown();
}
+1
View File
@@ -341,6 +341,7 @@ async fn local_deployment_runs_on_the_project_host() {
"db_save_sources",
"db_set_password",
"db_test",
"db_test_tunnel",
"db_set_ssh_secret",
"db_trust_ssh_host",
"db_connected",
+1
View File
@@ -172,6 +172,7 @@ async fn restricted_vcs_commands_run_no_program_of_the_repository() {
}
let calls: Vec<Call> = vec![
call!("vcs_status", "", ops::vcs::status(host, w)),
call!("vcs_status_paths", "", ops::vcs::status_paths(host, w, vec![file.clone()])),
sync!("vcs_roots", "", vcs::vcs_roots(host, w)),
call!("vcs_branch_info", "", vcs::vcs_branch_info(host, w, root())),
call!("vcs_line_hunks", "", vcs::vcs_line_hunks(host, w, file.clone())),
+26
View File
@@ -40,6 +40,7 @@ const BUILTIN: &[(&str, &str)] = &[
("groovy", include_str!("../../../languages/groovy.toml")),
("haskell", include_str!("../../../languages/haskell.toml")),
("hcl", include_str!("../../../languages/hcl.toml")),
("helm-template", include_str!("../../../languages/helm-template.toml")),
("html", include_str!("../../../languages/html.toml")),
("http", include_str!("../../../languages/http.toml")),
("ignore", include_str!("../../../languages/ignore.toml")),
@@ -548,6 +549,7 @@ mod tests {
"Groovy",
"Haskell",
"Haxe",
"Helm Template",
"HXML",
"HTTP",
"IDL",
@@ -841,6 +843,30 @@ mod tests {
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn helm_charts_make_yaml_and_notes_in_templates_folders_helm_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("charts/podinfo/templates/tests")).unwrap();
std::fs::create_dir_all(root.join("k8s/templates")).unwrap();
std::fs::write(root.join("charts/podinfo/Chart.yaml"), "apiVersion: v2\nname: podinfo\n").unwrap();
assert_eq!(detect("charts/podinfo/Chart.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/values.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/templates/deployment.yaml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/tests/connection.yml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/_helpers.tpl"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/NOTES.txt"), "helm-template");
// No `Chart.yaml` / `Chart.yml` above this `templates` folder.
assert_eq!(detect("k8s/templates/deployment.yaml"), "yaml");
assert_eq!(detect("common.gotmpl"), "helm-template");
// Outside a chart, `.tpl` belongs to other template engines (Smarty, Terraform).
assert_ne!(detect("site/index.tpl"), "helm-template");
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yaml")));
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yml")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
+19 -1
View File
@@ -5,6 +5,7 @@
use serde::{Deserialize, Serialize};
use std::io;
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};
/// The discovery file, relative to the app data directory.
@@ -39,6 +40,9 @@ pub fn kept_token(path: &Path) -> io::Result<String> {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery {
/// Where a client on this computer connects (the file of an IDE without this field: `127.0.0.1`).
#[serde(default = "loopback")]
pub address: IpAddr,
pub port: u16,
pub token: String,
/// The IDE's process.
@@ -47,6 +51,10 @@ pub struct Discovery {
pub version: String,
}
fn loopback() -> IpAddr {
IpAddr::V4(Ipv4Addr::LOCALHOST)
}
impl Discovery {
pub fn read(path: &Path) -> io::Result<Self> {
let bytes = std::fs::read(path)?;
@@ -103,7 +111,13 @@ mod tests {
fn round_trips_privately_and_only_its_owner_removes_it() {
let tmp = tempfile::tempdir().unwrap();
let path = discovery_path(tmp.path());
let ours = Discovery { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
let ours = Discovery {
address: "100.92.72.95".parse().unwrap(),
port: 1234,
token: "t".into(),
pid: std::process::id(),
version: "0.1.0".into(),
};
ours.write(&path).unwrap();
assert_eq!(Discovery::read(&path).unwrap(), ours);
#[cfg(unix)]
@@ -118,6 +132,10 @@ mod tests {
ours.write(&path).unwrap();
Discovery::remove_if_ours(&path);
assert!(!path.exists());
// The file of an IDE that listened on 127.0.0.1 only has no address.
std::fs::write(&path, r#"{"port":1,"token":"t","pid":1,"version":"0.1.0"}"#).unwrap();
assert_eq!(Discovery::read(&path).unwrap().address, loopback());
}
#[test]
+16 -6
View File
@@ -2,14 +2,15 @@
//! the IDE's built-in tools.
//!
//! - **Transport:** MCP Streamable HTTP (2025-06-18; 2025-03-26 and 2024-11-05
//! clients too) on `127.0.0.1:<random port>`, endpoint `/mcp`. Responses are
//! clients too) on `127.0.0.1:<random port>` (or another address of
//! [`ServerOptions`]), endpoint `/mcp`. Responses are
//! plain JSON (no SSE streams: `GET` is 405). Every request carries
//! `Authorization: Bearer <token>` (random, per run); requests from another
//! `Origin` or `Host` are refused (DNS rebinding).
//! - **Projects:** the `X-OpenIDEA-Project` header or the `?project=` query
//! names a folder; the server picks the open project containing it.
//! - **Discovery:** `<app-data>/mcp/server.json` (`port`, `token`, `pid`,
//! `version`; readable only by the user) while the server runs.
//! - **Discovery:** `<app-data>/mcp/server.json` (`address`, `port`, `token`,
//! `pid`, `version`; readable only by the user) while the server runs.
//! - **Stdio:** `<app exe> --mcp-stdio [--project <path>]` reads the discovery
//! file and relays newline-delimited JSON-RPC to the server ([`stdio`]), so
//! persisted client configurations hold no token.
@@ -23,7 +24,7 @@ mod server;
pub mod stdio;
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
pub use server::{McpServer, ServerOptions};
pub use server::{McpServer, ServerOptions, local_address};
pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf};
@@ -85,9 +86,15 @@ pub trait ToolRegistry: Send + Sync + 'static {
) -> Result<String, ToolCallError>;
}
/// The URL of the server at `port` for `project`.
/// The URL of the server at `127.0.0.1:<port>` for `project`.
pub fn endpoint_url(port: u16, project: &Path) -> String {
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
endpoint_url_at(std::net::Ipv4Addr::LOCALHOST.into(), port, project)
}
/// The URL of the server at `<address>:<port>` for `project`.
pub fn endpoint_url_at(address: std::net::IpAddr, port: u16, project: &Path) -> String {
let at = std::net::SocketAddr::new(address, port);
format!("http://{at}/mcp?project={}", encode_query(&project.to_string_lossy()))
}
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
@@ -133,6 +140,9 @@ mod tests {
fn urls_and_commands_name_the_project() {
let url = endpoint_url(4321, Path::new("/home/me/my app/项目"));
assert_eq!(url, "http://127.0.0.1:4321/mcp?project=/home/me/my%20app/%E9%A1%B9%E7%9B%AE");
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
assert_eq!(endpoint_url_at(ip("100.92.72.95"), 80, Path::new("/p")), "http://100.92.72.95:80/mcp?project=/p");
assert_eq!(endpoint_url_at(ip("fd7a::1"), 80, Path::new("/p")), "http://[fd7a::1]:80/mcp?project=/p");
assert_eq!(
stdio_command(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"), Path::new("/p")),
["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"]
+81 -20
View File
@@ -9,6 +9,7 @@ use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use std::convert::Infallible;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
@@ -40,6 +41,8 @@ type Body = Either<Full<Bytes>, EventStream>;
struct Shared {
registry: Arc<dyn ToolRegistry>,
token: String,
/// The address listened on (`0.0.0.0` / `::`: every address).
address: IpAddr,
port: u16,
requests: protocol::Requests,
cancel: CancellationToken,
@@ -48,6 +51,7 @@ struct Shared {
/// The running server. Stopping it (or dropping it) closes the port and
/// removes its discovery file.
pub struct McpServer {
address: IpAddr,
port: u16,
/// Why the port asked for in [`ServerOptions::port`] was not used.
port_unavailable: Option<String>,
@@ -60,7 +64,10 @@ pub struct McpServer {
/// How [`McpServer::start_with`] listens.
#[derive(Clone, Debug, Default)]
pub struct ServerOptions {
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
/// The address to listen on; `None`: `127.0.0.1`, this computer only. Another address of
/// this computer, or `0.0.0.0` / `::` for every address, lets other computers connect.
pub address: Option<IpAddr>,
/// Listen on `<address>:<port>`. When it can't be bound (in use, or reserved by the
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
/// `None`: a free port.
pub port: Option<u16>,
@@ -86,15 +93,20 @@ impl McpServer {
runtime: &tokio::runtime::Handle,
options: ServerOptions,
) -> std::io::Result<Self> {
let address = options.address.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
let bind = |port: u16| {
std::net::TcpListener::bind(SocketAddr::new(address, port))
.map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", SocketAddr::new(address, port))))
};
let (listener, port_unavailable) = match options.port {
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
Some(port) => match bind(port) {
Ok(listener) => (listener, None),
Err(e) => {
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
(bind(0)?, Some(format!("port {port}: {e}")))
}
},
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
None => (bind(0)?, None),
};
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
@@ -108,6 +120,7 @@ impl McpServer {
};
if let Some(path) = &discovery {
let info = Discovery {
address: local_address(address),
port,
token: token.clone(),
pid: std::process::id(),
@@ -120,13 +133,19 @@ impl McpServer {
let shared = Arc::new(Shared {
registry,
token: token.clone(),
address,
port,
requests: Default::default(),
cancel: cancel.clone(),
});
runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on 127.0.0.1:{port}");
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
tracing::info!("MCP server on {}", SocketAddr::new(address, port));
Ok(Self { address, port, port_unavailable, token, discovery, shutdown, cancel })
}
/// The address listened on (`0.0.0.0` / `::`: every address).
pub fn address(&self) -> IpAddr {
self.address
}
pub fn port(&self) -> u16 {
@@ -143,14 +162,24 @@ impl McpServer {
&self.token
}
/// The endpoint URL for `project`.
/// The endpoint URL for `project`, at the address a client on this computer connects to.
pub fn url(&self, project: &Path) -> String {
crate::endpoint_url(self.port, project)
crate::endpoint_url_at(local_address(self.address), self.port, project)
}
pub fn stop(self) {}
}
/// The address a client on this computer connects to for a server listening on `address`:
/// loopback for every address (`0.0.0.0` / `::`), else `address` itself.
pub fn local_address(address: IpAddr) -> IpAddr {
match address {
IpAddr::V4(a) if a.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V6(a) if a.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
a => a,
}
}
impl Drop for McpServer {
fn drop(&mut self) {
self.cancel.cancel();
@@ -212,7 +241,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
if req.uri().path() != "/mcp" {
return plain(StatusCode::NOT_FOUND, "not found: the endpoint is /mcp");
}
if !local_request(req.headers(), shared.port) {
if !local_request(req.headers(), shared.address, shared.port) {
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
}
if !authorized(req.headers(), &shared.token) {
@@ -343,11 +372,16 @@ impl hyper::body::Body for EventStream {
}
}
/// No `Origin` other than this server's own, and a loopback `Host`: a web
/// page (DNS rebinding) can't reach the tools even before the token check.
fn local_request(headers: &HeaderMap, port: u16) -> bool {
/// No `Origin` other than this server's own, and a `Host` naming this server by IP address
/// (loopback, the address listened on, or any when listening on every address) or as
/// `localhost`: never another host name, so a web page (DNS rebinding) can't reach the tools
/// even before the token check.
fn local_request(headers: &HeaderMap, address: IpAddr, port: u16) -> bool {
let ours = |authority: &str| {
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
authority.eq_ignore_ascii_case(&format!("localhost:{port}"))
|| authority.parse::<SocketAddr>().is_ok_and(|a| {
a.port() == port && (a.ip().is_loopback() || a.ip() == address || address.is_unspecified())
})
};
let host_ok = headers.get(header::HOST).is_none_or(|h| h.to_str().is_ok_and(ours));
let origin_ok = headers.get(header::ORIGIN).is_none_or(|o| {
@@ -534,7 +568,7 @@ mod tests {
}
#[test]
fn only_loopback_hosts_and_origins_of_this_server_pass() {
fn only_hosts_and_origins_of_this_server_pass() {
let headers = |pairs: &[(header::HeaderName, &str)]| {
let mut h = HeaderMap::new();
for (k, v) in pairs {
@@ -542,16 +576,43 @@ mod tests {
}
h
};
assert!(local_request(&headers(&[]), 80));
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
let host = |h: &str, address: IpAddr| local_request(&headers(&[(header::HOST, h)]), address, 80);
assert!(local_request(&headers(&[]), loopback, 80));
assert!(host("127.0.0.1:80", loopback));
assert!(host("[::1]:80", loopback));
assert!(local_request(
&headers(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
loopback,
80
));
assert!(!local_request(&headers(&[(header::HOST, "evil.example:80")]), 80));
assert!(!local_request(&headers(&[(header::HOST, "127.0.0.1:81")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), 80));
assert!(!host("evil.example:80", loopback));
assert!(!host("127.0.0.1:81", loopback));
assert!(!host("100.92.72.95:80", loopback), "another address of a loopback server");
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), loopback, 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), loopback, 80));
// Listening on one address: that address too, still never a host name.
let lan: IpAddr = "100.92.72.95".parse().unwrap();
assert!(host("100.92.72.95:80", lan) && host("127.0.0.1:80", lan));
assert!(!host("100.92.72.96:80", lan) && !host("evil.example:80", lan));
// Listening on every address: any IP address, still never a host name.
let every: IpAddr = "0.0.0.0".parse().unwrap();
assert!(host("192.168.1.7:80", every) && host("[fe80::1]:80", every));
assert!(!host("evil.example:80", every) && !host("192.168.1.7:81", every));
assert!(local_request(
&headers(&[(header::HOST, "192.168.1.7:80"), (header::ORIGIN, "http://192.168.1.7:80")]),
every,
80
));
}
#[test]
fn clients_on_this_computer_connect_to_loopback_for_every_address() {
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
assert_eq!(local_address(ip("0.0.0.0")), ip("127.0.0.1"));
assert_eq!(local_address(ip("::")), ip("::1"));
assert_eq!(local_address(ip("100.92.72.95")), ip("100.92.72.95"));
}
#[test]
+3 -2
View File
@@ -148,13 +148,14 @@ async fn post(
)
};
let info = Discovery::read(discovery).map_err(|_| not_running())?;
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.map_err(|_| not_running())?;
let at = std::net::SocketAddr::new(info.address, info.port);
let stream = tokio::net::TcpStream::connect(at).await.map_err(|_| not_running())?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream))
.await
.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
tokio::spawn(conn);
let request = Request::post(format!("/mcp?project={}", crate::encode_query(&project.to_string_lossy())))
.header(header::HOST, format!("127.0.0.1:{}", info.port))
.header(header::HOST, at.to_string())
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
.header("mcp-session-id", scope)
.header(header::CONTENT_TYPE, "application/json")
+32 -1
View File
@@ -373,7 +373,7 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
s.server.take().unwrap().stop();
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let token = "ab".repeat(32);
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
let options = ServerOptions { port: Some(free), token: Some(token.clone()), ..Default::default() };
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
s.server =
@@ -393,3 +393,34 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
second.port_unavailable()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn another_listen_address_is_used_and_one_not_on_this_computer_fails() {
let mut s = setup();
s.server.take().unwrap().stop();
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
// Every address: clients here use loopback; other computers name the server by an address of it.
let every = ServerOptions { address: Some("0.0.0.0".parse().unwrap()), ..Default::default() };
s.server = Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, every).unwrap());
let port = s.server().port();
assert_eq!(s.server().address().to_string(), "0.0.0.0");
assert!(s.server().url(&s.project).starts_with(&format!("http://127.0.0.1:{port}/mcp?")));
assert_eq!(Discovery::read(&s.discovery).unwrap().address.to_string(), "127.0.0.1");
let ping = json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string();
let request = |host: String| {
format!(
"POST /mcp HTTP/1.1\r\nHost: {host}\r\n{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ping}",
bearer(&s),
ping.len()
)
};
assert_eq!(http(port, &request(format!("192.0.2.7:{port}"))).await.0, 200);
assert_eq!(http(port, &request(format!("evil.example:{port}"))).await.0, 403, "never a host name");
// Not an address of this computer: the server does not start, and says which address.
let elsewhere = ServerOptions { address: Some("192.0.2.1".parse().unwrap()), ..Default::default() };
let error = McpServer::start_with(registry, None, &runtime, elsewhere).err().expect("no such address here");
assert!(error.to_string().contains("192.0.2.1"), "{error}");
}
+5
View File
@@ -234,6 +234,11 @@ pub trait HostApi: Send + Sync {
let _ = (path, set, item);
Err(HostError::Failed("decorations unavailable".into()))
}
/// Replace all `(path, icon)` entries of a tree decoration set at once (API 1.8).
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
let _ = (set, items);
Err(HostError::Failed("decorations unavailable".into()))
}
/// Trusted repository history.
fn vcs_log(
&self,
+3
View File
@@ -932,6 +932,9 @@ pub struct FileDecoration {
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
/// `[[contributes.fileIcons]]` id for this file or folder (API 1.8 `set-file-icon`).
#[serde(default)]
pub icon: Option<String>,
}
/// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+35 -2
View File
@@ -2505,7 +2505,13 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations(
Some(&real),
&set_id,
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
Some(crate::FileDecoration {
badge: item.badge,
style: item.style,
tooltip: item.tooltip,
note: None,
icon: None,
}),
)
.map_err(to_wit))
}
@@ -2523,9 +2529,36 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note), icon: None };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
fn set_file_icon(&mut self, path: String, set_id: String, icon: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: None, icon: Some(icon) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
fn set_file_icons(&mut self, set_id: String, items: Vec<(String, String)>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
if items.len() > 10_000 {
return Ok(Err(wit::Error::Invalid("at most 10000 file icons per set".into())));
}
let mut resolved = Vec::with_capacity(items.len());
for (path, icon) in items {
match self.decoration_path(&path) {
Ok(real) => resolved.push((real, icon)),
Err(e) => return Ok(Err(e)),
}
}
Ok(self.host.file_icon_decorations(&set_id, resolved).map_err(to_wit))
}
}
pub(crate) mod language_features_bindings {
Binary file not shown.
@@ -1 +1 @@
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
Binary file not shown.
@@ -1 +1 @@
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
Binary file not shown.
@@ -1 +1 @@
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
Binary file not shown.
@@ -1 +1 @@
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
Binary file not shown.
@@ -1 +1 @@
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
+2 -6
View File
@@ -39,12 +39,8 @@ impl Plugin for Fixture {
hover: None,
after: Some(after.into()),
};
host::set_decorations(
path,
"fixture",
&[item("typo", "typo message"), item("weak", "weak message")],
)
.map(|()| None)
host::set_decorations(path, "fixture", &[item("typo", "typo message"), item("weak", "weak message")])
.map(|()| None)
}
_ => Err(Error::NotFound(id.into())),
}
Binary file not shown.
@@ -1 +1 @@
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
Binary file not shown.
@@ -1 +1 @@
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
Binary file not shown.
@@ -1 +1 @@
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
Binary file not shown.
@@ -1 +1 @@
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
0f25487b8e488e5ec01fabd2951bdb03d80bc26e4e9aa508394a7f9cd09d1759
Binary file not shown.
@@ -1 +1 @@
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
80f9dc28e4f168dd22ccee1c53e1f6b08c446622eb10f07de29be5fc45e9fb1c
Binary file not shown.
@@ -1 +1 @@
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
5355cb4ef373eba3c54806eb00294fcd5fccb3cf05dae3e1c2a4ad45ab958f03
Binary file not shown.
@@ -1 +1 @@
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
fc75ede42e6db7619fa28fc8999f091d39e3c1ed401356db3a3f07de54287c99
Binary file not shown.
@@ -1 +1 @@
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
8793601496f1727cf912e4cdf86ec48fc340a17585098e98dee52a125cb8a16d
Binary file not shown.
@@ -1 +1 @@
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
fb2db14a645cad42941d20a25a3ff87ca6fdee635fdb40953383ff30ce16af8a
Binary file not shown.
@@ -1 +1 @@
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
fc66b3b56a1ab1c9d547e3efe6bb4693dca908bb18be8b8ebd26f2fb41e4436c
Binary file not shown.
@@ -1 +1 @@
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
c76830df9dee3caba932ffaddaabce88ac7ef23b3957b3e8e570b4939dd58d9d
@@ -1 +1 @@
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
3a41a06d54939a9e85fcf6b0bf15b336f928a3caf66576bd2da62c5b8b177c1d
Binary file not shown.
@@ -1 +1 @@
25eaf7ac2837ade4b3204698d3af41854324ece6ec196daba84bea6bb41f6cde
f30a8c0a7893519872628c001314efd567f87f4dfaf718a737ea3631b55881b0
Binary file not shown.
@@ -1 +1 @@
f7cd477be706ee5da2656ffb5435cb2b52443aec1270b3b1d804b2bdc6ed8bc8
cb65533984ffd8684c9e9f5ddeef92a8f387735c12310b25282ee7bbf2d73cd7
Binary file not shown.
@@ -1 +1 @@
c97989134a8e8a0a12013909f4726288b6d9cdaa44d02a58ff505a6cbb1fb9a1
d11f9cb1130f3f3aaa89514bf47ec8ece9b2ca0edf1d82c11f80b262325f16be
Binary file not shown.
@@ -1 +1 @@
67660f5ebbfdd3967fabcc0b5c41f215d4a38d9092b91eb0166f12bccfdffffa
48ea4414469ebd70a89da8a5df7b7bddc4d32f5c50f1759ee3cea14558073c92
Binary file not shown.
@@ -1 +1 @@
09c810ce7ebd820ea21525dc9faea99b13e7665b308cb131510e02b86e763c9f
0c02303182be04ed3776fb532fd8b72e6057aa0fdb92b7d235000146fef4120e
Binary file not shown.
@@ -1 +1 @@
9b3a210440560e05326947c7d549ce691315839daea2931c6a78720f7b0d9e7c
8162a70e848172663463119e6357984a73e54bc71a99b2845621d0733cf9333e
Binary file not shown.
@@ -1 +1 @@
57d8b00b5853ca789fe704f21e4394d1524dd73dfd27ea4a0b34f4b0e4f1a5f8
4524cab0ceac631153a945f6b76f7d40efd3c854740ae22c64580381e9532397
Binary file not shown.
@@ -1 +1 @@
53e355922819b8034c93070323c7eb2b1505d3bf561dd5de6abb930fe514748c
a92f4def072ca4cc805414516ab4f046fc54bcf11394a5413a27ea151c156174
Binary file not shown.
@@ -1 +1 @@
7af929a1adf5d89929d66e78068253ccde9ad5cedee329c2d4db6b7a5e29790e
994fca61bc5a10f0229301406af76c188a9cb2f593db85a403c1ece746e9f7b5
Binary file not shown.
@@ -1 +1 @@
511fab979382c5d64054ba4f5fdfc98d50e252dfd5896265ee589336bf8e7d70
48eaf97f57cc08c3854a1effa37ebf29defca4782f76c7de1da6ef9a02c23441
Binary file not shown.
@@ -1 +1 @@
444497128ec5e1e01229a93d2340b49ccc5bc27e67defb2c4fe19683042442a3
b896043cf261ff4bb19977f4648a736907fa897b8d4f0804077678a5895d1845
+2
View File
@@ -5,6 +5,7 @@
pub(crate) mod cmake;
mod dotnet;
mod zig;
use crate::RunConfig;
use ide_core::project_roots::expand_members;
@@ -72,6 +73,7 @@ pub(crate) fn detect_with_profiles(
}
}
}
detect_projects(root, &["build.zig"], zig::detect, &mut out);
dotnet::detect(root, &mut out);
detect_projects(root, &["Makefile", "makefile", "GNUmakefile"], detect_make, &mut out);
detect_projects(root, &["justfile", "Justfile", ".justfile"], detect_just, &mut out);
+232
View File
@@ -0,0 +1,232 @@
//! Zig projects (`build.zig`): the build's steps (`zig build <step>`) and, for each executable it
//! declares, the installed binary (`zig-out/bin/<name>`), built first and debuggable with CodeLLDB.
//! Read from the build script's source: detecting configurations never runs the project's build code.
use super::{ConfigExt, config, read};
use crate::{BeforeLaunchTask, RunConfig};
use std::path::Path;
pub(super) const BUILD_ID: &str = "zig:build";
pub(super) fn detect(dir: &Path, out: &mut Vec<RunConfig>) {
let Some(src) = read(&dir.join("build.zig")) else { return };
let src = code_only(&src);
out.push(config(BUILD_ID, "zig build", "zig", "zig", &["build"]));
for step in steps(&src) {
// `install` is what plain `zig build` runs.
if step == "install" || step == "uninstall" {
continue;
}
let mut c = config(format!("zig:{step}"), format!("zig build {step}"), "zig", "zig", &["build", &step]);
if step == "test" || step.starts_with("test-") || step.starts_with("test_") {
c = c.test();
}
out.push(c);
}
for exe in executables(&src) {
let binary = if cfg!(windows) {
format!("zig-out\\bin\\{}.exe", exe.name)
} else {
format!("./zig-out/bin/{}", exe.name)
};
let mut c = config(format!("zig:exe:{}", exe.name), exe.name.clone(), "zig-application", &binary, &[])
.debug("codelldb");
c.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: BUILD_ID.into() });
if let Some(source) = exe.source {
c.extras.params.insert("source".into(), source);
}
out.push(c);
}
}
/// `src` with comments blanked out (Zig has only `//` comments) and string contents kept.
fn code_only(src: &str) -> String {
let mut out = String::with_capacity(src.len());
for line in src.lines() {
let mut in_string = false;
let mut escaped = false;
let mut end = line.len();
let bytes = line.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if in_string {
match b {
_ if escaped => escaped = false,
b'\\' => escaped = true,
b'"' => in_string = false,
_ => {}
}
} else if b == b'"' {
in_string = true;
} else if b == b'/' && bytes.get(i + 1) == Some(&b'/') {
end = i;
break;
}
}
out.push_str(&line[..end]);
out.push('\n');
}
out
}
/// The string literal starting at `s` (after optional whitespace), unescaped only for `\"`.
fn string_at(s: &str) -> Option<String> {
let rest = s.trim_start().strip_prefix('"')?;
let mut value = String::new();
let mut chars = rest.chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(value),
'\\' => value.push(chars.next()?),
c => value.push(c),
}
}
None
}
/// Step names from `<build>.step("name", "description")`, in order and without duplicates.
fn steps(src: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for (i, _) in src.match_indices(".step(") {
let preceded = src[..i].chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '_');
if let Some(name) = string_at(&src[i + ".step(".len()..])
&& preceded
&& !name.is_empty()
&& !out.contains(&name)
{
out.push(name);
}
}
out
}
struct Executable {
name: String,
/// Root source file, relative to the build script's folder.
source: Option<String>,
}
/// `addExecutable(.{ .name = "…", … .root_source_file = <b>.path("…") … })`: the name at the
/// options' top level (an `imports` entry has its own `.name`) and the first root source file.
fn executables(src: &str) -> Vec<Executable> {
let mut out: Vec<Executable> = Vec::new();
for (start, _) in src.match_indices("addExecutable(") {
let body = &src[start + "addExecutable(".len()..];
let (mut depth, mut in_string, mut escaped) = (1usize, false, false);
let mut name = None;
let mut source = None;
for (i, c) in body.char_indices() {
if in_string {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'(' | '{' | '[' => depth += 1,
')' | '}' | ']' => {
depth -= 1;
if depth == 0 {
break;
}
}
'.' => {
let field = &body[i + 1..];
let value =
|key: &str| field.strip_prefix(key)?.trim_start().strip_prefix('=').map(str::trim_start);
if depth == 2 && name.is_none() {
name = value("name").and_then(string_at);
}
if source.is_none()
&& let Some(v) = value("root_source_file")
&& let Some(call) = v.find(".path(")
&& v[..call].chars().all(|c| c.is_alphanumeric() || c == '_')
{
source = string_at(&v[call + ".path(".len()..]);
}
}
_ => {}
}
}
if let Some(name) = name
&& !name.is_empty()
&& !out.iter().any(|e| e.name == name)
{
out.push(Executable { name, source });
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const INIT: &str = r#"const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const mod = b.addModule("app", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
});
// const old = b.addExecutable(.{ .name = "commented-out" });
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.imports = &.{
.{ .name = "app", .module = mod },
},
}),
});
b.installArtifact(exe);
const tool = b.addExecutable(.{ .name = "gen-docs", .root_module = b.createModule(.{
.root_source_file = b.path("tools/gen_docs.zig"),
}) });
_ = tool;
const run_step = b.step("run", "Run the app");
const test_step = b.step("test", "Run tests");
const docs = b.step("docs", "Generate docs // not a comment");
_ = .{ run_step, test_step, docs };
}
"#;
#[test]
fn steps_and_executables_come_from_the_build_script() {
let src = code_only(INIT);
assert_eq!(steps(&src), ["run", "test", "docs"]);
let exes = executables(&src);
assert_eq!(
exes.iter().map(|e| (e.name.as_str(), e.source.as_deref())).collect::<Vec<_>>(),
[("app", Some("src/main.zig")), ("gen-docs", Some("tools/gen_docs.zig"))]
);
}
#[test]
fn a_zig_project_gets_build_steps_and_debuggable_executables() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("build.zig"), INIT).unwrap();
let mut out = Vec::new();
detect(dir.path(), &mut out);
let ids: Vec<_> = out.iter().map(|c| c.id.as_str()).collect();
assert_eq!(ids, ["zig:build", "zig:run", "zig:test", "zig:docs", "zig:exe:app", "zig:exe:gen-docs"]);
let test = out.iter().find(|c| c.id == "zig:test").unwrap();
assert!(test.is_test);
assert_eq!(
(test.command.as_str(), test.args.as_slice()),
("zig", ["build".to_owned(), "test".to_owned()].as_slice())
);
let app = out.iter().find(|c| c.id == "zig:exe:app").unwrap();
assert_eq!(app.kind, "zig-application");
assert!(app.command.ends_with(if cfg!(windows) { "app.exe" } else { "zig-out/bin/app" }));
assert_eq!(app.debug_adapter.as_deref(), Some("codelldb"));
assert_eq!(app.extras.before_launch[0].kind, "run");
assert_eq!(app.extras.before_launch[0].target, BUILD_ID);
assert_eq!(app.extras.params.get("source").map(String::as_str), Some("src/main.zig"));
}
}
+14 -4
View File
@@ -26,8 +26,10 @@ pub struct TestCaseReport {
pub duration_ms: Option<f64>,
/// Failure / error / skip message.
pub message: Option<String>,
/// Failure stack trace and captured output.
/// Failure / error stack trace.
pub output: Option<String>,
/// What the test printed (`<system-out>`, `<system-err>`).
pub stdout: Option<String>,
}
fn decode(s: &str) -> String {
@@ -124,11 +126,13 @@ pub fn parse_report(xml: &str) -> Vec<TestCaseReport> {
if let Some(name) = tag.strip_prefix('/') {
match name.trim() {
"testcase" => out.extend(current.take()),
"failure" | "error" | "rerunFailure" | "rerunError" | "skipped" | "system-out" | "system-err" => {
name @ ("failure" | "error" | "rerunFailure" | "rerunError" | "skipped" | "system-out"
| "system-err") => {
if let (Some(t), Some(c)) = (text.take(), current.as_mut()) {
let t = t.trim_matches('\n');
if !t.trim().is_empty() {
let o = c.output.get_or_insert_with(String::new);
let field = if name.starts_with("system-") { &mut c.stdout } else { &mut c.output };
let o = field.get_or_insert_with(String::new);
if !o.is_empty() {
o.push('\n');
}
@@ -160,6 +164,7 @@ pub fn parse_report(xml: &str) -> Vec<TestCaseReport> {
duration_ms,
message: None,
output: None,
stdout: None,
};
if self_closing {
out.push(case);
@@ -238,6 +243,8 @@ mod tests {
<failure message="expected: &lt;1&gt; but was: &lt;2&gt;" type="org.opentest4j.AssertionFailedError"><![CDATA[org.opentest4j.AssertionFailedError: expected: <1> but was: <2>
at com.example.demo.todo.TodoServiceTests.fails(TodoServiceTests.java:30)
]]></failure>
<system-out><![CDATA[printed before failing
]]></system-out>
</testcase>
<testcase name="later" classname="com.example.demo.todo.TodoServiceTests" time="0">
<skipped message="not yet"/>
@@ -254,10 +261,13 @@ mod tests {
assert_eq!(cases[0].status, TestCaseStatus::Passed);
assert_eq!(cases[0].duration_ms, Some(31.0));
assert_eq!(cases[1].name, "deleteReturnsFalseForUnknownId");
assert_eq!(cases[1].output.as_deref(), Some("hello <world>"));
assert_eq!(cases[1].stdout.as_deref(), Some("hello <world>"));
assert_eq!(cases[1].output, None);
assert_eq!(cases[2].status, TestCaseStatus::Failed);
assert_eq!(cases[2].message.as_deref(), Some("expected: <1> but was: <2>"));
assert!(cases[2].output.as_deref().unwrap().contains("TodoServiceTests.java:30"));
assert!(!cases[2].output.as_deref().unwrap().contains("printed"));
assert_eq!(cases[2].stdout.as_deref(), Some("printed before failing"));
assert_eq!(cases[2].duration_ms, Some(1_234_500.0));
assert_eq!(cases[3].status, TestCaseStatus::Skipped);
assert_eq!(cases[3].message.as_deref(), Some("not yet"));
+21 -1
View File
@@ -3,7 +3,7 @@
use crate::discover::{project_venv_python, workspace_tsdk};
use crate::exec::{path_with, search_path, which};
use crate::npm::{has_ts_api, own_typescript};
use crate::npm::{has_ts_api, native_typescript, own_typescript};
use crate::{angular, astro, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
@@ -722,6 +722,16 @@ pub fn tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
}
}
/// TypeScript 7's native executable when the selected TypeScript is one (see
/// [`native_typescript`]); `None` for the bundled one and TypeScript 6 and older.
pub fn native_tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
match s.languages.typescript.version.trim() {
"" | "bundled" => None,
"workspace" => native_typescript(&root.join("node_modules/typescript")),
custom => native_typescript(&setting_path(root, custom)?),
}
}
const PYTHON_SERVERS: [&str; 7] = ["basedpyright", "pyright", "ty", "pyrefly", "pylsp", "jedi-language-server", "ruff"];
pub(crate) fn merge(base: &mut Value, layer: Value) {
@@ -1140,6 +1150,16 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
_ => {}
}
}
// TypeScript 7 serves LSP itself: it replaces the tsserver-based server.
if matches!(name, "vtsls" | "typescript-language-server")
&& let Some(native) = native_tsdk(server_root, s).or_else(|| native_tsdk(root, s))
{
cfg.command = native.to_string_lossy().into_owned();
cfg.args = vec!["--lsp".into(), "--stdio".into()];
cfg.initialization_options = None;
cfg.tsserver_project = false;
return;
}
let vue_command = s.tool_path(vue::SERVER).and_then(|p| setting_path(root, p));
let vue_command = vue_command.as_deref().map_or(vue::SERVER.into(), Path::to_string_lossy);
if name == "vtsls"
+14 -2
View File
@@ -749,14 +749,26 @@ pub(crate) fn workspace_tsdk(root: &Path) -> Option<PathBuf> {
fn typescript(root: Option<&Path>) -> Vec<Sdk> {
let mut out = Vec::new();
if let Some(lib) = root.and_then(workspace_tsdk) {
// TypeScript 7 has no `tsserver.js`; its package directory stands for it (`native_tsdk`).
let native = || {
let package = root?.join("node_modules/typescript");
crate::npm::native_typescript(&package).map(|_| package.join("lib"))
};
if let Some(lib) = root.and_then(workspace_tsdk).or_else(native) {
let version = std::fs::read_to_string(lib.parent().unwrap_or(&lib).join("package.json"))
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| v.get("version").and_then(|v| v.as_str()).map(str::to_owned));
out.push(Sdk {
path: lib.clone().into(),
name: format!("TypeScript {}", version.clone().unwrap_or_default()).trim().to_owned(),
name: format!(
"TypeScript {}{}",
version.clone().unwrap_or_default(),
if crate::npm::has_ts_api(&lib) { "" } else { " (native)" }
)
.replace(" ", " ")
.trim()
.to_owned(),
version,
source: "workspace".into(),
home: lib.parent().map(Into::into),
+35
View File
@@ -10,6 +10,41 @@ pub(crate) fn has_ts_api(lib: &Path) -> bool {
lib.join("typescript.js").is_file()
}
/// The native executable of TypeScript 7 (`typescript` 7+, whose `tsc` is native, or
/// `@typescript/native-preview`'s `tsgo`), from its package directory or its `lib`.
/// These packages have no JavaScript API (`lib/typescript.js`); their platform package
/// (`@typescript/typescript-darwin-arm64`) holds the executable, which serves LSP itself
/// (`--lsp --stdio`).
pub(crate) fn native_typescript(dir: &Path) -> Option<PathBuf> {
let package = if dir.join("package.json").is_file() { dir } else { dir.parent()? };
if has_ts_api(&package.join("lib")) {
return None;
}
let manifest: serde_json::Value =
serde_json::from_slice(&std::fs::read(package.join("package.json")).ok()?).ok()?;
let (base, bin) = match manifest.get("name")?.as_str()? {
"typescript" => ("typescript", "tsc"),
"@typescript/native-preview" => ("native-preview", "tsgo"),
_ => return None,
};
let os = match std::env::consts::OS {
"macos" => "darwin",
"windows" => "win32",
os => os,
};
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x64",
arch => arch,
};
let platform = format!("{base}-{os}-{arch}");
// Next to the package (`node_modules/@typescript/…`) or nested in it.
package
.ancestors()
.map(|a| a.join("node_modules/@typescript").join(&platform).join("lib").join(crate::exe(bin)))
.find(|exe| exe.is_file())
}
/// The usable TypeScript library Node resolves from an installed server package.
pub(crate) fn own_typescript(package: &Path) -> Option<PathBuf> {
package.ancestors().map(|a| a.join("node_modules/typescript/lib")).find(|l| has_ts_api(l))
+45
View File
@@ -1359,6 +1359,51 @@ fn a_server_chosen_from_a_node_installation_runs_on_that_node() {
assert_eq!(std::env::split_paths(&cfg.env["PATH"]).next(), Some(v20));
}
/// TypeScript 7 (`typescript` 7+, `@typescript/native-preview`) has no tsserver: choosing it
/// runs its native executable's own language server instead of vtsls / typescript-language-server.
#[test]
fn typescript_7_runs_its_own_language_server() {
let tmp = tempfile::tempdir().unwrap();
let modules = tmp.path().join("node_modules");
let os = match std::env::consts::OS {
"macos" => "darwin",
"windows" => "win32",
os => os,
};
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x64",
arch => arch,
};
std::fs::create_dir_all(modules.join("typescript/lib")).unwrap();
std::fs::write(modules.join("typescript/package.json"), r#"{"name":"typescript","version":"7.0.2"}"#).unwrap();
let native = modules.join(format!("@typescript/typescript-{os}-{arch}/lib")).join(exe("tsc"));
std::fs::create_dir_all(native.parent().unwrap()).unwrap();
std::fs::write(&native, "").unwrap();
let mut s = Settings::default();
s.languages.typescript.version = "workspace".into();
for name in ["vtsls", "typescript-language-server"] {
let mut cfg = server(name);
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(Path::new(&cfg.command), native, "{name}");
assert_eq!(cfg.args, ["--lsp", "--stdio"]);
assert!(cfg.initialization_options.is_none());
}
// Picked as a directory (its `lib`, as for older versions), too.
s.languages.typescript.version = modules.join("typescript/lib").to_string_lossy().into_owned();
let mut cfg = server("vtsls");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(Path::new(&cfg.command), native);
// TypeScript 6 keeps the tsserver-based server, pointed at it.
std::fs::write(modules.join("typescript/lib/typescript.js"), "").unwrap();
std::fs::write(modules.join("typescript/lib/tsserver.js"), "").unwrap();
s.languages.typescript.version = "workspace".into();
let mut cfg = server("vtsls");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(cfg.command, "vtsls");
}
#[test]
fn the_godot_setting_applies_to_server_debugger_and_run_configurations() {
let tmp = tempfile::tempdir().unwrap();
+23 -1
View File
@@ -1,7 +1,8 @@
//! An in-process SSH server for tests (feature `testing`): password `dev` /
//! `secret`, commands answered by a closure, SFTP served from a local
//! folder (remote `/x` is `<root>/x`), and a shell that echoes its input
//! (`exit` ends it with status 0).
//! (`exit` ends it with status 0). `direct-tcpip` channels are forwarded to local TCP
//! ports, and refused when nothing listens there.
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
use russh::{Channel, ChannelId};
@@ -71,6 +72,27 @@ impl server::Handler for Conn {
Ok(())
}
async fn channel_open_direct_tcpip(
&mut self,
channel: Channel<Msg>,
host: &str,
port: u32,
_originator_address: &str,
_originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
let Ok(mut upstream) = tokio::net::TcpStream::connect((host, port as u16)).await else {
return Ok(());
};
reply.accept().await;
tokio::spawn(async move {
let mut stream = channel.into_stream();
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
});
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
+12
View File
@@ -41,6 +41,14 @@ pub trait Backend: Send + Sync {
/// as `unstaged`.
fn status(&self) -> IdeResult<Vec<FileStatus>>;
/// Working-area changes for `paths` only (absolute; those outside the root
/// are ignored), which is what a watcher batch needs. The default is the
/// full status: always correct, just not proportional to the batch.
fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
let _ = paths;
self.status()
}
/// Text of `path` at `rev`; `None` when absent there.
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>>;
@@ -189,6 +197,10 @@ impl Backend for Repo {
Repo::status(self)
}
fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
Repo::status_paths(self, paths)
}
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>> {
Repo::file_at(self, path, rev, encoding)
}
+18
View File
@@ -764,6 +764,24 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
status::status(self)
}
/// Status of `paths` only (absolute; those outside the repository are ignored), for the
/// dirty-scope refresh after a watcher batch. An empty list is the full status.
pub fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
// Restricted Mode starts no program of the repository, and the scoped read is `git status`
// with a pathspec: answer with the full gitoxide status instead.
if !self.execution_allowed.load(std::sync::atomic::Ordering::Acquire) {
return status::status(self);
}
if paths.is_empty() {
return status::status_paths(self, &[]);
}
let rel: Vec<String> = paths.iter().filter_map(|p| self.given_rel_path(p).ok()).collect();
if rel.is_empty() {
return Ok(Vec::new());
}
status::status_paths(self, &rel)
}
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
refs::branch_info(self)
+1 -1
View File
@@ -148,7 +148,7 @@ impl Repo {
}
/// `path` relative to the worktree, spelled as given.
fn given_rel_path(&self, path: &Path) -> IdeResult<String> {
pub(crate) fn given_rel_path(&self, path: &Path) -> IdeResult<String> {
let rel = if path.is_absolute() {
self.strip_root(path)
.ok_or_else(|| IdeError::invalid(format!("{} is outside the repository", path.display())))?
+19 -2
View File
@@ -146,17 +146,34 @@ impl Repos {
/// The reads go through [`Backend`], the Changes-view surface — repositories of a
/// future second backend slot in here once `Repos` learns to hold them.
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
let groups: Vec<(Arc<Repo>, Vec<PathBuf>)> = self.repos.iter().map(|repo| (repo.clone(), Vec::new())).collect();
self.read_status(groups, false)
}
/// [`status`](Self::status) for `paths` only: each path goes to its innermost repository,
/// so a repository without one is not read at all and a nested repository keeps its own
/// files (the enclosing one's "untracked folder" entry for them is dropped).
pub fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
self.read_status(self.group(paths), true)
}
/// Reads one status per repository: the whole working tree, or `scoped` the paths it was
/// given. A nested repository reports its own files, so the enclosing one's "untracked
/// folder" entry for it is dropped. A repository that cannot be read is left out; fails
/// only when all fail.
fn read_status(&self, groups: Vec<(Arc<Repo>, Vec<PathBuf>)>, scoped: bool) -> IdeResult<Vec<FileStatus>> {
let mut out = Vec::new();
let mut first_error = None;
let mut read = 0;
for repo in &self.repos {
for (repo, mine) in groups {
let nested: Vec<&Path> = self
.repos
.iter()
.map(|r| r.root())
.filter(|r| *r != repo.root() && r.starts_with(repo.root()))
.collect();
let status = match (repo.as_ref() as &dyn Backend).status() {
let backend = repo.as_ref() as &dyn Backend;
let status = match if scoped { backend.status_paths(&mine) } else { backend.status() } {
Ok(status) => {
read += 1;
status
+47 -3
View File
@@ -24,9 +24,38 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
tracing::debug!("status via gitoxide failed, asking git: {}", e.message);
cli_status(r, &ignored)
})?;
Ok(collect(r, &ignored, map, conflicts))
}
/// Status of `paths` only (repository-relative, `/`-separated): the dirty-scope refresh.
///
/// A watcher batch touches a few files, and the full status walks the whole tree; git's own
/// pathspec filtering keeps that batch proportional to what changed. An empty list is the
/// full status, so a caller that lost the paths still gets a correct answer.
pub(crate) fn status_paths(r: &Repo, paths: &[String]) -> IdeResult<Vec<FileStatus>> {
// Pathspecs always take `/`, and a path that *is* the repository root names everything: both
// matter, because `Repo::status_paths` hands over paths as the platform spells them.
let patterns: Vec<String> =
paths.iter().map(|path| path.replace('\\', "/")).filter(|path| !path.is_empty()).collect();
if patterns.is_empty() {
return status(r);
}
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
// `git status` with the pathspec, for the same reason the full read falls back to it: gitoxide's
// in-process pathspec matched nothing for a plain relative path here, and a silent empty
// answer is worse than the ~40 ms a `git` process costs on Windows.
let (map, conflicts) = cli_status_paths(r, &ignored, &patterns)?;
Ok(collect(r, &ignored, map, conflicts))
}
fn collect(
r: &Repo,
ignored: &IgnoredNames,
map: BTreeMap<String, Entry>,
conflicts: HashSet<String>,
) -> Vec<FileStatus> {
let mut disk = r.gix().is_ok_and(|repo| crate::repo::ignores_case(&repo)).then(DiskSpelling::default);
Ok(map
.into_iter()
map.into_iter()
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
// changes too, not just untracked hits above. Conflicts stay visible so merges
@@ -51,7 +80,7 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
renamed_from: e.renamed_from,
}
})
.collect())
.collect()
}
/// Spells repository paths the way a case-insensitive disk does, so that the tree, tabs and
@@ -216,6 +245,21 @@ fn cli_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
}
/// `git status` for `paths` only, parsed and filtered like the full one. `--literal-pathspecs`
/// keeps a `*` or `[` in a file name from being read as a pattern.
fn cli_status_paths(r: &Repo, ignored: &IgnoredNames, paths: &[String]) -> IdeResult<Entries> {
let overrides = crate::cli::filter_overrides(r.root(), &ide_core::CancellationToken::new())?;
let mut args: Vec<&str> = overrides.iter().map(String::as_str).collect();
args.extend(["--literal-pathspecs", "status", "--porcelain=v2", "-z", "--untracked-files=all"]);
if !crate::execution_allowed() {
args.push("--ignore-submodules=dirty");
}
args.push("--");
args.extend(paths.iter().map(String::as_str));
let out = crate::cli::run(r.root(), &args, None)?;
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
}
fn parse_porcelain_v2(out: &str, ignored: &IgnoredNames) -> Entries {
let kind = |c: u8| match c {
b'M' | b'T' => Some(ChangeKind::Modified),
+45
View File
@@ -2065,3 +2065,48 @@ fn a_case_only_directory_rename_keeps_status_and_head_content() {
assert_eq!(status_of(&st, "src/financial/table/index.vue").staged, Some(ChangeKind::Modified));
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "src/financial/table/index.vue");
}
#[test]
fn status_paths_reads_only_the_named_paths() {
// The dirty-scope refresh: a watcher batch names a few paths, and git's pathspec
// filtering keeps the answer proportional to the batch instead of the whole tree.
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.write("b.txt", "a\n");
fx.write("dir/c.txt", "a\n");
fx.commit_all("base");
fx.write("a.txt", "b\n");
fx.write("b.txt", "b\n");
fx.write("dir/c.txt", "b\n");
fx.write("new.txt", "n\n");
let repo = fx.repo();
repo.set_ignored_names(ide_core::settings::Settings::default().ignored_names());
let one = repo.status_paths(&[fx.path("a.txt")]).unwrap();
assert_eq!(one.len(), 1, "{one:#?}");
assert_eq!(one[0].rel_path, "a.txt");
assert_eq!(one[0].unstaged, Some(ChangeKind::Modified));
// A directory stands for everything below it, as the watcher reports a removed one.
let dir = repo.status_paths(&[fx.path("dir")]).unwrap();
assert_eq!(dir.iter().map(|s| s.rel_path.as_str()).collect::<Vec<_>>(), ["dir/c.txt"]);
// A nested file: the path a watcher hands over is spelled by the platform, and the pathspec
// needs `/` whatever the platform's separator is.
let nested = repo.status_paths(&[fx.path("dir").join("c.txt")]).unwrap();
assert_eq!(nested.iter().map(|s| s.rel_path.as_str()).collect::<Vec<_>>(), ["dir/c.txt"]);
// The repository root itself names everything, not a path called "".
assert_eq!(repo.status_paths(&[fx.path("")]).unwrap().len(), 4);
// A new file the batch names is reported as untracked, not as a directory entry.
let new = repo.status_paths(&[fx.path("new.txt")]).unwrap();
assert_eq!((new[0].rel_path.as_str(), new[0].unstaged), ("new.txt", Some(ChangeKind::Untracked)));
// Paths outside the repository are ignored rather than an error; nothing named is nothing read.
let outside = if cfg!(windows) { PathBuf::from("C:/elsewhere/x.txt") } else { PathBuf::from("/elsewhere/x.txt") };
assert!(repo.status_paths(&[outside]).unwrap().is_empty());
// An empty list is the full status, so a caller that lost the paths stays correct.
assert_eq!(repo.status_paths(&[]).unwrap().len(), 4);
}
+1 -1
View File
@@ -41,7 +41,7 @@ User-visible text is written in English at the call site and goes through the te
## WebKit compatibility
On macOS and Linux the app runs in WebKit, and the supported floor is Safari 16 (macOS 11 can update to it). `pnpm build` runs `scripts/check-webkit.ts` on the bundle, and `src/platform/webkitCompatibility.test.ts` checks application regexes. Avoid:
On macOS and Linux the app runs in WebKit, and the supported floor is Safari 16, the engine of macOS 13 (on macOS 11 and 12 a Safari update doesn't update the engine other apps use). `pnpm build` runs `scripts/check-webkit.ts` on the bundle, and `src/platform/webkitCompatibility.test.ts` checks application regexes. Avoid:
- regex lookbehind (`(?<=…)`, `(?<!…)`), which needs Safari 16.4, and the regex `v` flag;
- import attributes;
+1 -1
View File
@@ -9,7 +9,7 @@ In the file names below, `<version>` is the release version, for example `0.1.40
## macOS
macOS 11 or later.
macOS 13 Ventura or later.
| Mac | File |
| ------------- | -------------------------------- |
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff. Show more