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

..
Автор SHA1 Повідомлення Дата
l0ng-ai a7708725cb feat(vcs): fill the commit message with the repository's commit template
An empty commit message field now starts with the file `commit.template`
names (any git config level; `~/` and repository-relative paths resolve
as git resolves them), without its `#` comment lines, since commits keep
the message as written. It is filled when the repository opens or
changes and when a commit clears the field; a typed message is never
replaced, and a prepared merge / cherry-pick / revert message still
takes precedence.

Fixes #1285
2026-11-05 02:04:47 +08:00
265 змінених файлів з 1491 додано та 20821 видалено

No files matched your search

+4 -17
Переглянути файл
@@ -2,8 +2,6 @@ name: Language smoke
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
# with a quick fix, an intention, formatting, rename) per language, against the language
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
# Then running and testing each fixture through the run store and openidea-server
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts.
on:
push:
branches: [main]
@@ -16,18 +14,12 @@ on:
- "fixtures/**"
- "Cargo.toml"
- "Cargo.lock"
- "src/features/run/**"
- "src/platform/**"
- "smoke/**"
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
workflow_dispatch:
concurrency:
group: language-smoke-${{ github.ref }}
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -61,10 +53,9 @@ jobs:
shared-key: language-smoke
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- if: matrix.language == 'typescript'
uses: actions/setup-node@v4
with: { node-version: 22 }
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
@@ -104,7 +95,3 @@ 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 }}
- run: pnpm install --frozen-lockfile
- run: cargo build --locked -p ide-host --bin openidea-server
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
-2
Переглянути файл
@@ -69,8 +69,6 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
Створено
-3
Переглянути файл
@@ -4662,14 +4662,12 @@ dependencies = [
name = "ide-vcs"
version = "0.1.36"
dependencies = [
"blake3",
"dunce",
"encoding_rs",
"gix",
"ide-core",
"ignore",
"libc",
"redb",
"regex-lite",
"serde",
"serde_json",
@@ -4737,7 +4735,6 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"uuid",
"zip",
]
-9
Переглянути файл
@@ -52,15 +52,6 @@ Their original copyright headers and Apache-2.0 licensing are retained.
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
## JetBrains IntelliJ Platform VCS log graph algorithms
`crates/ide-vcs/src/bek.rs` and `src/features/vcs/graph.ts` adapt the Bek ordering,
graph layout and LinearBek merge linearization algorithms from
[intellij-community](https://github.com/JetBrains/intellij-community/tree/1d70d5c54302d655758edbd2f808fb8dea8b1764/platform/vcs-log/graph/src/com/intellij/vcs/log/graph)
(`impl/facade/sort/bek`, `impl/permanent/GraphLayoutBuilder.kt` and
`linearBek/LinearBekGraphBuilder.java`). Modified from the originals.
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
## JetBrains IntelliJ Platform diff algorithm
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
-6
Переглянути файл
@@ -716,12 +716,6 @@ pub struct PhpSettings {
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
/// Folders outside the project whose code the language server indexes too (a platform a
/// module extends); absolute, `~/`, or relative to the project.
pub include_paths: Vec<String>,
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
/// `composer.json` (`wordpress`).
pub stubs: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
+6 -18
Переглянути файл
@@ -589,12 +589,11 @@ impl DebugManager {
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
if config.adapter == "debugpy" {
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
let python = match arguments.get("python") {
Some(Value::String(p)) => Some(p.clone()),
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
_ => None,
};
if let Some(python) = python {
@@ -652,19 +651,8 @@ impl DebugManager {
{
arguments["project"] = json!(real);
}
if config.adapter == "codelldb" {
if arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
// The Debug Console and Evaluate Expression take expressions, as with every other
// debugger; LLDB commands still run with a leading backtick.
match arguments.get_mut("_adapterSettings") {
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
Some(Value::Object(settings)) => {
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
}
Some(_) => {}
}
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
let port = debug_server.map(|s| s.port);
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
-31
Переглянути файл
@@ -698,37 +698,6 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
assert_eq!(launch["type"], "pwa-node");
}
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
/// and the Debug Console send expressions, so its console evaluates them. A launch
/// configuration that picks a console mode keeps it.
#[tokio::test]
async fn codelldb_console_evaluates_expressions() {
let launch_with = |extra: Value| async move {
let h = harness(Script::default());
let config = LaunchConfig {
name: "demo".into(),
adapter: "codelldb".into(),
request: "launch".into(),
program: Some("/p/target/debug/demo".into()),
args: vec![],
cwd: Some("/p".into()),
env: BTreeMap::new(),
extra_json: Some(extra.to_string()),
};
h.start(config).await.unwrap();
h.request("launch")["arguments"].clone()
};
let launch = launch_with(json!({})).await;
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
let launch =
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
}
/// An adapter that is already listening (java-debug inside jdtls): the client
/// connects over TCP to the `debugServer` port instead of spawning anything.
#[tokio::test]
+5 -14
Переглянути файл
@@ -79,7 +79,6 @@ pub struct Host {
pub runtime: tokio::runtime::Handle,
/// One project per window, keyed by window label.
projects: RwLock<HashMap<String, Arc<Project>>>,
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
}
@@ -123,7 +122,6 @@ impl Host {
owner,
runtime,
projects: RwLock::default(),
external_logs: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
};
@@ -180,10 +178,6 @@ impl Host {
}
pub fn close_project(&self, window: &str) {
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
let project = self.projects.write().remove(window);
if let Some(project) = project {
if !project.light {
@@ -199,6 +193,9 @@ impl Host {
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
@@ -314,20 +311,14 @@ impl Host {
}
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_project_read_request(window, id);
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
token.cancel();
}
token
}
/// Read-only work remains cancellable/disposed with its window in restricted projects.
pub fn begin_project_read_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
token
}
pub fn end_request(&self, id: RequestId) {
for ids in self.project_requests.write().values_mut() {
ids.retain(|entry| *entry != id);
+1 -1
Переглянути файл
@@ -18,7 +18,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
/// editor buffer, so syntax features and language-server requests work in it.
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
let vd = project.lsp.virtual_document(&uri).await?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
+3 -10
Переглянути файл
@@ -137,23 +137,16 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
let history = host.project(window).ok().map(|p| p.history.clone());
host.blocking(move || {
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
if let Some(h) = history {
for p in paths {
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
}
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
}
ide_workspace::file_ops::delete(&path)
ide_vfs::delete(&path)
})
.await
}
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
}
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
host.blocking(move || ide_vfs::copy(&from, &to)).await
}
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
+1 -26
Переглянути файл
@@ -260,9 +260,6 @@ pub async fn lsp_implementations(
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
@@ -441,30 +438,12 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
let edit = edit.clone();
blocking(move || apply_edit(&documents, &edit)).await?;
}
if let Some(command) = action.command
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
{
if let Some(command) = action.command {
project.lsp.execute_command(doc, command).await?;
}
Ok(())
}
/// A command after a code action's edit that no server provides: what other editors'
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
/// is the action; there is nothing to run.
async fn is_client_follow_up(
project: &crate::project::Project,
doc: DocId,
server: Option<ide_core::ServerId>,
command: &Command,
) -> bool {
let provided = project.lsp.provides_command(doc, server, &command.command).await;
if !provided {
tracing::debug!("skipping `{}` after the code action's edit: no server provides it", command.command);
}
!provided
}
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
host.project(window)?.lsp.prepare_rename(doc, pos).await
}
@@ -697,7 +676,6 @@ pub async fn lsp_execute_code_action(
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let edited = action.edit.is_some();
if let Some(edit) = action.edit {
let documents = project.documents.clone();
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
@@ -705,9 +683,6 @@ pub async fn lsp_execute_code_action(
.map_err(|e| ide_core::IdeError::internal(e.to_string()))??;
}
match action.command {
Some(command) if edited && is_client_follow_up(&project, doc, Some(action.server), &command).await => {
Ok(Vec::new())
}
Some(command) => project
.lsp
.execute_action_command(doc, action.server, command, answers, cancel)
+1 -7
Переглянути файл
@@ -3,7 +3,7 @@ use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
use serde::{Deserialize, Serialize};
@@ -32,11 +32,6 @@ pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
Ok(host.project(window)?.index.status())
}
/// The project's file extensions, most common first.
pub fn index_file_extensions(host: &Host, window: &str) -> IdeResult<Vec<ExtensionCount>> {
Ok(host.project(window)?.index.file_extensions())
}
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
@@ -165,7 +160,6 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
-3
Переглянути файл
@@ -17,8 +17,6 @@ pub struct ProjectInfo {
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<ide_workspace::workspace::Workspace>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unity: Option<ide_workspace::unity::UnityProjectInfo>,
}
pub fn info(project: &Project) -> ProjectInfo {
@@ -35,7 +33,6 @@ pub fn info(project: &Project) -> ProjectInfo {
settings,
root: project.root.clone().into(),
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
unity: ide_workspace::unity::detect(&project.root),
}
}
-18
Переглянути файл
@@ -142,21 +142,6 @@ pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: V
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Writes one of the IDE's Gradle init scripts (`text`; the file is named `<name>-<hash>`) on
/// the machine that runs the build, for `--init-script`, and returns its path.
pub async fn run_gradle_init_script(_host: &Host, _window: &str, name: String, text: String) -> IdeResult<String> {
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err(IdeError::invalid(format!("invalid init script name {name:?}")));
}
tokio::task::spawn_blocking(move || {
ide_run::gradle_jvm::write_script(&ide_run::gradle_jvm::default_dir(), &name, &text)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
pub async fn run_test_reports(
@@ -323,9 +308,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
d.add("run_gradle_init_script", |c| async move {
run_gradle_init_script(&c.host, &c.window, c.arg("name")?, c.arg("text")?).await
});
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
-40
Переглянути файл
@@ -34,42 +34,6 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
/// the language server configuration picks it).
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevels {
/// The one Intelephense checks against; `None`: its default.
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
/// The one Auto picks, from `composer.json`.
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
/// The workspace member `path` belongs to: its own settings choose the level, not the
/// workspace's. `None`: the opened project.
pub member: Option<ide_core::IpcPath>,
}
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
let project = host.project(window)?;
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
let config_dir = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
});
let (root, settings) = match &member {
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
None => (&project_root, project_settings),
};
PhpLanguageLevels {
effective: ide_sdk::php::language_level(root, &settings),
auto: ide_sdk::php::composer_level(root),
member: member.map(ide_core::IpcPath),
}
})
.await
.map_err(join_err)
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
@@ -180,10 +144,6 @@ pub async fn sdk_python_pip(
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add(
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
+22 -239
Переглянути файл
@@ -12,76 +12,6 @@ use std::path::{Path, PathBuf};
use crate::apply_git_executable;
/// Read-only repository access also works in a standalone log window.
struct LogAccess {
project: Option<std::sync::Arc<Project>>,
external: Option<std::sync::Arc<Repo>>,
cache: PathBuf,
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
repo: PathBuf,
revision: String,
path: PathBuf,
}
let encoded = uri
.strip_prefix("git-revision:")
.and_then(|s| s.split('/').next())
.ok_or_else(|| ide_core::IdeError::invalid("invalid Git revision URI"))?;
if encoded.len() > 32768 || encoded.len() % 2 != 0 || !encoded.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(ide_core::IdeError::invalid("invalid Git revision URI"));
}
let bytes = (0..encoded.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&encoded[i..i + 2], 16)
.map_err(|_| ide_core::IdeError::invalid("invalid Git revision URI"))
})
.collect::<IdeResult<Vec<_>>>()?;
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let path = target.repo.join(&target.path);
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
.await?
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
Ok(ide_lsp::types::VirtualDocument {
uri: uri.into(),
language_id: host.languages.detect(&target.path, text.lines().next()),
text,
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
})
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
let external = host
.external_logs
.read()
.get(host.owner_window(window))
.and_then(|rs| rs.iter().find(|r| Some(r.root()) == at).cloned())
.filter(|repo| project.as_ref().is_none_or(|p| p.repos().at_root(repo.root()).is_none()));
Self { project, external, cache: host.local_data_dir.join("cache/git-log") }
}
fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if let Some(repo) = &self.external {
repo.enable_log_index(&self.cache);
return f(repo);
}
self.project
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
.with_log_repo_at(at, f)
}
}
/// A repository of the project (IDEA's VCS root).
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -252,8 +182,8 @@ pub fn vcs_roots(host: &Host, window: &str) -> IdeResult<Vec<VcsRoot>> {
}
pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<BranchInfo> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branch_info())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
}
/// Gutter change markers for a file, computed against its current (unsaved) text.
@@ -267,21 +197,9 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
}
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
let project = host.project(window)?;
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| {
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
});
}
external.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?.file_at(
&path,
rev.as_deref(),
encoding_rs::UTF_8,
)
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
})
.await
}
@@ -328,124 +246,8 @@ pub async fn vcs_block_history(
}
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
let project = LogAccess::new(host, window, repo.as_deref());
let request = query.request;
let cancel =
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
r.log_cancellable(&query, &cancel)
})
})
.await;
if let Some(id) = request {
host.end_request(id);
}
result
}
pub async fn vcs_log_refs(
host: &Host,
window: &str,
repo: Option<PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_refs())).await
}
pub async fn vcs_log_users(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_users())).await
}
pub async fn vcs_open_external_log(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_core::IpcPath> {
let path = dunce::canonicalize(path)?;
let repo = Repo::discover(&path)?.ok_or_else(|| ide_core::IdeError::unavailable("folder is not a Git worktree"))?;
let root = repo.root().to_path_buf();
repo.set_execution_allowed(false);
repo.enable_log_index(&host.local_data_dir.join("cache/git-log"));
if let Ok(project) = host.project(window) {
project.open_external_log(&root)?;
}
let mut logs = host.external_logs.write();
let roots = logs.entry(host.owner_window(window).to_string()).or_default();
if !roots.iter().any(|r| r.root() == root) {
roots.push(std::sync::Arc::new(repo));
}
Ok(root.into())
}
pub async fn vcs_log_details(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
) -> IdeResult<LogDetails> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_details(&revision))).await
}
pub async fn vcs_log_details_batch(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revisions: Vec<String>,
request: RequestId,
) -> IdeResult<Vec<LogDetails>> {
let project = LogAccess::new(host, window, repo.as_deref());
let cancel = host.begin_project_read_request(window, request);
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
})
})
.await;
host.end_request(request);
result
}
pub async fn vcs_log_index(
host: &Host,
window: &str,
repo: Option<PathBuf>,
request: Option<RequestId>,
) -> IdeResult<LogIndexProgress> {
let project = LogAccess::new(host, window, repo.as_deref());
let cancel =
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
if request.is_some() { r.index_log(&cancel) } else { Ok(r.log_index_progress()) }
})
})
.await;
if let Some(request) = request {
host.end_request(request);
}
result
}
pub async fn vcs_revision_children(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
directory: String,
) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_children(&revision, &directory))).await
}
pub async fn vcs_revision_files(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_files(&revision))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
}
pub async fn vcs_commit_files(
@@ -454,8 +256,8 @@ pub async fn vcs_commit_files(
repo: Option<PathBuf>,
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
/// Combined changes of several commits, `commits` oldest first.
@@ -465,13 +267,13 @@ pub async fn vcs_commits_changes(
repo: Option<PathBuf>,
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
}
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branches())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
}
pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
@@ -826,8 +628,8 @@ pub async fn vcs_config_get(
repo: Option<PathBuf>,
key: String,
) -> IdeResult<Option<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
}
/// Local history operation that may take a while (hooks, large checkouts); git's
@@ -888,8 +690,8 @@ pub async fn vcs_create_tag(
}
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.tags())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
}
/// Merge dialog: merges `branches` into the current branch.
@@ -925,8 +727,8 @@ pub async fn vcs_rebase_with(
}
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
@@ -1013,8 +815,8 @@ pub async fn vcs_diff_with_worktree(
repo: Option<PathBuf>,
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Files that differ between two revisions (the "N files updated" of Update Project).
@@ -1025,8 +827,8 @@ pub async fn vcs_diff_revisions(
from: String,
to: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
@@ -1213,25 +1015,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_block_history(&c.host, &c.window, request, path, text, lines).await
});
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
d.add("vcs_log_refs", |c| async move { vcs_log_refs(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_log_users", |c| async move { vcs_log_users(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_open_external_log", |c| async move { vcs_open_external_log(&c.host, &c.window, c.arg("path")?).await });
d.add("vcs_log_details", |c| async move {
vcs_log_details(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
});
d.add("vcs_log_details_batch", |c| async move {
vcs_log_details_batch(&c.host, &c.window, c.arg("repo")?, c.arg("revisions")?, c.arg("request")?).await
});
d.add(
"vcs_log_index",
|c| async move { vcs_log_index(&c.host, &c.window, c.arg("repo")?, c.arg("request")?).await },
);
d.add("vcs_revision_children", |c| async move {
vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await
});
d.add("vcs_revision_files", |c| async move {
vcs_revision_files(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
});
d.add("vcs_commit_files", |c| async move {
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
});
-11
Переглянути файл
@@ -667,17 +667,6 @@ impl HostApi for PluginApi {
Ok(())
}
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
let event = PluginEvent::OpenLocation {
plugin_id: self.id().to_owned(),
path: path.to_path_buf().into(),
line,
column,
};
self.shared.sink.emit(client, event);
Ok(())
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
-1
Переглянути файл
@@ -114,7 +114,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
)
}
-2
Переглянути файл
@@ -192,8 +192,6 @@ pub enum PluginEvent {
args: Vec<String>,
cwd: ide_core::IpcPath,
},
#[serde(rename_all = "camelCase")]
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
-10
Переглянути файл
@@ -378,19 +378,9 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
let path = std::path::PathBuf::from(icon);
let svg = super::icons::read_icon(pkg, &path)?;
out.assets.push((view_icon_asset(&d.id), svg));
out.files.push(path);
}
Ok(out)
}
pub fn view_icon_asset(id: &str) -> String {
format!("viewIcon/{id}")
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CustomEditorDecl {
+2 -58
Переглянути файл
@@ -34,8 +34,6 @@ pub struct Project {
pub lsp: Arc<LspManager>,
/// Git repositories: the one containing the project root and those nested below it.
pub repos: Arc<RwLock<Repos>>,
/// Explicitly opened external logs remain read-only and outside project indexing.
pub(crate) external_log_repos: RwLock<Vec<Arc<Repo>>>,
/// Incrementally maintained syntax trees of the open documents.
pub syntax: Arc<SyntaxCache>,
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
@@ -464,7 +462,6 @@ impl Host {
index,
lsp,
repos,
external_log_repos: RwLock::default(),
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
@@ -598,7 +595,6 @@ impl Host {
index,
lsp,
repos: Arc::default(),
external_log_repos: RwLock::default(),
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots: Arc::default(),
@@ -696,14 +692,7 @@ fn scan_repositories(
build_cache: &Path,
) {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| {
p.file_name().is_some_and(|n| {
let name = n.to_string_lossy();
ignored.is_ignored_name(&name)
|| (ide_workspace::unity::GENERATED_DIRS.contains(&name.as_ref())
&& p.parent().is_some_and(ide_workspace::unity::is_project))
})
};
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root, build_cache));
match Repos::discover_roots(&roots, &skip) {
@@ -750,51 +739,6 @@ impl Project {
}
}
pub fn open_external_log(&self, path: &Path) -> IdeResult<PathBuf> {
let root = dunce::canonicalize(path)?;
let repo = Repo::discover(&root)?.ok_or_else(|| IdeError::unavailable("folder is not a Git worktree"))?;
let root = repo.root().to_path_buf();
repo.set_execution_allowed(false);
let mut external = self.external_log_repos.write();
if !external.iter().any(|r| r.root() == root) {
external.push(Arc::new(repo));
}
Ok(root)
}
/// Log read operations may also use an explicitly opened external worktree.
/// Mutations continue to use `with_repo_at` and cannot reach these repositories.
pub fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if let Some(path) = at
&& self.repos().at_root(path).is_none()
{
let repo = self
.external_log_repos
.read()
.iter()
.find(|r| r.root() == path)
.cloned()
.ok_or_else(|| IdeError::unavailable("external Git log is not open"))?;
return f(&repo);
}
self.with_repo_at(at, f)
}
pub fn with_log_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if self.repos().for_path(path).is_some() {
return self.with_repo_of(path, f);
}
let repo = self
.external_log_repos
.read()
.iter()
.filter(|r| path.starts_with(r.root()))
.max_by_key(|r| r.root().components().count())
.cloned()
.ok_or_else(|| IdeError::unavailable("path is not in an open Git log"))?;
f(&repo)
}
/// Runs `f` with the repository `path` (a file or folder) belongs to.
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
match self.repos().for_path(path) {
@@ -1011,7 +955,7 @@ impl Project {
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
}
let bytes = self.with_log_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
Ok(ide_vfs::detect_encoding(&bytes, fallback))
}
+1 -1
Переглянути файл
@@ -188,7 +188,7 @@ impl Dispatcher {
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
})
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
.add("fs_set_writable", |c| async move {
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
})
-9
Переглянути файл
@@ -222,7 +222,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "project_current"
| "project_roots"
| "index_status"
| "index_file_extensions"
| "jvm_file_presentations"
| "deploy_coverage"
| "deploy_test_connection"
@@ -308,14 +307,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_blame"
| "vcs_block_history"
| "vcs_log"
| "vcs_log_refs"
| "vcs_log_users"
| "vcs_log_details"
| "vcs_log_details_batch"
| "vcs_log_index"
| "vcs_revision_children"
| "vcs_revision_files"
| "vcs_open_external_log"
| "vcs_commit_files"
| "vcs_commits_changes"
| "vcs_branches"
-115
Переглянути файл
@@ -1,115 +0,0 @@
//! Explicit external logs allow history reads without adding a writable project root.
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops::vcs};
use std::{path::Path, process::Command, sync::Arc};
fn git(root: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.current_dir(root)
.args(["-c", "user.name=Test", "-c", "[email protected]", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_history_is_readable_and_never_becomes_a_write_target() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
let external = base.join("external");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&external).unwrap();
git(&external, &["init", "-b", "main"]);
let deleted = external.join("deleted.txt");
std::fs::write(&deleted, "historical content\n").unwrap();
git(&external, &["add", "."]);
git(&external, &["commit", "-m", "initial"]);
let first = git(&external, &["rev-parse", "HEAD"]);
git(&external, &["rm", "deleted.txt"]);
git(&external, &["commit", "-m", "delete"]);
let head = git(&external, &["rev-parse", "HEAD"]);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
// A standalone editor needs no project, indexer, or writable VCS root.
vcs::vcs_open_external_log(&host, "standalone", external.clone()).await.unwrap();
let standalone = vcs::vcs_log(&host, "standalone", Some(external.clone()), Default::default()).await.unwrap();
assert_eq!(standalone[0].id, head);
let details = vcs::vcs_log_details_batch(
&host,
"standalone",
Some(external.clone()),
vec![head.clone(), first.clone()],
ide_core::RequestId(987),
)
.await
.unwrap();
assert!(details.iter().all(|d| d.signature == "N"));
assert_eq!(
vcs::vcs_revision_children(&host, "standalone", Some(external.clone()), first.clone(), String::new())
.await
.unwrap(),
vec!["deleted.txt"]
);
assert!(host.project("standalone").is_err());
host.trust.decide(&external, false, false).unwrap();
let restricted = host.open_project("restricted", &external).unwrap();
assert!(!restricted.is_trusted());
vcs::vcs_open_external_log(&host, "restricted", external.clone()).await.unwrap();
let restricted_log = vcs::vcs_log(
&host,
"restricted",
Some(external.clone()),
ide_vcs::LogQuery { request: Some(ide_core::RequestId(988)), summary_only: true, ..Default::default() },
)
.await
.unwrap();
assert_eq!(restricted_log[0].id, head);
assert_eq!(
vcs::vcs_log_details_batch(
&host,
"restricted",
Some(external.clone()),
vec![head.clone()],
ide_core::RequestId(989)
)
.await
.unwrap()[0]
.signature,
"N"
);
host.close_project("restricted");
let payload =
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
let document =
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
assert_eq!(document.text, "historical content\n");
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
vcs::vcs_open_external_log(&host, "main", external.clone()).await.unwrap();
let log = vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.unwrap();
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![head.as_str(), first.as_str()]);
assert_eq!(
vcs::vcs_file_at(&host, "main", deleted.clone(), Some(first.clone())).await.unwrap().as_deref(),
Some("historical content\n")
);
assert!(project.repos().at_root(&external).is_none());
assert!(vcs::vcs_checkout(&host, "main", Some(external.clone()), first.clone(), false, None).await.is_err());
assert!(vcs::vcs_apply_patch(&host, "main", Some(external.clone()), String::new()).await.is_err());
assert_eq!(git(&external, &["rev-parse", "HEAD"]), head);
assert!(!deleted.exists());
host.close_project("main");
}
+2 -282
Переглянути файл
@@ -1,7 +1,6 @@
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename,
//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a
//! file — on its `fixtures/` project, through the `ops` the app's commands call and the
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename and
//! formatting — on its `fixtures/` project, through the `ops` the app's commands call and the
//! language server the app starts by default.
//!
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
@@ -69,35 +68,6 @@ struct Checks {
indent: Indent,
/// Renaming the symbol to the new name edits these files.
rename: (At, &'static str, &'static [&'static str]),
/// Parameter info in a call being typed: a signature naming this parameter.
signature: (Typed, &'static str),
/// The Structure view of a file: these symbols, at any depth.
outline: (&'static str, &'static [&'static str]),
/// Completing a name the file doesn't import yet also imports it.
auto_import: AutoImport,
/// Optimize Imports, with an import typed after a line.
organize_imports: Option<(Typed, Imports)>,
/// Moving a file updates its references: (file, new path, a file that then contains this).
file_move: Option<(&'static str, &'static str, &'static str, &'static str)>,
}
#[derive(Clone, Copy, Debug)]
struct AutoImport {
/// The line typed, ending in the start of the name.
typed: Typed,
/// The completion item.
label: &'static str,
/// What the item's import adds to the file.
result: &'static str,
}
/// What Optimize Imports does with the typed import.
#[derive(Clone, Copy, Debug)]
enum Imports {
/// Removes it (unused).
Removed,
/// Sorts it before this line.
Before(&'static str),
}
#[derive(Clone, Copy, Debug)]
@@ -462,146 +432,6 @@ impl Smoke {
}
}
async fn signature(&self, call: Typed, parameter: &str) {
let (doc, pos, before) = self.type_line(call).await;
self.retry("parameter info", self.warm_up, || async {
let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await;
match &found {
Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()),
_ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::<Vec<_>>()))),
}
})
.await;
self.restore(doc, &before).await;
}
async fn outline(&self, file: &str, symbols: &[&str]) {
let (doc, _) = self.doc(file).await;
fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet<String>) {
for item in items {
out.insert(item.name.clone());
names(&item.children, out);
}
}
self.retry("structure", self.warm_up, || async {
let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await;
let mut shown = BTreeSet::new();
if let Ok(items) = &found {
names(items, &mut shown);
}
if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) }
})
.await;
}
async fn auto_import(&self, a: AutoImport) {
let (doc, pos, before) = self.type_line(a.typed).await;
assert!(!before.contains(a.result), "{a:?}: the file already contains the result");
self.retry("auto-import completion", self.warm_up, || async {
let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await;
let items = match &list {
Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::<Vec<_>>(),
Err(e) => return Err(format!("{e}")),
};
let text = self.doc(a.typed.file).await.1;
let mut imports = Vec::new();
for item in items {
// Servers compute the import when the item is resolved (on selection).
let item = if item.additional_edits.is_empty() {
ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item)
} else {
item
};
let mut document = ide_text::Document::new(&text);
if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) {
return Ok(());
}
// Or accepting it runs a server command that edits the file (kotlin-lsp).
if let Some(command) = item.command.clone() {
let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await;
let text = self.doc(a.typed.file).await.1;
if ran.is_ok() && text.contains(a.result) {
return Ok(());
}
}
imports.push(format!("{:?} {:?}", item.additional_edits, item.command));
}
let labels: Vec<String> = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap();
Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label))
})
.await;
self.restore(doc, &before).await;
}
async fn organize_imports(&self, import: Typed, expect: Imports) {
let (doc, _, before) = self.type_line(import).await;
let typed = import.text.trim();
let organized = |text: &str| match expect {
Imports::Removed => !text.lines().any(|l| l.trim() == typed),
Imports::Before(line) => {
let at = |needle: &str| text.lines().position(|l| l.trim() == needle);
matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b)
}
};
self.retry("optimize imports", self.warm_up, || async {
let text = self.doc(import.file).await.1;
// The whole file, as Optimize Imports asks (positions past the end fail on jdtls).
let last = text.split('\n').count() as u32 - 1;
let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or(""))));
let kind = "source.organizeImports".to_string();
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await;
let actions: Vec<_> = match &found {
Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(),
Err(e) => return Err(format!("{e}")),
};
let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else {
return Err(format!(
"no {kind} action: {:?}",
found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())
));
};
ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?;
let text = self.doc(import.file).await.1;
if organized(&text) { Ok(()) } else { Err(text) }
})
.await;
self.restore(doc, &before).await;
}
async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) {
// The servers know the referencing file (opened, as in an editor).
let _ = self.doc(referencing).await;
let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }];
let edit = self
.retry("move file", self.warm_up, || async {
let found =
ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await;
let want = self.path(referencing);
match &found {
Ok(e)
if e.edit
.files
.iter()
.any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) =>
{
Ok(e.edit.clone())
}
_ => Err(found),
}
})
.await;
ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into())
.await
.unwrap_or_else(|e| self.fail("move file", e));
if !self.path(to).is_file() {
self.fail("move file", format_args!("{to} doesn't exist after the move"));
}
let text = self.doc(referencing).await.1;
if !text.contains(result) {
self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}"));
}
}
async fn run(&self, c: &Checks) {
self.definition(c.definition.0, c.definition.1).await;
self.references(c.references.0, c.references.1).await;
@@ -610,17 +440,7 @@ impl Smoke {
self.quick_fix(c.quick_fix).await;
self.intention(c.intention.0, c.intention.1).await;
self.format(c.format.0, c.indent, c.format.1).await;
self.signature(c.signature.0, c.signature.1).await;
self.outline(c.outline.0, c.outline.1).await;
self.auto_import(c.auto_import).await;
if let Some((import, expect)) = c.organize_imports {
self.organize_imports(import, expect).await;
}
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
// Last: it moves a file the other checks use.
if let Some((from, to, referencing, result)) = c.file_move {
self.file_move(from, to, referencing, result).await;
}
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
}
@@ -660,22 +480,6 @@ const TYPESCRIPT: Checks = Checks {
"formatPrice",
&["src/index.ts", "src/money.ts"],
),
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
outline: ("src/cart.ts", &["Cart", "add", "total"]),
auto_import: AutoImport {
typed: typed("src/index.ts", "const total = cart.total();", " addMon"),
label: "addMoney",
result: "addMoney",
},
organize_imports: Some((
typed(
"src/index.ts",
"import { cents, formatMoney } from \"./money.ts\";",
"import { readFileSync } from \"node:fs\";",
),
Imports::Removed,
)),
file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -717,19 +521,6 @@ const PYTHON: Checks = Checks {
"TaskBoard",
&["src/tasks/cli.py", "src/tasks/models.py"],
),
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
auto_import: AutoImport {
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"),
label: "datetime",
result: "import datetime",
},
organize_imports: Some((
typed("src/tasks/models.py", "from enum import IntEnum", "import abc"),
Imports::Before("import os"),
)),
// basedpyright has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -767,16 +558,6 @@ const RUST: Checks = Checks {
),
indent: Indent::Spaces(4),
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
auto_import: AutoImport {
typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"),
label: "HashSet",
result: "use std::collections::HashSet;",
},
// rust-analyzer has no `source.organizeImports`.
organize_imports: None,
file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -816,16 +597,6 @@ const GO: Checks = Checks {
"Open",
&["cmd/server/main.go", "internal/store/store.go"],
),
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
auto_import: AutoImport {
typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"),
label: "ToUpper",
result: "\t\"strings\"",
},
organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)),
// gopls renames packages, not files: moving a Go file changes no references.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -882,37 +653,6 @@ const JAVA: Checks = Checks {
"submit",
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
),
signature: (
typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" book.place(",
),
"customer",
),
outline: (
"src/main/java/com/example/orders/OrderBook.java",
&["OrderBook", "place(String, String, int, long)", "revenueCents()"],
),
auto_import: AutoImport {
typed: typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" ArrayLis",
),
label: "ArrayList",
result: "import java.util.ArrayList;",
},
organize_imports: Some((
typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"),
Imports::Removed,
)),
file_move: Some((
"src/main/java/com/example/orders/OrderBook.java",
"src/main/java/com/example/orders/Ledger.java",
"src/main/java/com/example/orders/App.java",
"Ledger book",
)),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -965,26 +705,6 @@ const KOTLIN: Checks = Checks {
"find",
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
),
signature: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
"isbn",
),
outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]),
auto_import: AutoImport {
typed: typed(
"src/main/kotlin/com/example/library/App.kt",
"val library = Library()",
" val stamp = LocalDateTi",
),
label: "LocalDateTime",
result: "import java.time.LocalDateTime",
},
organize_imports: Some((
typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"),
Imports::Removed,
)),
// kotlin-lsp has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-77
Переглянути файл
@@ -2045,83 +2045,6 @@ fn pinned_programs_fill_views_and_open_terminals() {
assert_eq!(args, ["follow", "build", "--lines=5"]);
}
#[test]
fn views_open_project_files_at_a_line() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
let press = |button: &str| {
f.take_events();
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
_ => None,
});
(result, opened)
};
let (result, opened) = press("log");
assert!(result.is_ok(), "{result:?}");
let (client, path, line, column) = opened.expect("a location");
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
let (result, opened) = press("outside");
assert!(result.is_err(), "a path outside the project is refused");
assert!(opened.is_none());
f.host.set_capability("jobs", "project.read", false).unwrap();
let (result, opened) = press("log");
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
assert!(opened.is_none());
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
let package = |dir: &str, icon: &str, svg: &str| {
let pkg = tmp.path().join(dir);
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
std::fs::write(pkg.join(path), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "panel-icon"
name = "Panel Icon"
version = "1.0.0"
engines.openidea = "^1.0"
[[contributes.views]]
id = "panel"
title = "Panel"
icon = "{icon}"
location = "bottom"
web = "panel/index.html"
"#
),
)
.unwrap();
pkg
};
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
let info = f.install(&package("clean", "icons/panel.svg", clean));
assert!(info.error.is_none(), "{:?}", info.error);
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<rect"));
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
-16
Переглянути файл
@@ -141,19 +141,3 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let text = "allprojects { }\n".to_string();
let path =
ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text.clone()).await.unwrap();
assert!(path.contains("openidea-gradle-tests-"), "{path}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
let again = ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text).await.unwrap();
assert_eq!(again, path);
// A name is not a path.
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
assert!(escaped.is_err(), "{escaped:?}");
}
-100
Переглянути файл
@@ -1,100 +0,0 @@
use ide_core::{NullSink, RequestId};
use ide_host::{Host, HostConfig, ops};
use std::{sync::Arc, time::Duration};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_index() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("Game");
for name in ["Assets/Scripts", "Packages", "ProjectSettings", "Library/PackageCache", "Temp", "Logs"] {
std::fs::create_dir_all(root.join(name)).unwrap();
}
std::fs::write(root.join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 6000.0.62f1\n").unwrap();
std::fs::write(root.join("Assets/Scripts/Player.cs"), "class Player {}\n").unwrap();
std::fs::write(root.join("Packages/manifest.json"), "{}\n").unwrap();
std::fs::write(root.join("Library/PackageCache/Cached.cs"), "class Cached {}\n").unwrap();
std::fs::write(root.join("Temp/Temporary.cs"), "class Temporary {}\n").unwrap();
std::fs::write(root.join("Logs/Editor.log"), "log\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
let info = ops::project::info(&project);
assert_eq!(info.name, "Game");
let unity = info.unity.unwrap();
assert_eq!(unity.editor_version.as_deref(), Some("6000.0.62f1"));
assert!(unity.projects.is_empty() && unity.solutions.is_empty());
assert!(project.index.wait_idle(Duration::from_secs(20)));
assert_eq!(project.index.find_files("Player.cs", 100).len(), 1);
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
}
let hits = Arc::new(std::sync::Mutex::new(Vec::new()));
let out = hits.clone();
let query = serde_json::from_value(serde_json::json!({
"pattern": "class", "isRegex": false, "caseSensitive": false, "wholeWord": false,
"include": [], "exclude": [], "scope": { "kind": "project" }, "maxMatches": 100,
}))
.unwrap();
ops::search::start(
&host,
"main",
RequestId::next(),
query,
Arc::new(move |event| {
if let ops::search::SearchEvent::File { file } = event {
out.lock().unwrap().push(file.rel_path);
}
}),
)
.await
.unwrap();
assert_eq!(*hits.lock().unwrap(), ["Assets/Scripts/Player.cs"]);
let listing = ops::fs::read_dir(&host, "main", root.clone()).await.unwrap();
assert!(listing.iter().any(|e| e.name == "Library" && e.excluded));
assert!(project.exclude_globs().contains(&"/Obj".to_owned()));
// Project metadata refreshes without reopening when Unity generates its solution.
std::fs::write(root.join("Game.sln"), "").unwrap();
std::fs::write(root.join("Assembly-CSharp.csproj"), "").unwrap();
let unity = ops::project::info(&project).unity.unwrap();
assert_eq!(unity.solutions, ["Game.sln"]);
assert_eq!(unity.projects, ["Assembly-CSharp.csproj"]);
// Desktop commands and remote RPC share these host operations.
let original = root.join("Assets/Scripts/Player.cs");
let renamed = root.join("Assets/Scripts/Hero.cs");
let copied = root.join("Assets/Scripts/HeroCopy.cs");
let meta = |path: &std::path::Path| -> std::path::PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".meta");
name.into()
};
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
std::fs::write(meta(&original), original_metadata).unwrap();
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
assert!(!original.exists() && !meta(&original).exists());
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
assert!(copied.exists());
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
host.close_project("main");
let ordinary = base.join("ConsoleApp");
std::fs::create_dir(&ordinary).unwrap();
std::fs::write(ordinary.join("App.csproj"), "").unwrap();
let project = host.open_project("main", &ordinary).unwrap();
assert!(ops::project::info(&project).unity.is_none());
assert!(!project.exclude_globs().contains(&"/Library".to_owned()));
host.close_project("main");
}
-209
Переглянути файл
@@ -1,209 +0,0 @@
//! A day with Git on a `fixtures/` project, through the `ops` the app's commands call: an edit
//! marked in the gutter, Local Changes and its diff, a commit in the log, a branch, a stash, and
//! a merge that conflicts, resolved and committed. Open documents follow every step.
use ide_core::DocId;
use ide_host::{Host, HostConfig, ops};
use ide_vcs::{ChangeKind, CommitOptions, FileStatus, HunkKind, LogQuery};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
const WINDOW: &str = "main";
/// The line every branch changes, in `src/lib.rs`.
const LINE: &str = "pub fn format_price(cents: u64) -> String {";
fn git(root: &Path, args: &[&str]) -> String {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
struct Flow {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
impl Flow {
async fn new(fixture: &str) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
git(&root, &["init", "-q", "-b", "main"]);
for (key, value) in [
("user.name", "Test"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git(&root, &["config", key, value]);
}
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "-m", "Initial commit"]);
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
let project = ops::project::open(&host, WINDOW, &root).await.unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
Flow { host, root, _tmp: tmp }
}
async fn open(&self, file: &str) -> DocId {
ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap().id
}
async fn text(&self, doc: DocId) -> String {
ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text
}
/// Types `with` over `LINE` in the editor (unsaved).
async fn edit_line(&self, doc: DocId, with: &str) {
let text = self.text(doc).await;
let current = text.lines().find(|l| l.starts_with("pub fn format_price")).unwrap().to_owned();
let project = self.host.project(WINDOW).unwrap();
project.documents.replace_all(doc, &text.replacen(&current, with, 1)).unwrap();
}
async fn save(&self, doc: DocId) {
ops::doc::save(&self.host, WINDOW, doc).await.unwrap();
}
async fn status(&self) -> Vec<FileStatus> {
ops::vcs::status(&self.host, WINDOW).await.unwrap()
}
async fn commit(&self, files: &[&str], message: &str) -> String {
let paths = files.iter().map(|f| self.root.join(f)).collect();
ops::vcs::vcs_stage(&self.host, WINDOW, paths).await.unwrap();
let options = CommitOptions { run_hooks: true, ..Default::default() };
ops::vcs::vcs_commit(&self.host, WINDOW, None, message.into(), options).await.unwrap()
}
async fn head(&self) -> ide_vcs::Commit {
let log = ops::vcs::vcs_log(&self.host, WINDOW, None, LogQuery::default()).await.unwrap();
log.into_iter().next().expect("a commit")
}
}
fn changed<'a>(status: &'a [FileStatus], file: &str) -> Option<&'a FileStatus> {
status.iter().find(|s| s.rel_path == file)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn change_commit_branch_stash_and_merge_a_conflict() {
let f = Flow::new("rust-app").await;
let lib_path = f.root.join("src/lib.rs");
let lib = f.open("src/lib.rs").await;
let original = f.text(lib).await;
let line = original.lines().position(|l| l == LINE).expect("the fixture's line") as u32;
// An edit in the editor: a modified line in the gutter, against HEAD, before saving.
let base = "pub fn format_price(cents: u64) -> String { // base";
f.edit_line(lib, base).await;
let hunks = ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap();
assert!(
matches!(&hunks[..], [h] if h.kind == HunkKind::Modified && h.start == line && h.old_text.contains(LINE)),
"{hunks:?}"
);
assert!(f.status().await.is_empty(), "nothing saved yet");
// Saved: Local Changes lists it, and its diff shows the edit.
f.save(lib).await;
let status = f.status().await;
assert_eq!(changed(&status, "src/lib.rs").and_then(|s| s.unstaged), Some(ChangeKind::Modified), "{status:?}");
let diff = ops::vcs::vcs_diff_text(&f.host, WINDOW, lib_path.clone(), false).await.unwrap();
assert!(diff.contains(&format!("-{LINE}")) && diff.contains(&format!("+{base}")), "{diff}");
// Commit: the log's head; nothing left to commit; no gutter marks.
let id = f.commit(&["src/lib.rs"], "Mark the base").await;
let head = f.head().await;
assert_eq!((head.id.as_str(), head.summary.as_str()), (id.as_str(), "Mark the base"));
assert!(f.status().await.is_empty());
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
// A branch changes the line its way.
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
f.edit_line(lib, theirs).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Feature's change").await;
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
assert!(branches.iter().any(|b| b.name == "feature" && b.current), "{branches:?}");
// Back on main, the open file shows main's text.
ops::vcs::vcs_checkout(&f.host, WINDOW, None, "main".into(), false, None).await.unwrap();
assert!(f.text(lib).await.contains(base));
// Stash: work in progress put aside and brought back.
let main_rs = f.open("src/main.rs").await;
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
f.save(main_rs).await;
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
assert!(f.status().await.is_empty());
assert!(!f.text(main_rs).await.contains("work in progress"));
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
assert!(matches!(&stashes[..], [s] if s.message.contains("wip")), "{stashes:?}");
ops::vcs::vcs_stash_apply(&f.host, WINDOW, None, 0, true, false, None).await.unwrap();
assert_eq!(f.text(main_rs).await, wip);
assert!(ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap().is_empty());
ops::vcs::vcs_discard(&f.host, WINDOW, vec![f.root.join("src/main.rs")]).await.unwrap();
assert!(f.status().await.is_empty());
// Main changes the same line: merging the branch conflicts.
let ours = "pub fn format_price(cents: u64) -> String { // main";
f.edit_line(lib, ours).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Main's change").await;
let merged = ops::vcs::vcs_merge(&f.host, WINDOW, None, "feature".into()).await;
let status = f.status().await;
let conflicted = changed(&status, "src/lib.rs").map(|s| (s.staged, s.unstaged));
assert!(
matches!(conflicted, Some((Some(ChangeKind::Conflicted), _) | (_, Some(ChangeKind::Conflicted)))),
"merge: {merged:?}; status: {status:?}"
);
// The open file shows the conflict as Git left it.
assert!(f.text(lib).await.contains("<<<<<<<"), "{}", f.text(lib).await);
let versions = ops::vcs::vcs_conflict_versions(&f.host, WINDOW, lib_path.clone()).await.unwrap();
let has = |side: &Option<String>, line: &str| side.as_deref().is_some_and(|t| t.contains(line));
assert!(has(&versions.base, base) && has(&versions.ours, ours) && has(&versions.theirs, theirs), "{versions:?}");
// Resolved (Accept Theirs) and committed: a merge commit, nothing left.
let resolved = versions.theirs.clone().unwrap();
f.host.project(WINDOW).unwrap().documents.replace_all(lib, &resolved).unwrap();
f.save(lib).await;
f.commit(&["src/lib.rs"], "Merge feature").await;
let head = f.head().await;
assert_eq!(head.parents.len(), 2, "{head:?}");
assert!(f.status().await.is_empty());
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
}
-63
Переглянути файл
@@ -175,66 +175,3 @@ async fn contended_version_reads_do_not_block_the_async_worker() {
assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_actions_skip_a_command_no_server_provides_after_their_edit() {
use ide_lsp::types::{CodeAction, Command};
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("imports.txt");
std::fs::write(&file, "unused\nused\n").unwrap();
let host = Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local-data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.open_light("main", &file).unwrap();
let info = ops::doc::open(&host, "main", file.clone()).await.unwrap();
// Optimize Imports as vtsls offers it: the edit, then a command its client runs itself.
let action = |edit: bool| CodeAction {
title: "Organize Imports".into(),
kind: Some("source.organizeImports".into()),
is_preferred: false,
disabled_reason: None,
edit: edit.then(|| {
serde_json::from_value(json!({ "files": [{ "kind": "edit", "path": file, "edits": [
{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 1, "col": 0 } }, "new_text": "" }
] }] }))
.unwrap()
}),
command: Some(Command {
title: String::new(),
command: "_typescript.didOrganizeImports".into(),
arguments: vec![],
}),
data: None,
server: ide_core::ServerId(1),
};
let follow_up = action(true).command.unwrap();
let asked = ops::lsp::lsp_resolve_command(
&host,
"main",
ide_core::RequestId(1),
info.id,
ide_core::ServerId(1),
follow_up,
vec![],
)
.await;
assert!(matches!(asked, Ok(None)), "{asked:?}");
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(2), info.id, action(true), vec![])
.await
.unwrap();
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "used\n");
ops::lsp::lsp_apply_code_action(&host, "main", info.id, action(true)).await.unwrap();
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "");
// Without an edit the command is the action: it still can't run.
let ran =
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(3), info.id, action(false), vec![]).await;
assert!(ran.is_err(), "{ran:?}");
}
-51
Переглянути файл
@@ -114,54 +114,3 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn php_language_level_follows_the_member_of_the_file() {
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let (api, legacy) = (base.join("api"), base.join("legacy"));
for (member, composer) in [
(&api, r#"{"require": {"php": "^7.4"}}"#),
(&legacy, r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.0.5"}}}"#),
] {
std::fs::create_dir(member).unwrap();
std::fs::write(member.join("composer.json"), composer).unwrap();
}
let root = base.join("workspace");
Workspace::save(&root, "PHP", vec![api.clone(), legacy.clone()], true).unwrap();
std::fs::write(root.join("composer.json"), r#"{"require": {"php": "^8.2"}}"#).unwrap();
let config_dir = base.join("config");
ide_workspace::SettingsStore::load(&config_dir, Some(&legacy))
.set(ide_workspace::SettingsScope::Project, "languages.php.languageLevel", "8.3".into())
.unwrap();
let host = Host::new(HostConfig {
config_dir,
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let level = async |path: std::path::PathBuf| ops::sdk::sdk_php_language_level(&host, "main", path).await.unwrap();
let level_of_api = level(api.join("src/Invoice.php")).await;
assert_eq!(level_of_api.effective, found("7.4.0", PhpLevelSource::Require));
assert_eq!(level_of_api.auto, level_of_api.effective);
assert_eq!(level_of_api.member.as_deref(), Some(api.as_path()));
// The member's own settings, not the workspace's.
let level_of_legacy = level(legacy.join("index.php")).await;
assert_eq!(level_of_legacy.effective, found("8.3.0", PhpLevelSource::Settings));
assert_eq!(level_of_legacy.auto, found("8.0.5", PhpLevelSource::Platform));
// Outside every member: the workspace's own.
let level_elsewhere = level(base.join("elsewhere.php")).await;
assert_eq!(level_elsewhere.effective, found("8.2.0", PhpLevelSource::Require));
assert_eq!(level_elsewhere.member, None);
host.close_project("main");
}
-30
Переглянути файл
@@ -8,7 +8,6 @@ use ide_language::{LanguageRegistry, Syntax};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
@@ -65,14 +64,6 @@ pub struct IndexStatus {
pub symbols: u32,
}
/// How many indexed files share an extension (lower case, without the dot).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExtensionCount {
pub extension: String,
pub files: u32,
}
pub struct ProjectIndex {
shared: Arc<Shared>,
worker: Mutex<Option<JoinHandle<()>>>,
@@ -168,27 +159,6 @@ impl ProjectIndex {
}
}
/// The extensions of the indexed files, most common first: the project's file
/// types, for suggestions such as Find in Files' file masks. Dotfiles
/// (`.gitignore`) have no extension.
pub fn file_extensions(&self) -> Vec<ExtensionCount> {
let d = self.shared.data.read();
let mut counts: HashMap<String, u32> = HashMap::new();
for file in d.files.iter().flatten() {
let name = &file.rel[file.name_start as usize..];
if let Some((stem, extension)) = name.rsplit_once('.')
&& !stem.is_empty()
&& !extension.is_empty()
{
*counts.entry(extension.to_ascii_lowercase()).or_default() += 1;
}
}
let mut out: Vec<ExtensionCount> =
counts.into_iter().map(|(extension, files)| ExtensionCount { extension, files }).collect();
out.sort_by(|a, b| b.files.cmp(&a.files).then_with(|| a.extension.cmp(&b.extension)));
out
}
/// Fuzzy file search. IDEA-like ranking: file-name matches beat directory
/// matches; `/` in the query matches path segments; recently opened files boost.
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
-22
Переглянути файл
@@ -111,28 +111,6 @@ fn indexes_files_and_symbols_honouring_excludes() {
assert_eq!(m.path, fx.root.join("src/main.rs"));
}
#[test]
fn counts_file_extensions_most_common_first() {
let fx = Fixture::new(&[
("src/a.rs", "fn a() {}\n"),
("src/b.RS", "fn b() {}\n"),
("src/lib.rs", "fn c() {}\n"),
("Cargo.toml", "[package]\n"),
("README.md", "# r\n"),
("docs/guide.md", "# g\n"),
(".gitignore", "target\n"),
("Makefile", "all:\n"),
("target/debug/gen.rs", "fn generated() {}\n"),
]);
let idx = fx.open();
let counts: Vec<(String, u32)> = idx.file_extensions().into_iter().map(|c| (c.extension, c.files)).collect();
assert_eq!(
counts,
[("rs".into(), 3), ("md".into(), 2), ("toml".into(), 1)],
"case-folded, excluded folders and extension-less files left out"
);
}
#[test]
fn file_ranking_is_idea_like() {
let fx = sample();
+1 -43
Переглянути файл
@@ -23,7 +23,6 @@ mod scope;
mod server;
mod tcp;
mod text;
mod ts_file_moves;
mod tsserver_bridge;
pub mod types;
mod uri;
@@ -1693,20 +1692,6 @@ impl LspManager {
Ok(())
}
/// Whether a server of `doc` (`server`, when given) provides the command `name`. Code actions
/// can carry commands other editors' extensions run themselves after the edit (vtsls'
/// `_typescript.didOrganizeImports`): no server provides those.
pub async fn provides_command(&self, doc: DocId, server: Option<ide_core::ServerId>, name: &str) -> bool {
let servers = match self.snapshot(doc) {
Ok(snap) => snap.servers,
Err(_) => self.servers.lock().clone(),
};
self.ready_targets(&servers, |c| c.commands().iter().any(|c| c == name))
.await
.iter()
.any(|t| server.is_none_or(|s| t.server.id == s))
}
async fn command_target(&self, doc: DocId, server: ide_core::ServerId, name: &str) -> IdeResult<Target> {
let snap = self.snapshot(doc)?;
self.ready_targets(&snap.servers, |c| c.commands().iter().any(|c| c == name))
@@ -1725,10 +1710,6 @@ impl LspManager {
answers: Vec<FormAnswer>,
cancel: CancellationToken,
) -> IdeResult<Option<CommandForm>> {
// Nothing to ask for a command its server doesn't provide (executing it decides).
if !self.provides_command(doc, Some(server), &command.command).await {
return Ok(None);
}
let t = self.command_target(doc, server, &command.command).await?;
let interactive = t.server.name == "gopls"
&& t.caps()
@@ -2011,30 +1992,7 @@ impl LspManager {
servers.iter().zip(ready).filter_map(|(s, run)| Some(Target { server: s.clone(), run: run? })).collect();
let mut out = FileRenameEdits::default();
for t in targets {
let Some(params) = Self::file_op_params(&t, "willRename", &moves) else {
if t.server.config.tsserver_project && t.caps().commands().iter().any(|c| c == ts_file_moves::COMMAND) {
out.servers.push(t.server.name.clone());
let opened = self.open_for_file_op(&t, &moves);
for (from, to, _) in &moves {
let r: IdeResult<Value> = self
.call(
&t,
"workspace/executeCommand",
ts_file_moves::request(from, to),
Some(&cancel),
LONG_TIMEOUT,
)
.await;
match r {
Ok(result) => out.edit.files.extend(ts_file_moves::edit(&result).files),
Err(e) if e.kind == ErrorKind::Cancelled && cancel.is_cancelled() => return Err(e),
Err(e) => out.errors.push(e.message),
}
}
drop(opened);
}
continue;
};
let Some(params) = Self::file_op_params(&t, "willRename", &moves) else { continue };
out.servers.push(t.server.name.clone());
let opened = self.open_for_file_op(&t, &moves);
let r: IdeResult<Option<lsp::WorkspaceEdit>> =
-89
Переглянути файл
@@ -1,89 +0,0 @@
//! Reference updates for moved files from vtsls, which has no `workspace/willRenameFiles`:
//! it updates imports itself after `didRenameFiles`, behind a prompt the client answers. The
//! IDE asks tsserver for the same edits up front (`getEditsForFileRename`, through
//! `typescript.tsserverRequest`), so they join the move's preview and undo, and the server's
//! own update is off (`updateImportsOnFileMove.enabled = "never"` in `languages/*.toml`).
use ide_core::{FileEdit, Pos, Range, TextEdit, WorkspaceEdit};
use serde::Deserialize;
use serde_json::{Value, json};
use std::path::PathBuf;
pub(crate) const COMMAND: &str = "typescript.tsserverRequest";
/// The `typescript.tsserverRequest` arguments asking for the edits of moving `from` to `to`.
pub(crate) fn request(from: &std::path::Path, to: &std::path::Path) -> Value {
json!({
"command": COMMAND,
"arguments": ["getEditsForFileRename", { "oldFilePath": from, "newFilePath": to }],
})
}
#[derive(Deserialize)]
struct Location {
line: u32,
offset: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Change {
start: Location,
end: Location,
new_text: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct FileChanges {
file_name: PathBuf,
text_changes: Vec<Change>,
}
/// tsserver's `FileCodeEdits[]` (the command's `body`; 1-based lines and UTF-16 offsets) as an edit.
pub(crate) fn edit(result: &Value) -> WorkspaceEdit {
let body = result.get("body").unwrap_or(result);
let files: Vec<FileChanges> = serde_json::from_value(body.clone()).unwrap_or_default();
let pos = |l: &Location| Pos::new(l.line.saturating_sub(1), l.offset.saturating_sub(1));
WorkspaceEdit {
files: files
.into_iter()
.filter(|f| !f.text_changes.is_empty())
.map(|f| FileEdit::Edit {
path: f.file_name.into(),
edits: f
.text_changes
.into_iter()
.map(|c| TextEdit { range: Range::new(pos(&c.start), pos(&c.end)), new_text: c.new_text })
.collect(),
version: None,
})
.collect(),
preconditions: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_tsserver_file_code_edits() {
let result = json!({ "success": true, "body": [
{ "fileName": "/p/src/index.ts", "textChanges": [
{ "start": { "line": 2, "offset": 31 }, "end": { "line": 2, "offset": 41 }, "newText": "./lib/money.ts" }
] },
{ "fileName": "/p/src/unchanged.ts", "textChanges": [] }
] });
let edit = edit(&result);
assert_eq!(edit.files.len(), 1);
let FileEdit::Edit { path, edits, version } = &edit.files[0] else { panic!("{edit:?}") };
assert_eq!(**path, *std::path::Path::new("/p/src/index.ts"));
assert_eq!(
edits,
&[TextEdit { range: Range::new(Pos::new(1, 30), Pos::new(1, 40)), new_text: "./lib/money.ts".into() }]
);
assert!(version.is_none());
assert!(super::edit(&json!({ "success": false })).files.is_empty());
}
}
-35
Переглянути файл
@@ -2336,41 +2336,6 @@ async fn will_and_did_rename_files_honour_filters() {
);
}
#[tokio::test(flavor = "multi_thread")]
async fn will_rename_files_asks_tsserver_when_the_server_has_no_file_operations() {
// vtsls: no `willRename` filters, but tsserver's `getEditsForFileRename` through its command.
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] });
let h = harness_configured("typescript", "ts", &[("vtsls", caps)], |cfg| cfg.tsserver_project = true);
let money = h.root.join("money.ts");
let index = h.root.join("index.ts");
std::fs::write(&money, "export const one = 1;\n").unwrap();
std::fs::write(&index, "import { one } from \"./money\";\n").unwrap();
let s = h.launcher.state("vtsls");
*s.responder.lock() = Some(Arc::new(move |_, method, p| {
(method == "workspace/executeCommand" && p["arguments"][0] == "getEditsForFileRename").then(|| {
json!({ "success": true, "body": [{ "fileName": p["arguments"][1]["oldFilePath"].as_str().unwrap().replace("money.ts", "index.ts"),
"textChanges": [{ "start": { "line": 1, "offset": 22 }, "end": { "line": 1, "offset": 29 }, "newText": "./lib/money" }] }] })
})
}));
let to = h.root.join("lib/money.ts");
let result =
h.mgr.will_rename_files(&[(money.clone(), to.clone())], ide_core::CancellationToken::new()).await.unwrap();
assert_eq!(result.servers, vec!["vtsls".to_owned()]);
assert!(result.errors.is_empty(), "{:?}", result.errors);
match &result.edit.files[..] {
[FileEdit::Edit { path, edits, version: None }] => {
assert_eq!(path, &index);
assert_eq!(edits[0].range, rng(0, 21, 0, 28));
assert_eq!(edits[0].new_text, "./lib/money");
}
other => panic!("unexpected edit {other:?}"),
}
let asked = s.requested("workspace/executeCommand");
let asked = asked.iter().find(|r| r["arguments"][0] == "getEditsForFileRename").unwrap();
assert_eq!(asked["arguments"][1], json!({ "oldFilePath": money, "newFilePath": to }));
}
// ------------------------------------------------------------------ inspect code
struct InspectTestHost {
-1
Переглянути файл
@@ -105,7 +105,6 @@ pub trait HostApi: Send + Sync {
args: Vec<String>,
cwd: Option<&Path>,
) -> Result<(), HostError>;
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
/// A setting of the plugin as JSON (project over global over the schema default).
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
}
-15
Переглянути файл
@@ -640,21 +640,6 @@ impl views::Host for StoreData {
let title = clip(title, crate::runtime::MAX_TITLE);
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
}
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let Some(root) = self.invocation.workspace_root.clone() else {
return Ok(Err(wit::Error::Invalid("no project is open in this window".into())));
};
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
let client = self.invocation.client.clone();
Ok(self.host.open_location(client.as_deref(), &real, line, column).map_err(to_wit))
}
}
impl settings::Host for StoreData {
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
a50d1eb41ab8dd0c9c8d6bae8266906cd6eb5de8e6f6ca6f681990389472bb5b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
-1
Переглянути файл
@@ -12,7 +12,6 @@ activation = ["on-view:jobs", "on-command:*"]
[capabilities]
required = ["process:jobs"]
optional = ["project.read"]
[[contributes.views]]
id = "jobs"
-2
Переглянути файл
@@ -64,8 +64,6 @@ impl Views for Jobs {
return self.follow();
}
ViewEvent::Button(b) if b == "follow" => return self.follow(),
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
_ => return Ok(()),
}
host::refresh_view(VIEW);
+1 -1
Переглянути файл
@@ -1 +1 @@
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
+1 -41
Переглянути файл
@@ -143,10 +143,7 @@ impl Mappings {
}
let (l, rest, _) = best?;
// A `..` from the server must not leave the local folder.
if rest.split('/').filter(|s| !s.is_empty()).any(|s| {
let mut components = Path::new(s).components();
!matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some()
}) {
if rest.split('/').any(|s| s == ".." || s == ".") {
return None;
}
Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s)))
@@ -310,43 +307,6 @@ mod tests {
assert_eq!(local_folder("/src/", &root), Some(root.join("src")));
}
#[cfg(windows)]
#[test]
fn native_anchors_never_replace_the_project_relative_fallback() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let inside = root.join("src");
let outside = tmp.path().join("outside");
let missing = tmp.path().join("missing");
std::fs::create_dir_all(&inside).unwrap();
std::fs::create_dir(&outside).unwrap();
for slash in ["\\", "/"] {
let spelling = |path: &Path| path.to_string_lossy().replace('\\', slash);
assert_eq!(local_folder(&spelling(&inside), &root), Some(inside.clone()));
assert_eq!(local_folder(&spelling(&outside), &root), None);
assert_eq!(local_folder(&spelling(&missing), &root), Some(root.join(posix(&missing))));
assert_eq!(local_folder(&format!("{slash}src{slash}"), &root), Some(inside.clone()));
}
}
#[test]
fn remote_paths_cannot_replace_or_leave_a_local_mapping() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let m = Mappings::new(&server(vec![]), root, "/srv");
for remote in ["/srv/../outside", "/srv/./file"] {
assert_eq!(m.local(remote), None, "{remote}");
}
#[cfg(windows)]
for remote in
["/srv/..\\outside", "/srv/C:\\outside", "/srv/\\outside", "/srv/dir\\..\\outside", "/srv/C:/outside"]
{
assert_eq!(m.local(remote), None, "{remote}");
}
assert_eq!(m.local("/srv/dir/file"), Some(root.join("dir/file")));
assert_eq!(m.local("/srv"), Some(root.to_path_buf()));
}
#[cfg(unix)]
#[test]
fn absolute_symlink_inside_project_resolves_to_the_project_spelling() {
+2 -11
Переглянути файл
@@ -612,18 +612,9 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
// Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`).
if name == "intelephense"
&& let Some(level) = crate::php::language_level(root, s)
&& let Some(version) = crate::php::language_level(root, s)
{
merge_init_options(cfg, json!({ "environment": { "phpVersion": level.version } }));
}
if name == "intelephense" {
if let Some(stubs) = crate::php::intelephense_stubs(root, s) {
merge_init_options(cfg, json!({ "stubs": stubs }));
}
let include_paths = crate::php::include_paths(root, s);
if !include_paths.is_empty() {
merge_init_options(cfg, json!({ "environment": { "includePaths": include_paths } }));
}
merge_init_options(cfg, json!({ "environment": { "phpVersion": version } }));
}
// Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js.
+11 -177
Переглянути файл
@@ -1,53 +1,24 @@
//! The PHP language level: the one chosen in Settings › PHP, else the one the root
//! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows);
//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes.
//! and the Intelephense storage that goes with it.
use crate::apply::{merge, merge_init_options, setting_path};
use crate::apply::{merge, merge_init_options};
use ide_core::settings::Settings;
use ide_language::LanguageServerConfig;
use serde_json::{Value, json};
use std::path::Path;
/// A PHP version and where it comes from.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevel {
/// `X.Y.Z`.
pub version: String,
pub source: PhpLevelSource,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum PhpLevelSource {
/// `languages.php.languageLevel`.
Settings,
/// `config.platform.php` of `composer.json`.
Platform,
/// The lowest version `require.php` of `composer.json` allows.
Require,
}
/// The project's PHP version: the one chosen in the settings, else [`composer_level`];
/// `None` when neither names one.
pub fn language_level(root: &Path, s: &Settings) -> Option<PhpLanguageLevel> {
/// The project's PHP version as `X.Y.Z`; `None` when neither the settings nor
/// `composer.json` name one.
pub fn language_level(root: &Path, s: &Settings) -> Option<String> {
let explicit = s.languages.php.language_level.trim();
if !explicit.is_empty() {
return version(explicit).map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Settings));
return version(explicit).map(dotted);
}
composer_level(root)
}
/// The PHP version the root `composer.json` declares (the setting's Auto):
/// `config.platform.php`, else the lowest version `require.php` allows.
pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
let text = std::fs::read_to_string(root.join("composer.json")).ok()?;
let composer: Value = serde_json::from_str(&text).ok()?;
if let Some(platform) = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version) {
return Some(PhpLanguageLevel::new(platform, PhpLevelSource::Platform));
}
lowest_allowed(composer.pointer("/require/php")?.as_str()?)
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
let platform = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version);
platform.or_else(|| lowest_allowed(composer.pointer("/require/php")?.as_str()?)).map(dotted)
}
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
@@ -77,74 +48,10 @@ pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir:
merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() }));
}
/// The stub sets Intelephense loads when `stubs` is not set (Intelephense 1.18.5). Setting
/// `stubs` replaces them, so additions are sent together with these.
#[rustfmt::skip]
const DEFAULT_STUBS: &[&str] = &[
"apache", "bcmath", "bz2", "calendar", "com_dotnet", "Core", "ctype", "curl", "date", "dba", "dom", "enchant",
"exif", "fileinfo", "filter", "fpm", "ftp", "gd", "hash", "iconv", "imap", "intl", "json", "ldap", "libxml",
"mbstring", "mcrypt", "mssql", "mysqli", "oci8", "odbc", "openssl", "pcntl", "pcre", "PDO", "pgsql", "Phar",
"posix", "pspell", "random", "readline", "Reflection", "regex", "session", "shmop", "SimpleXML", "snmp", "soap",
"sockets", "sodium", "SPL", "sqlite3", "standard", "superglobals", "sybase", "sysvmsg", "sysvsem", "sysvshm",
"tidy", "tokenizer", "uri", "xml", "xmlreader", "xmlrpc", "xmlwriter", "Zend OPcache", "zip", "zlib",
];
/// Stub sets outside the defaults whose names are not lower case; the rest are the lower-case
/// extension name.
const MIXED_CASE_STUBS: &[&str] =
&["Ev", "FFI", "LuaSandbox", "Parle", "SQLite", "SaxonC", "SplType", "ZendCache", "ZendDebugger", "ZendUtils"];
/// `Zend OPcache`, `zend-opcache` and `zend_opcache` name the same stubs.
fn stub_key(name: &str) -> String {
name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}
/// The `stubs` Intelephense loads: its defaults plus a stub set for each `ext-*` that the root
/// `composer.json` requires (`require`, `require-dev`) and those in Settings › PHP. `None` when
/// that adds nothing to the defaults, so the server keeps its own.
pub fn intelephense_stubs(root: &Path, s: &Settings) -> Option<Vec<String>> {
let composer: Option<Value> =
std::fs::read_to_string(root.join("composer.json")).ok().and_then(|t| serde_json::from_str(&t).ok());
let extensions = ["require", "require-dev"]
.into_iter()
.filter_map(|section| composer.as_ref()?.get(section)?.as_object())
.flat_map(|deps| deps.keys())
.filter_map(|dep| dep.strip_prefix("ext-"))
.map(|ext| {
let key = stub_key(ext);
MIXED_CASE_STUBS
.iter()
.find(|stub| stub_key(stub) == key)
.map_or(ext.to_ascii_lowercase(), |s| s.to_string())
});
let configured = s.languages.php.stubs.iter().map(|stub| stub.trim().to_owned());
let mut stubs: Vec<String> = DEFAULT_STUBS.iter().map(|s| s.to_string()).collect();
let mut known: std::collections::HashSet<String> = stubs.iter().map(|s| stub_key(s)).collect();
for stub in extensions.chain(configured) {
if !stub.is_empty() && known.insert(stub_key(&stub)) {
stubs.push(stub);
}
}
(stubs.len() > DEFAULT_STUBS.len()).then_some(stubs)
}
/// The folders in Settings › PHP › Include paths, resolved against the project.
pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
s.languages
.php
.include_paths
.iter()
.filter_map(|p| setting_path(root, p))
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
type Version = (u32, u32, u32);
impl PhpLanguageLevel {
fn new((major, minor, patch): Version, source: PhpLevelSource) -> Self {
Self { version: format!("{major}.{minor}.{patch}"), source }
}
fn dotted((major, minor, patch): Version) -> String {
format!("{major}.{minor}.{patch}")
}
/// `7`, `7.4`, `v7.4.3`, `8.1.*`, `7.4.0-dev`: missing parts are 0.
@@ -198,18 +105,13 @@ mod tests {
use super::*;
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
levels(composer, explicit).0.map(|l| l.version)
}
/// The effective level and the composer one.
fn levels(composer: Option<&str>, explicit: &str) -> (Option<PhpLanguageLevel>, Option<PhpLanguageLevel>) {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer).unwrap();
}
let mut s = Settings::default();
s.languages.php.language_level = explicit.into();
(language_level(tmp.path(), &s), composer_level(tmp.path()))
language_level(tmp.path(), &s)
}
fn required(constraint: &str) -> Option<String> {
@@ -299,74 +201,6 @@ mod tests {
}
}
fn stubs(composer: Option<Value>, configured: &[&str]) -> Option<Vec<String>> {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer.to_string()).unwrap();
}
let mut s = Settings::default();
s.languages.php.stubs = configured.iter().map(|s| s.to_string()).collect();
intelephense_stubs(tmp.path(), &s)
}
fn added(stubs: Option<Vec<String>>) -> Vec<String> {
let stubs = stubs.expect("stubs");
assert_eq!(stubs[..DEFAULT_STUBS.len()], DEFAULT_STUBS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
stubs[DEFAULT_STUBS.len()..].to_vec()
}
#[test]
fn required_extensions_and_configured_stubs_follow_the_defaults() {
let composer = json!({
"require": {"php": "^8.1", "ext-redis": "*", "ext-json": "*", "monolog/monolog": "^3"},
"require-dev": {"ext-Imagick": "*", "ext-ffi": "*", "ext-redis": "*"}
});
assert_eq!(
added(stubs(Some(composer), &[" wordpress ", "Redis", ""])),
["redis", "imagick", "FFI", "wordpress"]
);
assert_eq!(added(stubs(None, &["wordpress"])), ["wordpress"]);
}
#[test]
fn nothing_beyond_the_defaults_sends_nothing() {
assert_eq!(stubs(None, &[]), None);
assert_eq!(stubs(Some(json!({"require": {"php": "^8.1"}})), &[]), None);
// Default stub sets under their extension names: `ext-pdo`, `ext-zend-opcache`, `ext-simplexml`.
let defaults =
json!({"require": {"ext-pdo": "*", "ext-zend-opcache": "*", "ext-simplexml": "*", "ext-json": "*"}});
assert_eq!(stubs(Some(defaults), &["SPL", "core"]), None);
assert_eq!(stubs(Some(json!("not an object")), &[]), None);
}
#[test]
fn include_paths_resolve_against_the_project() {
let root = Path::new("/work/module");
let mut s = Settings::default();
s.languages.php.include_paths = vec!["../platform".into(), "/opt/prestashop".into(), " ".into()];
assert_eq!(
include_paths(root, &s),
[root.join("../platform").to_string_lossy().into_owned(), "/opt/prestashop".to_string()]
);
}
#[test]
fn each_level_names_its_source() {
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let require = r#"{"require": {"php": "^7.4"}}"#;
let platform = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.2.1"}}}"#;
let require_level = found("7.4.0", PhpLevelSource::Require);
assert_eq!(levels(Some(require), ""), (require_level.clone(), require_level.clone()));
let platform_level = found("8.2.1", PhpLevelSource::Platform);
assert_eq!(levels(Some(platform), ""), (platform_level.clone(), platform_level));
// An explicit level: the composer one is still what Auto would pick.
assert_eq!(levels(Some(require), "8.1"), (found("8.1.0", PhpLevelSource::Settings), require_level));
assert_eq!(levels(None, "8.1"), (found("8.1.0", PhpLevelSource::Settings), None));
assert_eq!(levels(None, ""), (None, None));
assert_eq!(levels(Some(r#"{"require": {"php": "*"}}"#), ""), (None, None));
}
#[test]
fn explicit_level_wins() {
let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#;
-24
Переглянути файл
@@ -1325,30 +1325,6 @@ fn intelephense_gets_the_php_language_level() {
assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}}));
}
#[test]
fn intelephense_gets_the_required_stubs_and_include_paths() {
use serde_json::json;
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^8.1", "ext-redis": "*"}}"#).unwrap();
let mut s = Settings::default();
s.languages.php.stubs = vec!["wordpress".into()];
s.languages.php.include_paths = vec!["../platform".into()];
let mut cfg = server("intelephense");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
let stubs: Vec<&str> = options["stubs"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
assert_eq!(stubs.len(), 70);
assert_eq!((stubs[0], &stubs[68..]), ("apache", &["redis", "wordpress"][..]));
assert_eq!(
options["environment"],
json!({"phpVersion": "8.1.0", "includePaths": [tmp.path().join("../platform").to_string_lossy()]})
);
let mut other = server("phpactor");
configure_server(&mut other, tmp.path(), tmp.path(), &s);
assert_eq!(other.initialization_options, None);
}
#[test]
fn go_constraints_change_only_protocol_settings() {
use serde_json::json;
+7 -17
Переглянути файл
@@ -267,29 +267,19 @@ impl Session {
}),
)
.await?;
let failed = |code: Option<u32>| {
let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label))
};
// A command that exits early (no gzip, a folder it cannot write) stops reading: a
// write would wait for channel window forever, so each one races the exit.
let mut exit = std::pin::pin!(process.wait());
let mut sent = 0;
for chunk in packed.chunks(256 * 1024) {
tokio::select! {
written = process.write(chunk) => written?,
code = &mut exit => return Err(failed(code)),
}
process.write(chunk).await?;
sent += chunk.len();
progress(sent as f64 / packed.len().max(1) as f64);
}
tokio::select! {
closed = process.eof() => closed?,
code = &mut exit => return Err(failed(code)),
}
match exit.await {
process.eof().await?;
match process.wait().await {
Some(0) => Ok(()),
code => Err(failed(code)),
code => {
let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
Err(io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label)))
}
}
}
-23
Переглянути файл
@@ -269,26 +269,3 @@ async fn runs_commands_and_streams_processes() {
assert_eq!(process.wait().await, Some(3));
assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned()));
}
#[tokio::test]
async fn upload_reports_a_command_that_stops_reading() {
let env = env().await;
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
// Incompressible and larger than the channel window: the command exits (status 3, `oops`)
// without reading it, as when the host has no gzip or the folder is not writable.
let mut state = 0x9e37_79b9_7f4a_7c15_u64;
let data: Vec<u8> = (0..8 << 20)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state as u8
})
.collect();
let put = session.put("/home/dev/.openidea/server/openidea-server", &data);
let err = tokio::time::timeout(std::time::Duration::from_secs(20), put)
.await
.expect("the upload must end when the command exits")
.unwrap_err();
assert!(err.message.contains("oops"), "{}", err.message);
}
-2
Переглянути файл
@@ -15,8 +15,6 @@ serde_json.workspace = true
specta.workspace = true
gix = { workspace = true, features = ["sha1", "sha256", "status", "blob-diff", "revision", "index", "dirwalk", "parallel"] }
tracing.workspace = true
redb.workspace = true
blake3.workspace = true
tempfile.workspace = true
which = "8"
regex-lite = "0.1"
-192
Переглянути файл
@@ -1,192 +0,0 @@
//! Bek branch partitioning and bottom-up block merge.
//! Adapted from JetBrains intellij-community BekBranchCreator/BekBranch/Merger,
//! Copyright 2000-2026 JetBrains s.r.o. and contributors, Apache-2.0.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashSet};
pub(crate) fn sort(parents: &[Vec<usize>], times: &[i64], heads: &[usize]) -> Vec<usize> {
let n = parents.len();
let mut children = vec![Vec::new(); n];
for (i, ps) in parents.iter().enumerate() {
for &p in ps {
children[p].push(i);
}
}
let mut layout = vec![0usize; n];
let mut important = Vec::new();
let mut next_layout = 1;
for &head in heads {
if layout[head] != 0 {
continue;
}
important.push(head);
let mut stack = vec![head];
while let Some(&node) = stack.last() {
let first = layout[node] == 0;
if first {
layout[node] = next_layout;
}
if let Some(&p) = parents[node].iter().find(|&&p| layout[p] == 0) {
stack.push(p);
} else {
if first {
next_layout += 1;
}
stack.pop();
}
}
}
let mut done = vec![false; n];
let mut restrictions = vec![HashSet::new(); n];
let mut releases = vec![Vec::new(); n];
let mut branches = Vec::new();
for head in important {
if done[head] {
continue;
}
let start = layout[head];
done[head] = true;
let mut branch = vec![head];
let mut stack = vec![head];
while let Some(&node) = stack.last() {
let mut next = None;
for &p in parents[node].iter().rev() {
if done[p] {
if layout[p] < start && restrictions[node].insert(p) {
releases[p].push(node);
}
} else if layout[node] <= layout[p] && !children[p].iter().any(|&c| !done[c] && layout[c] <= layout[p])
{
next = Some(p);
break;
}
}
if let Some(p) = next {
done[p] = true;
branch.push(p);
stack.push(p);
} else {
stack.pop();
}
}
branches.push(branch);
}
let mut owner = vec![0usize; n];
for (b, nodes) in branches.iter().enumerate() {
for &node in nodes {
owner[node] = b;
}
}
let mut remaining: Vec<_> = branches.iter().map(Vec::len).collect();
let mut prepared = vec![None; branches.len()];
let mut queue = BinaryHeap::new();
let prepare = |b: usize,
remaining: &[usize],
restrictions: &[HashSet<usize>],
prepared: &mut [Option<usize>],
queue: &mut BinaryHeap<Reverse<(i64, usize)>>| {
let end = remaining[b];
if end == 0 || prepared[b].is_some() || !restrictions[branches[b][end - 1]].is_empty() {
return;
}
let mut start = end - 1;
while start > 0 {
let up = branches[b][start - 1];
let down = branches[b][start];
if !restrictions[up].is_empty() {
break;
}
let connects = parents[up].contains(&down);
if !(parents[up].len() > 1 && connects) {
if !connects {
break;
}
let delta = times[up].abs_diff(times[down]);
if delta > 3 * 24 * 3600 || (start + 20 < end && delta > 4 * 3600) {
break;
}
}
start -= 1;
}
prepared[b] = Some(start);
queue.push(Reverse((times[branches[b][start]], b)));
};
for b in 0..branches.len() {
prepare(b, &remaining, &restrictions, &mut prepared, &mut queue);
}
let mut result = vec![0; n];
let mut offset = n;
while let Some(Reverse((_, b))) = queue.pop() {
let start = prepared[b].take().unwrap();
let part = &branches[b][start..remaining[b]];
offset -= part.len();
result[offset..offset + part.len()].copy_from_slice(part);
remaining[b] = start;
let mut released = HashSet::from([b]);
for &node in part {
for &up in &releases[node] {
restrictions[up].remove(&node);
let branch = owner[up];
if restrictions[up].is_empty() && remaining[branch] > 0 && branches[branch][remaining[branch] - 1] == up
{
released.insert(branch);
}
}
}
for branch in released {
prepare(branch, &remaining, &restrictions, &mut prepared, &mut queue);
}
}
debug_assert_eq!(offset, 0, "Bek must place every node");
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn branch_blocks_preserve_every_edge_under_clock_skew_and_interleaved_refs() {
let mut seed = 17u64;
let mut random = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
seed as usize
};
for _ in 0..200 {
let n = 70;
let parents: Vec<Vec<usize>> = (0..n)
.map(|i| {
let mut ps = Vec::new();
if i + 1 < n {
for _ in 0..(random() % 3 + 1) {
let p = i + 1 + random() % (n - i - 1);
if !ps.contains(&p) {
ps.push(p);
}
}
}
ps
})
.collect();
let times: Vec<_> = (0..n).map(|_| (random() % 1_000_000) as i64).collect();
let mut children = vec![0; n];
for ps in &parents {
for &p in ps {
children[p] += 1;
}
}
let mut heads: Vec<_> = (0..n).filter(|&i| children[i] == 0 || i % 11 == 0).collect();
heads.sort_by_cached_key(|_| random());
let result = sort(&parents, &times, &heads);
assert_eq!(result.iter().copied().collect::<HashSet<_>>().len(), n);
let mut positions = vec![0; n];
for (i, &node) in result.iter().enumerate() {
positions[node] = i;
}
for (node, ps) in parents.iter().enumerate() {
for &p in ps {
assert!(positions[node] < positions[p]);
}
}
}
}
}
File diff suppressed because it is too large. Load diff
+3 -254
Переглянути файл
@@ -112,64 +112,22 @@ pub struct Commit {
pub id: String,
pub short_id: String,
pub parents: Vec<String>,
/// Connections in the filtered graph; never use these as revision/diff parents.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_parents: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub graph_dashed: Vec<bool>,
/// Repository-relative filename at this revision in rename-following history.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_position: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_total: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub graph_boundary_parents: Vec<String>,
/// Reachability from HEAD, computed independently of the visible filters.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_current_branch: Option<bool>,
pub author: String,
pub email: String,
#[serde(default)]
pub committer: String,
#[serde(default)]
pub committer_email: String,
pub time_ms: f64,
pub summary: String,
pub message: String,
#[serde(default)]
pub message_truncated: bool,
pub refs: Vec<String>,
/// Committer time (Log › View Options › Commit Timestamp).
#[serde(default)]
pub commit_time_ms: f64,
/// Legacy alias of `graph_dashed`, indexed by displayed parents.
/// Per parent, under a user or text filter: the link passes commits the filter hides
/// (`parents` then name the nearest shown ancestors). Empty when no parent is rewritten.
#[serde(default)]
#[specta(optional)]
pub dashed: Vec<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogDetails {
pub message: String,
pub containing_branches: Vec<String>,
/// Git's verification code: G/U good, B bad, E error, N unsigned, X/Y expired, R revoked.
pub signature: String,
pub signer: String,
pub key: String,
pub fingerprint: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogIndexProgress {
pub indexed: u32,
pub total: u32,
pub running: bool,
}
/// Order of the Git Log (Graph Options › Sort).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -179,29 +137,13 @@ pub enum LogOrder {
Topo,
/// `--date-order`: commit time, but never a parent before its children.
Date,
// Topological grouping with incoming merge parents visited before the main line.
Standard,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogQuery {
#[serde(default)]
pub speed_search: Option<String>,
#[serde(default)]
pub search_fields: Vec<String>,
/// Jump directly to an indexed window containing this commit (50 rows of context).
#[serde(default)]
pub seek: Option<String>,
#[serde(default)]
pub retain_until: Option<String>,
pub branch: Option<String>,
pub path: Option<ide_core::IpcPath>,
#[serde(default)]
pub paths: Vec<ide_core::IpcPath>,
/// File history follows renames; structure filters retain fixed paths.
#[serde(default)]
pub follow: bool,
pub author: Option<String>,
pub text: Option<String>,
pub skip: u32,
@@ -210,9 +152,6 @@ pub struct LogQuery {
/// not the left). Takes precedence over `branch`.
#[serde(default)]
pub range: Option<String>,
/// Additional ranges unioned with each other and with selected branches.
#[serde(default)]
pub ranges: Vec<String>,
/// `text` is a regular expression (Log filter `.*`).
#[serde(default)]
pub text_regex: bool,
@@ -226,8 +165,6 @@ pub struct LogQuery {
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub authors_exact: bool,
#[serde(default)]
pub order: LogOrder,
/// Follow only the first parent of merges (`--first-parent`).
#[serde(default)]
@@ -242,13 +179,6 @@ pub struct LogQuery {
pub since_ms: Option<f64>,
#[serde(default)]
pub until_ms: Option<f64>,
#[serde(default)]
pub request: Option<ide_core::RequestId>,
#[serde(default)]
pub summary_only: bool,
/// Inclusive refresh boundary. Clients join only verified linear prefixes.
#[serde(default)]
pub stop_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
@@ -557,7 +487,6 @@ pub struct ChangelistState {
pub files: std::collections::BTreeMap<String, String>,
}
mod bek;
mod blame;
mod block_history;
pub mod changelists;
@@ -567,7 +496,6 @@ mod diff;
mod history;
mod ignore;
mod index_writes;
mod log_index;
mod manage;
mod ops;
mod patch;
@@ -671,9 +599,6 @@ pub struct Repo {
git_dir: PathBuf,
inner: std::sync::Mutex<repo::Cache>,
ignored: std::sync::RwLock<IgnoredNames>,
log_index: std::sync::Mutex<log_index::LogIndex>,
index_progress: std::sync::Mutex<LogIndexProgress>,
log_signatures: std::sync::Mutex<std::collections::HashMap<String, LogDetails>>,
}
impl Repo {
@@ -813,183 +738,7 @@ impl Repo {
/// `refs` decorations are `HEAD`, branch names (`main`, `origin/main`) and `tag: <name>`.
pub fn log(&self, query: &LogQuery) -> IdeResult<Vec<Commit>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
history::log(self, query, &CancellationToken::new())
}
pub fn log_cancellable(&self, query: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
history::log(self, query, cancel)
}
/// Enables the disposable, content-addressed log cache outside the worktree.
pub fn enable_log_index(&self, cache_dir: &Path) {
let key = blake3::hash(self.git_dir.to_string_lossy().as_bytes()).to_hex().to_string();
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).open(&cache_dir.join(key));
}
/// Reference names and peeled commit ids, including tags and detached HEAD.
pub fn log_refs(&self) -> IdeResult<std::collections::BTreeMap<String, String>> {
let repo = self.gix()?;
Ok(history::decorations(&repo)?
.into_iter()
.flat_map(|(id, names)| names.into_iter().map(move |name| (name, id.to_string())))
.collect())
}
pub fn log_users(&self) -> IdeResult<Vec<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let out = cli::run(self.root(), &["log", "--all", "--format=%an <%ae>"], None)?;
Ok(out
.stdout_str()
.lines()
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect())
}
pub fn log_index_progress(&self) -> LogIndexProgress {
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
/// Incremental persistent inverted index; completed objects survive cancellation/restart.
pub fn index_log(&self, cancel: &CancellationToken) -> IdeResult<LogIndexProgress> {
{
let mut progress = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
if progress.running {
return Ok(progress.clone());
}
progress.running = true;
}
let result = (|| {
let repo = self.gix()?;
let out = cli::run_cancellable(self.root(), &["rev-list", "--all", "--topo-order"], cancel)?;
let ids: Vec<_> = out
.stdout_str()
.lines()
.map(|id| gix::ObjectId::from_hex(id.as_bytes()).map_err(repo::gx))
.collect::<IdeResult<_>>()?;
{
let mut p = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
p.total = ids.len() as u32;
p.indexed = 0;
}
for (i, id) in ids.iter().enumerate() {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let indexed = self.log_index.lock().unwrap_or_else(|p| p.into_inner()).indexed(&id.to_string());
if !indexed {
let files = self.commit_files(&id.to_string())?;
let paths =
files.into_iter().flat_map(|f| std::iter::once(f.path).chain(f.old_path)).collect::<Vec<_>>();
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
let c = index.get(&repo, *id)?;
let message = index.message(&repo, *id)?;
index.index_commit(&c.id, &message, &c.author, &c.email, &paths);
if i % 100 == 0 {
index.flush();
}
}
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).indexed = i as u32 + 1;
}
Ok(())
})();
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).flush();
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).running = false;
result?;
Ok(self.log_index_progress())
}
pub fn log_details(&self, revision: &str) -> IdeResult<LogDetails> {
self.log_details_cancellable(revision, &CancellationToken::new())
}
pub fn log_details_cancellable(&self, revision: &str, cancel: &CancellationToken) -> IdeResult<LogDetails> {
let allowed = self.execution_allowed.load(std::sync::atomic::Ordering::Acquire);
let _scope = crate::ExecutionScope::enter(allowed);
let repo = self.gix()?;
let object =
repo.find_object(repo::resolve(&repo, revision)?).map_err(repo::gx)?.peel_to_commit().map_err(repo::gx)?;
let commit_id = object.id;
let id = commit_id.to_string();
let contains = format!("--contains={id}");
let branches =
cli::run_cancellable(self.root(), &["branch", "-a", &contains, "--format=%(refname:short)"], cancel)?;
let cache_key = format!("{allowed}:{id}");
let cached = self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).get(&cache_key).cloned();
let mut details = if let Some(details) = cached {
details
} else {
let end = object.data.windows(2).position(|s| s == b"\n\n").unwrap_or(object.data.len());
let headers = &object.data[..end];
let signed = headers.windows(8).any(|s| s == b"\ngpgsig ") || headers.windows(8).any(|s| s == b"\ngpgsig-");
let output = if allowed && signed {
Some(cli::run_cancellable(
self.root(),
&["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", "--end-of-options", &id],
cancel,
)?)
} else {
None
};
let text = output.as_ref().map(|out| out.stdout_str()).unwrap_or_default();
let fields: Vec<_> = text.trim_end().split('\0').collect();
let message = {
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
let message = index.message(&repo, commit_id)?;
index.flush();
message
};
let details = LogDetails {
message,
containing_branches: Vec::new(),
signature: if !signed {
"N".into()
} else {
fields.first().filter(|s| !s.is_empty()).unwrap_or(&"E").to_string()
},
signer: fields.get(1).unwrap_or(&"").to_string(),
key: fields.get(2).unwrap_or(&"").to_string(),
fingerprint: fields.get(3).unwrap_or(&"").to_string(),
};
self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).insert(cache_key, details.clone());
details
};
details.containing_branches =
branches.stdout_str().lines().filter(|s| !s.ends_with("/HEAD")).map(str::to_string).collect();
Ok(details)
}
/// Lists one immutable revision directory. Directory names end in `/`.
pub fn revision_children(&self, revision: &str, directory: &str) -> IdeResult<Vec<String>> {
let repo = self.gix()?;
let id = repo::resolve(&repo, revision)?.to_string();
let directory = self.rel_path(std::path::Path::new(directory))?;
let tree = if directory.is_empty() { id } else { format!("{id}:{directory}") };
let out = cli::run(self.root(), &["ls-tree", "-z", "--", &tree], None)?;
Ok(out
.stdout
.split(|b| *b == 0)
.filter_map(|line| {
let split = line.iter().position(|b| *b == b'\t')?;
let name = String::from_utf8_lossy(&line[split + 1..]);
let dir = line.starts_with(b"040000 ");
Some(format!(
"{}{name}{}",
if directory.is_empty() { String::new() } else { format!("{directory}/") },
if dir { "/" } else { "" }
))
})
.collect())
}
pub fn revision_files(&self, revision: &str) -> IdeResult<Vec<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let repo = self.gix()?;
let id = repo::resolve(&repo, revision)?.to_string();
let out = cli::run(self.root(), &["ls-tree", "-r", "-z", "--name-only", "--", &id], None)?;
Ok(out
.stdout
.split(|b| *b == 0)
.filter(|p| !p.is_empty())
.map(|p| String::from_utf8_lossy(p).into_owned())
.collect())
history::log(self, query)
}
/// Files changed by `commit` relative to its first parent (renames detected).
pub fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>> {
-385
Переглянути файл
@@ -1,385 +0,0 @@
//! Immutable commit metadata, persisted by object id. Ref decorations and graph
//! projections are deliberately never cached as commit metadata.
use crate::Commit;
use crate::repo::{gx, to_string};
use gix::ObjectId;
use gix::bstr::ByteSlice;
use ide_core::IdeResult;
use redb::{Database, MultimapTableDefinition, ReadableDatabase, TableDefinition};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::SystemTime;
const COMMITS: TableDefinition<&str, &[u8]> = TableDefinition::new("summaries-v2");
const MESSAGES: TableDefinition<&str, &str> = TableDefinition::new("messages-v1");
const PATHS: TableDefinition<&str, &[u8]> = TableDefinition::new("paths-v2");
const POSTINGS: MultimapTableDefinition<&str, &str> = MultimapTableDefinition::new("search-v1");
const INDEXED: TableDefinition<&str, bool> = TableDefinition::new("indexed-v1");
pub(crate) struct IndexedView {
pub commits: Vec<Commit>,
pub positions: HashMap<String, usize>,
}
impl IndexedView {
pub fn new(commits: Vec<Commit>) -> Self {
let positions = commits.iter().enumerate().map(|(i, c)| (c.id.clone(), i)).collect();
Self { commits, positions }
}
}
#[derive(Default)]
pub(crate) struct LogIndex {
opened: bool,
db: Option<Arc<Database>>,
memory: HashMap<ObjectId, Arc<Commit>>,
dirty: Vec<Arc<Commit>>,
dirty_messages: Vec<(String, String)>,
dirty_search: Vec<(String, Vec<String>)>,
path_memory: HashMap<(ObjectId, String), Option<(ObjectId, u16)>>,
dirty_paths: Vec<(String, Option<(String, u16)>)>,
pub head: Option<(ObjectId, Option<SystemTime>)>,
pub reachable: HashSet<ObjectId>,
pub views: HashMap<String, Arc<IndexedView>>,
pub nodes: HashMap<ObjectId, (Vec<ObjectId>, i64)>,
pub shallow: HashSet<ObjectId>,
}
impl LogIndex {
/// A permanent DAG: refresh discovers only new objects, then joins arbitrary
/// changed tips against known ancestry. Reachability is recomputed for resets/deleted refs.
pub fn graph(
&mut self,
repo: &gix::Repository,
tips: &[ObjectId],
shallow: &HashSet<ObjectId>,
first_parent: bool,
cancel: &ide_core::CancellationToken,
) -> IdeResult<Vec<ObjectId>> {
if &self.shallow != shallow {
self.nodes.clear();
self.shallow = shallow.clone();
}
let mut pending = tips.to_vec();
while let Some(id) = pending.pop() {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
if self.nodes.contains_key(&id) {
continue;
}
let record = self.get(repo, id)?;
let parents: Vec<_> = if shallow.contains(&id) {
Vec::new()
} else {
record.parents.iter().map(|s| ObjectId::from_hex(s.as_bytes()).map_err(gx)).collect::<IdeResult<_>>()?
};
pending.extend(parents.iter().copied());
self.nodes.insert(id, (parents, (record.commit_time_ms / 1000.0) as i64));
}
let mut seen = HashSet::new();
let mut ids = Vec::new();
let mut pending = tips.to_vec();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
ids.push(id);
if let Some((parents, _)) = self.nodes.get(&id) {
pending.extend(parents.iter().take(if first_parent { 1 } else { usize::MAX }).rev().copied());
}
}
Ok(ids)
}
pub fn indexed(&self, id: &str) -> bool {
self.db
.as_ref()
.and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(INDEXED).ok()?;
Some(table.get(id).ok()??.value())
})
.unwrap_or(false)
}
pub fn index_commit(&mut self, id: &str, message: &str, author: &str, email: &str, paths: &[String]) {
let mut keys: HashSet<String> = Self::grams(message).into_iter().map(|s| format!("m:{s}")).collect();
for user in [author, email, email.split('@').next().unwrap_or(email)] {
keys.insert(format!("u:{}", normalize_user(user)));
}
for path in paths {
keys.insert(format!("p:{path}"));
}
self.dirty_search.push((id.into(), keys.into_iter().collect()));
}
fn grams(text: &str) -> Vec<String> {
let chars: Vec<_> = text.to_lowercase().chars().collect();
chars.windows(3).map(|s| s.iter().collect()).collect::<HashSet<_>>().into_iter().collect()
}
/// Missing/unindexed commits are always checked directly by the caller.
pub fn message_candidates(&self, text: &str) -> Option<HashSet<String>> {
let grams = Self::grams(text);
if grams.is_empty() {
return None;
}
let db = self.db.as_ref()?;
let txn = db.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut candidates: Option<HashSet<String>> = None;
for gram in grams {
let values = table.get(format!("m:{gram}").as_str()).ok()?;
let ids: HashSet<_> = values.filter_map(Result::ok).map(|s| s.value().to_string()).collect();
candidates = Some(match candidates {
Some(old) => old.intersection(&ids).cloned().collect(),
None => ids,
});
if candidates.as_ref().is_some_and(HashSet::is_empty) {
break;
}
}
Some(candidates.unwrap_or_default())
}
pub fn user_candidates(&self, authors: &[String]) -> Option<HashSet<String>> {
let txn = self.db.as_ref()?.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut ids = HashSet::new();
for author in authors {
let name = author.split('<').next().unwrap_or(author).trim();
let email = author.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(author);
for alias in [name, email, email.split('@').next().unwrap_or(email)] {
for value in table.get(format!("u:{}", normalize_user(alias)).as_str()).ok()?.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
}
}
Some(ids)
}
pub fn path_candidates(&self, paths: &[String]) -> Option<HashSet<String>> {
let txn = self.db.as_ref()?.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut ids = HashSet::new();
for path in paths {
for value in table.get(format!("p:{path}").as_str()).ok()?.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
let prefix = format!("p:{path}/");
let end = format!("{prefix}{}", char::MAX);
for row in table.range(prefix.as_str()..end.as_str()).ok()?.filter_map(Result::ok) {
for value in row.1.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
}
}
Some(ids)
}
pub fn open(&mut self, dir: &Path) {
if self.opened {
return;
}
self.opened = true;
// Several windows can read the same worktree; redb permits one owning
// handle per file, shared here rather than silently disabling its index.
static DATABASES: OnceLock<Mutex<HashMap<std::path::PathBuf, Weak<Database>>>> = OnceLock::new();
let path = dir.join("commits.redb");
let mut databases = DATABASES.get_or_init(Mutex::default).lock().unwrap_or_else(|p| p.into_inner());
self.db = databases.get(&path).and_then(Weak::upgrade).or_else(|| {
let db = Arc::new(std::fs::create_dir_all(dir).ok().and_then(|_| Database::create(&path).ok())?);
databases.insert(path, Arc::downgrade(&db));
Some(db)
});
}
pub fn get(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<Arc<Commit>> {
if let Some(commit) = self.memory.get(&id) {
return Ok(commit.clone());
}
let key = id.to_string();
let cached = self.db.as_ref().and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(COMMITS).ok()?;
let value = table.get(key.as_str()).ok()??;
serde_json::from_slice::<Commit>(value.value()).ok().filter(|c| c.id == key)
});
let commit = if let Some(commit) = cached {
Arc::new(commit)
} else {
let commit = repo.find_commit(id).map_err(gx)?;
let author = commit.author().map_err(gx)?;
let committer = commit.committer().map_err(gx)?;
let raw = commit.message_raw_sloppy();
let summary = raw.split(|b| *b == b'\n').find(|l| !l.trim().is_empty()).unwrap_or_default().trim();
let summary_text = to_string(summary.as_bstr());
let record = Arc::new(Commit {
id: key.clone(),
short_id: key[..key.len().min(8)].to_string(),
parents: commit.parent_ids().map(|p| p.to_string()).collect(),
graph_parents: None,
graph_dashed: Vec::new(),
dashed: Vec::new(),
on_current_branch: None,
author: to_string(author.name.trim().as_bstr()),
email: to_string(author.email.trim().as_bstr()),
committer: to_string(committer.name.trim().as_bstr()),
committer_email: to_string(committer.email.trim().as_bstr()),
time_ms: author.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0),
commit_time_ms: committer.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0),
summary: summary_text.clone(),
message: summary_text,
message_truncated: raw.trim() != summary,
history_path: None,
log_position: None,
log_total: None,
graph_boundary_parents: Vec::new(),
refs: Vec::new(),
});
if self.db.is_some() {
self.dirty.push(record.clone());
}
record
};
// Keep hot pages in memory; older metadata stays in the persistent index.
if self.memory.len() >= 4096 {
self.flush();
self.memory.clear();
}
self.memory.insert(id, commit.clone());
Ok(commit)
}
pub fn message(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<String> {
if let Some((_, message)) = self.dirty_messages.iter().find(|(key, _)| key == &id.to_string()) {
return Ok(message.clone());
}
let key = id.to_string();
let cached = self.db.as_ref().and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(MESSAGES).ok()?;
Some(table.get(key.as_str()).ok()??.value().to_string())
});
if let Some(message) = cached {
return Ok(message);
}
let message = to_string(repo.find_commit(id).map_err(gx)?.message_raw_sloppy()).trim_end().to_string();
if self.db.is_some() {
self.dirty_messages.push((key, message.clone()));
}
Ok(message)
}
pub fn flush(&mut self) {
if self.dirty.is_empty()
&& self.dirty_paths.is_empty()
&& self.dirty_messages.is_empty()
&& self.dirty_search.is_empty()
{
return;
}
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
let Some(db) = &self.db else {
return Ok(());
};
let txn = db.begin_write()?;
{
let mut table = txn.open_table(COMMITS)?;
for commit in &self.dirty {
let bytes = serde_json::to_vec(commit.as_ref())?;
table.insert(commit.id.as_str(), bytes.as_slice())?;
}
}
{
let mut table = txn.open_table(PATHS)?;
for (key, value) in &self.dirty_paths {
let bytes = serde_json::to_vec(value)?;
table.insert(key.as_str(), bytes.as_slice())?;
}
}
{
let mut table = txn.open_table(MESSAGES)?;
for (id, message) in &self.dirty_messages {
table.insert(id.as_str(), message.as_str())?;
}
}
{
let mut postings = txn.open_multimap_table(POSTINGS)?;
let mut indexed = txn.open_table(INDEXED)?;
for (id, keys) in &self.dirty_search {
for key in keys {
postings.insert(key.as_str(), id.as_str())?;
}
indexed.insert(id.as_str(), true)?;
}
}
txn.commit()?;
Ok(())
})();
if let Err(error) = result {
tracing::warn!("Git log cache write failed: {error}");
}
self.dirty_messages.clear();
self.dirty_search.clear();
self.dirty.clear();
self.dirty_paths.clear();
}
pub fn path_entry(
&mut self,
repo: &gix::Repository,
id: ObjectId,
path: &str,
) -> IdeResult<Option<(ObjectId, u16)>> {
let memory_key = (id, path.to_string());
if let Some(value) = self.path_memory.get(&memory_key) {
return Ok(*value);
}
let key = format!("{id}\0{path}");
let cached = self.db.as_ref().and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(PATHS).ok()?;
let value = table.get(key.as_str()).ok()??;
serde_json::from_slice::<Option<(String, u16)>>(value.value()).ok()
});
let cached = match cached {
Some(None) => Some(None),
Some(Some((hex, mode))) => ObjectId::from_hex(hex.as_bytes()).ok().map(|id| Some((id, mode))),
None => None,
};
let value = if let Some(value) = cached {
value
} else {
let tree = repo.find_commit(id).map_err(gx)?.tree().map_err(gx)?;
let value = tree
.lookup_entry_by_path(Path::new(path))
.map_err(gx)?
.map(|entry| (entry.object_id(), entry.mode().value()));
if self.db.is_some() {
self.dirty_paths.push((key, value.map(|(id, mode)| (id.to_string(), mode))));
}
value
};
if self.path_memory.len() >= 4096 {
self.flush();
self.path_memory.clear();
}
self.path_memory.insert(memory_key, value);
Ok(value)
}
}
pub(crate) fn normalize_user(user: &str) -> String {
user.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
pub(crate) fn matches_user(query: &str, name: &str, email: &str) -> bool {
let query_name = query.split('<').next().unwrap_or(query).trim();
let query_email = query.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(query);
let target =
[normalize_user(name), normalize_user(email), normalize_user(email.split('@').next().unwrap_or(email))];
[query_name, query_email, query_email.split('@').next().unwrap_or(query_email)]
.iter()
.any(|alias| target.contains(&normalize_user(alias)))
}
-3
Переглянути файл
@@ -64,9 +64,6 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
git_dir,
inner: std::sync::Mutex::new(cache),
ignored: Default::default(),
log_index: Default::default(),
index_progress: Default::default(),
log_signatures: Default::default(),
}))
}
-438
Переглянути файл
@@ -1,438 +0,0 @@
use ide_vcs::{LogOrder, LogQuery, Repo};
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args([
"-c",
"init.defaultBranch=main",
"-c",
"user.name=Test",
"-c",
"[email protected]",
"-c",
"commit.gpgsign=false",
])
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn commit(dir: &Path, file: &str, message: &str) -> String {
std::fs::write(dir.join(file), message).unwrap();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-qm", message]);
git(dir, &["rev-parse", "HEAD"])
}
#[test]
fn branch_and_independent_ranges_form_a_union() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "a", "root");
let first = commit(dir, "a", "first");
let second = commit(dir, "a", "second");
let third = commit(dir, "a", "third");
git(dir, &["checkout", "-qb", "side", &root]);
let side = commit(dir, "b", "side");
let repo = Repo::discover(dir).unwrap().unwrap();
for order in [LogOrder::Topo, LogOrder::Date, LogOrder::Standard] {
let log = repo
.log(&LogQuery {
branches: vec!["side".into()],
ranges: vec![format!("{root}..{first}"), format!("{second}..{third}")],
order,
..Default::default()
})
.unwrap();
let got: std::collections::HashSet<_> = log.iter().map(|c| c.id.clone()).collect();
assert_eq!(got, [root.clone(), first.clone(), third.clone(), side.clone()].into());
let ranges = repo
.log(&LogQuery {
ranges: vec![format!("{root}..{first}"), format!("{second}..{third}")],
order,
..Default::default()
})
.unwrap();
assert_eq!(
ranges.iter().map(|c| c.id.clone()).collect::<std::collections::HashSet<_>>(),
[first.clone(), third.clone()].into()
);
let positions: std::collections::HashMap<_, _> =
log.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
for (i, c) in log.iter().enumerate() {
for p in c.graph_parents.as_ref().unwrap() {
assert!(positions.get(p.as_str()).is_none_or(|p| *p > i));
}
}
}
}
#[test]
fn lazy_summaries_seek_and_index_survive_refresh_and_restart() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let mut ids = Vec::new();
for i in 0..70 {
ids.push(commit(dir, "a", &format!("summary {i}\n\nneedle body {i}")));
}
let cache = tempfile::tempdir().unwrap();
let repo = Repo::discover(dir).unwrap().unwrap();
repo.enable_log_index(cache.path());
let query = LogQuery { summary_only: true, limit: 5, ..Default::default() };
let page = repo.log(&query).unwrap();
assert_eq!(page[0].message, "summary 69");
assert!(page[0].message_truncated);
assert_eq!(page[0].log_position, Some(0));
assert_eq!(page[0].log_total, Some(70));
let window = repo.log(&LogQuery { seek: Some(ids[5].clone()), limit: 20, ..query.clone() }).unwrap();
assert!(window.iter().any(|c| c.id == ids[5]));
assert!(window[0].log_position.unwrap() > 0);
assert!(repo.log(&LogQuery { request: Some(ide_core::RequestId(1)), ..query.clone() }).is_ok());
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
assert!(repo.log_cancellable(&query, &cancel).is_err());
let progress = repo.index_log(&ide_core::CancellationToken::new()).unwrap();
assert_eq!((progress.indexed, progress.total, progress.running), (70, 70, false));
assert_eq!(repo.log(&LogQuery { text: Some("needle body 5".into()), ..query.clone() }).unwrap().len(), 5);
let new = commit(dir, "b", "new head");
let refreshed = repo.log(&LogQuery { retain_until: Some(ids[0].clone()), ..query.clone() }).unwrap();
assert_eq!(refreshed[0].id, new);
assert_eq!(refreshed.len(), 71);
drop(repo);
let repo = Repo::discover(dir).unwrap().unwrap();
repo.enable_log_index(cache.path());
let hit = repo.log(&LogQuery { text: Some("needle body 69".into()), ..query }).unwrap();
assert_eq!(hit[0].id, ids[69]);
assert_eq!(repo.log_details(&ids[69]).unwrap().message, "summary 69\n\nneedle body 69");
assert_eq!(repo.log_details(&ids[69]).unwrap().signature, "N");
}
#[test]
fn followed_history_carries_the_filename_at_each_revision() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let first = commit(dir, "old.txt", "original");
git(dir, &["mv", "old.txt", "new.txt"]);
git(dir, &["commit", "-qm", "rename"]);
let renamed = git(dir, &["rev-parse", "HEAD"]);
let tip = commit(dir, "new.txt", "latest");
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { path: Some("new.txt".into()), follow: true, ..Default::default() }).unwrap();
assert_eq!(
log.iter().map(|c| (c.id.as_str(), c.history_path.as_deref())).collect::<Vec<_>>(),
vec![(tip.as_str(), Some("new.txt")), (renamed.as_str(), Some("new.txt")), (first.as_str(), Some("old.txt"))]
);
assert_eq!(repo.revision_children(&first, "").unwrap(), vec!["old.txt"]);
}
#[test]
fn detail_filters_connect_visible_ancestors_without_changing_revision_parents() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let a = commit(dir, "a", "keep root");
commit(dir, "b", "hidden middle");
git(dir, &["commit", "--amend", "--no-edit", "--author=Other <[email protected]>"]);
let b = git(dir, &["rev-parse", "HEAD"]);
let c = commit(dir, "a", "keep tip");
let repo = Repo::discover(dir).unwrap().unwrap();
for q in [
LogQuery { text: Some("keep".into()), ..Default::default() },
LogQuery { path: Some("a".into()), ..Default::default() },
LogQuery { authors: vec!["Test".into()], authors_exact: true, ..Default::default() },
] {
let page = repo.log(&LogQuery { limit: 1, ..q.clone() }).unwrap();
assert_eq!(page[0].id, c);
assert_eq!(page[0].parents, vec![b.clone()]);
assert_eq!(page[0].graph_parents, Some(vec![a.clone()]));
assert_eq!(page[0].graph_dashed, vec![true]);
assert_eq!(page[0].on_current_branch, Some(true));
let next = repo.log(&LogQuery { skip: 1, limit: 1, ..q }).unwrap();
assert_eq!(next[0].id, a);
assert_eq!(next[0].graph_parents, Some(vec![]));
}
}
#[test]
fn all_history_includes_commits_reachable_only_from_tags() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "a", "root");
let tip = commit(dir, "a", "tagged tip");
git(dir, &["tag", "v1"]);
git(dir, &["reset", "--hard", &root]);
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery::default()).unwrap();
let tagged = log.iter().find(|c| c.id == tip).unwrap();
assert!(tagged.refs.contains(&"tag: v1".into()));
assert_eq!(tagged.on_current_branch, Some(false));
}
#[test]
fn projection_does_not_truncate_long_hidden_chains() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let mut stream = String::new();
for i in 0..4100 {
let message = if i == 0 || i == 4099 { "keep" } else { "hide" };
stream.push_str(&format!(
"commit refs/heads/main\ncommitter Test <[email protected]> {} +0000\ndata {}\n{}\n\n",
1000 + i,
message.len(),
message
));
}
let mut child = Command::new("git")
.current_dir(dir)
.args(["fast-import", "--quiet"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(stream.as_bytes()).unwrap();
assert!(child.wait().unwrap().success());
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { text: Some("keep".into()), ..Default::default() }).unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log[0].graph_parents, Some(vec![log[1].id.clone()]));
}
#[test]
fn path_and_shallow_history_remain_topological_with_clock_skew() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
std::fs::create_dir(&source).unwrap();
git(&source, &["init", "-q"]);
let make = |message: &str, parent: Option<&str>, time: &str| {
std::fs::write(source.join("a.txt"), message).unwrap();
git(&source, &["add", "a.txt"]);
let tree = git(&source, &["write-tree"]);
let mut cmd = Command::new("git");
cmd.current_dir(&source).args([
"-c",
"user.name=Test",
"-c",
"[email protected]",
"commit-tree",
&tree,
"-m",
message,
]);
if let Some(p) = parent {
cmd.args(["-p", p]);
}
let out = cmd.env("GIT_AUTHOR_DATE", time).env("GIT_COMMITTER_DATE", time).output().unwrap();
assert!(out.status.success());
String::from_utf8(out.stdout).unwrap().trim().to_string()
};
let root = make("root", None, "@5000 +0000");
let left = make("left", Some(&root), "@1000 +0000");
let right = make("right", Some(&root), "@2000 +0000");
git(&source, &["update-ref", "refs/heads/main", &left]);
git(&source, &["update-ref", "refs/heads/side", &right]);
let shallow = tmp.path().join("shallow");
git(
tmp.path(),
&[
"clone",
"-q",
"--depth=2",
"--no-single-branch",
&format!("file://{}", source.display()),
shallow.to_str().unwrap(),
],
);
for dir in [&source, &shallow] {
let repo = Repo::discover(dir).unwrap().unwrap();
for order in [LogOrder::Date, LogOrder::Topo, LogOrder::Standard] {
let log = repo.log(&LogQuery { order, path: Some("a.txt".into()), ..Default::default() }).unwrap();
assert_eq!(log.len(), 3);
let positions: std::collections::HashMap<_, _> =
log.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
for (i, c) in log.iter().enumerate() {
for parent in c.graph_parents.as_ref().unwrap() {
assert!(positions.get(parent.as_str()).is_none_or(|p| *p > i));
}
}
}
}
}
#[test]
fn multiple_paths_exact_authors_patterns_and_rename_history() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "old.txt", "root");
git(dir, &["mv", "old.txt", "new.txt"]);
git(dir, &["commit", "-qm", "rename"]);
let rename = git(dir, &["rev-parse", "HEAD"]);
let tip = commit(dir, "other.txt", "another file");
git(dir, &["branch", "feature/log"]);
git(dir, &["branch", "feature/skip", &root]);
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { paths: vec!["new.txt".into(), "other.txt".into()], ..Default::default() }).unwrap();
assert_eq!(log.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), vec![tip.clone(), rename.clone()]);
let log = repo.log(&LogQuery { path: Some("new.txt".into()), follow: true, ..Default::default() }).unwrap();
assert_eq!(log.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), vec![rename, root.clone()]);
let log = repo
.log(&LogQuery { branches: vec!["feature/.*".into(), "-feature/skip".into()], ..Default::default() })
.unwrap();
assert_eq!(log[0].id, tip);
std::fs::write(dir.join("other.txt"), "different author").unwrap();
git(dir, &["add", "-A"]);
git(dir, &["-c", "user.name=Test User", "-c", "[email protected]", "commit", "-qm", "different author"]);
let log = repo.log(&LogQuery { authors: vec!["Test".into()], authors_exact: true, ..Default::default() }).unwrap();
assert_eq!(log.len(), 3);
assert!(log.iter().all(|c| c.author == "Test"));
assert!(repo.log_users().unwrap().contains(&"Test User <[email protected]>".to_string()));
}
#[test]
fn metadata_and_path_cache_survive_restart_without_caching_refs_or_filters() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("repo");
std::fs::create_dir(&dir).unwrap();
git(&dir, &["init", "-q"]);
let first = commit(&dir, "a", "first\n\nComplete commit body");
git(&dir, &["tag", "v1"]);
git(&dir, &["tag", "-a", "annotated", "-m", "release"]);
let cache = tmp.path().join("cache");
{
let repo = Repo::discover(&dir).unwrap().unwrap();
repo.enable_log_index(&cache);
let log = repo.log(&LogQuery { path: Some("a".into()), summary_only: true, ..Default::default() }).unwrap();
assert!(log[0].message_truncated);
assert_eq!(log[0].message, "first");
assert_eq!(repo.log_details(&first).unwrap().message, "first\n\nComplete commit body");
let details = repo.log_details("annotated").unwrap();
assert_eq!(details.message, "first\n\nComplete commit body");
assert_eq!(details.signature, "N");
assert!(details.containing_branches.contains(&"main".to_string()));
assert_eq!(repo.revision_files(&first).unwrap(), vec!["a"]);
}
let second = commit(&dir, "a", "second");
git(&dir, &["tag", "-f", "v1"]);
let repo = Repo::discover(&dir).unwrap().unwrap();
repo.enable_log_index(&cache);
let log = repo.log(&LogQuery { path: Some("a".into()), ..Default::default() }).unwrap();
assert_eq!(log[0].id, second);
assert!(log[0].refs.contains(&"tag: v1".into()));
assert!(!log[1].refs.contains(&"tag: v1".into()));
assert!(log[1].message.contains("Complete commit body"));
assert!(!log[1].message_truncated);
}
#[test]
fn cancelled_query_does_not_publish_a_partial_page() {
let tmp = tempfile::tempdir().unwrap();
git(tmp.path(), &["init", "-q"]);
commit(tmp.path(), "a", "root");
let repo = Repo::discover(tmp.path()).unwrap().unwrap();
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
assert_eq!(
repo.log_cancellable(&LogQuery::default(), &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn shallow_boundaries_do_not_reconnect_to_locally_present_ancestors() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "a", "keep root");
let boundary = commit(dir, "a", "hidden boundary");
let tip = commit(dir, "a", "keep tip");
git(dir, &["tag", "root", &root]);
std::fs::write(dir.join(".git/shallow"), format!("{boundary}\n")).unwrap();
let repo = Repo::discover(dir).unwrap().unwrap();
for order in [LogOrder::Date, LogOrder::Topo, LogOrder::Standard] {
for text in [None, Some("keep".to_string())] {
let log = repo.log(&LogQuery { order, text, path: Some("a".into()), ..Default::default() }).unwrap();
let boundary_row = log.iter().find(|c| c.id == boundary);
if let Some(row) = boundary_row {
assert_eq!(row.parents, vec![root.clone()]);
assert_eq!(row.graph_parents, Some(vec![]));
} else {
assert_eq!(log.iter().find(|c| c.id == tip).unwrap().graph_parents, Some(vec![]));
}
}
}
}
#[test]
fn exact_branch_exclusions_treat_punctuation_literally() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "a", "root");
git(dir, &["branch", "release/1.0"]);
let tip = commit(dir, "a", "tip");
git(dir, &["branch", "release/1x0"]);
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo
.log(&LogQuery { branches: vec!["release/.*".into(), "-release/1.0".into()], ..Default::default() })
.unwrap();
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![tip.as_str(), root.as_str()]);
}
#[test]
fn mode_only_changes_appear_in_path_history() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "script", "root");
git(dir, &["update-index", "--chmod=+x", "script"]);
git(dir, &["commit", "-qm", "make executable"]);
let tip = git(dir, &["rev-parse", "HEAD"]);
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { path: Some("script".into()), ..Default::default() }).unwrap();
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![tip.as_str(), root.as_str()]);
}
#[test]
fn multiple_paths_are_simplified_together_at_merges() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
commit(dir, "a", "a0");
commit(dir, "b", "b0");
git(dir, &["branch", "side"]);
commit(dir, "a", "left");
git(dir, &["checkout", "side"]);
commit(dir, "b", "right");
git(dir, &["checkout", "main"]);
git(dir, &["merge", "--no-ff", "side", "-m", "merge both paths"]);
let repo = Repo::discover(dir).unwrap().unwrap();
let expected = git(dir, &["log", "--topo-order", "--format=%H", "--", "a", "b"]);
for order in [LogOrder::Topo, LogOrder::Date, LogOrder::Standard] {
let log = repo
.log(&LogQuery {
branch: Some("HEAD".into()),
paths: vec!["a".into(), "b".into()],
order,
..Default::default()
})
.unwrap();
assert_eq!(log[0].summary, "merge both paths");
assert_eq!(
log.iter().map(|c| c.id.as_str()).collect::<std::collections::HashSet<_>>(),
expected.lines().collect()
);
assert_eq!(log[0].parents.len(), 2);
assert_eq!(log[0].graph_parents.as_ref().unwrap().len(), 2);
}
}
+4 -20
Переглянути файл
@@ -64,7 +64,7 @@ fn ids(commits: &[Commit]) -> Vec<String> {
}
fn parents_of(commits: &[Commit], id: &str) -> Vec<String> {
commits.iter().find(|c| c.id == id).unwrap().graph_parents.clone().unwrap()
commits.iter().find(|c| c.id == id).unwrap().parents.clone()
}
#[test]
@@ -75,14 +75,6 @@ fn date_order_follows_commit_time_and_reports_it() {
assert_eq!(out[0].commit_time_ms, 5_000_000.0);
}
#[test]
fn incoming_order_groups_the_merged_branch_before_the_main_parent() {
let h = history();
let out = log(&h, LogQuery { order: LogOrder::Standard, ..Default::default() });
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.s1.clone(), h.c2.clone(), h.c1.clone()]);
assert_eq!(out[1].parents, vec![h.c2.clone(), h.s1.clone()]);
}
#[test]
fn no_merges_links_children_to_the_merge_parents() {
let h = history();
@@ -98,7 +90,6 @@ fn first_parent_skips_merged_branches() {
let out = log(&h, LogQuery { first_parent: true, ..Default::default() });
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.c2.clone(), h.c1.clone()]);
assert_eq!(parents_of(&out, &h.m), vec![h.c2.clone()]);
assert_eq!(out.iter().find(|c| c.id == h.m).unwrap().parents, vec![h.c2.clone(), h.s1.clone()]);
}
#[test]
@@ -210,26 +201,19 @@ fn a_user_filter_links_shown_commits_past_hidden_ones() {
let log = repo.log(&LogQuery { author: Some("me@".into()), ..Default::default() }).unwrap();
let links: Vec<(&str, Vec<&str>, Vec<bool>)> = log
.iter()
.map(|c| {
(
c.summary.as_str(),
c.graph_parents.as_ref().unwrap().iter().map(String::as_str).collect(),
if c.graph_dashed.iter().any(|d| *d) { c.graph_dashed.clone() } else { Vec::new() },
)
})
.map(|c| (c.summary.as_str(), c.parents.iter().map(String::as_str).collect(), c.dashed.clone()))
.collect();
assert_eq!(
links,
[("m3", vec![m2.as_str()], vec![]), ("m2", vec![m1.as_str()], vec![true]), ("m1", vec![], vec![]),],
"m2's parent o1 is hidden: m2 links to m1, dashed"
);
assert!(!log.iter().any(|c| c.graph_parents.as_ref().unwrap().contains(&o1)));
assert_eq!(log[1].parents, vec![o1]);
assert!(!log.iter().any(|c| c.parents.contains(&o1)));
// A page ends at m2: walking on still links it to m1.
let page = repo.log(&LogQuery { author: Some("me@".into()), limit: 2, ..Default::default() }).unwrap();
assert_eq!(page.len(), 2);
assert_eq!(page[1].graph_parents.as_deref().unwrap(), std::slice::from_ref(&m1));
assert_eq!(page[1].parents, std::slice::from_ref(&m1));
let next = repo.log(&LogQuery { author: Some("me@".into()), skip: 2, limit: 2, ..Default::default() }).unwrap();
assert_eq!(next.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), std::slice::from_ref(&m1));
let _ = m3;
-5
Переглянути файл
@@ -256,11 +256,6 @@ pub fn set_writable(path: &Path, writable: bool) -> IdeResult<()> {
pub fn rename(from: &Path, to: &Path) -> IdeResult<()> {
ops::rename(from, to)
}
/// Checks source existence and destination conflicts without mutation, using
/// the same filesystem identity rules as [`rename`].
pub fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> {
ops::validate_rename(from, to)
}
/// Moves to the OS trash (recoverable), like IDEA's safe delete for files.
/// Deletes permanently when the trash is unavailable.
pub fn delete(path: &Path) -> IdeResult<()> {
+1 -9
Переглянути файл
@@ -88,7 +88,7 @@ fn same_file(a: &Path, b: &Path) -> bool {
}
}
pub(crate) fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> {
pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
if !exists(from) {
return Err(not_found(from));
}
@@ -99,14 +99,6 @@ pub(crate) fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> {
if exists(to) && !same_file(from, to) {
return Err(already_exists(to));
}
Ok(())
}
pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
validate_rename(from, to)?;
if from == to {
return Ok(());
}
ensure_parent(to)?;
match fs::rename(from, to) {
Ok(()) => Ok(()),
-1
Переглянути файл
@@ -21,7 +21,6 @@ blake3.workspace = true
tracing.workspace = true
tempfile.workspace = true
globset.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true
-3
Переглянути файл
@@ -401,11 +401,8 @@ impl Documents {
if edit.files.is_empty() {
return Ok(());
}
// Validate project rules before path resolution can erase linked components.
crate::file_ops::validate_workspace_edit_paths(edit)?;
let mut edit = normalize_edit(edit)?;
let captured = !edit.preconditions.is_empty();
crate::file_ops::expand_workspace_edit(&mut edit)?;
self.prepare_workspace_edit(&mut edit)?;
self.validate_preconditions(&edit.preconditions)?;
for file in &edit.files {
+27 -16
Переглянути файл
@@ -120,8 +120,6 @@ impl FileMoves {
if moves.is_empty() {
return Err(IdeError::invalid("nothing to move"));
}
let expanded = crate::file_ops::expand_moves(moves)?;
let moves = expanded.as_slice();
for m in moves {
if !m.from.exists() {
return Err(IdeError::not_found(format!("{} does not exist", m.from.display())));
@@ -147,9 +145,16 @@ impl FileMoves {
restore(documents, &paths, &before);
return Err(e);
}
if let Err(e) = crate::file_ops::move_files(moves) {
restore(documents, &paths, &before);
return Err(e);
let mut done: Vec<&FileMove> = Vec::new();
for m in moves {
if let Err(e) = ide_vfs::rename(&m.from, &m.to) {
for d in done.iter().rev() {
let _ = ide_vfs::rename(&d.to, &d.from);
}
restore(documents, &paths, &before);
return Err(e);
}
done.push(m);
}
let mut edited = Vec::with_capacity(paths.len());
for (p, before) in paths.iter().zip(before) {
@@ -191,21 +196,27 @@ impl FileMoves {
.ok_or_else(|| IdeError::not_found("the refactoring can no longer be undone"))?;
records.remove(i)
};
let back: Vec<_> =
record.moves.iter().rev().map(|m| FileMove { from: m.to.clone(), to: m.from.clone() }).collect();
if let Err(e) = crate::file_ops::move_files(&back) {
self.records.lock().push(record);
return Err(e);
for m in &record.moves {
if !m.to.exists() {
return Err(IdeError::not_found(format!("{} no longer exists", m.to.display())));
}
if m.from.exists() {
return Err(IdeError::new(
ide_core::error::ErrorKind::AlreadyExists,
format!("{} exists again", m.from.display()),
));
}
}
let mut outcome = UndoOutcome { moves: back.clone(), conflicts: Vec::new() };
let mut outcome = UndoOutcome::default();
for m in record.moves.iter().rev() {
ide_vfs::rename(&m.to, &m.from)?;
outcome.moves.push(FileMove { from: m.to.clone(), to: m.from.clone() });
}
let back: Vec<FileMove> = outcome.moves.clone();
for f in &record.edited {
let path = moved_path(&f.path, &back);
match read_text(documents, &path) {
Ok(now) if now == f.after => {
if write_text(documents, &path, &f.before).is_err() {
outcome.conflicts.push(path.into());
}
}
Ok(now) if now == f.after => write_text(documents, &path, &f.before)?,
Ok(now) if now == f.before => {}
_ => outcome.conflicts.push(path.into()),
}
-94
Переглянути файл
@@ -1,94 +0,0 @@
//! Project-aware file operations. Plain files retain the VFS behavior; project
//! rules may expand a resource operation before the common transaction runs.
use crate::FileMove;
use ide_core::{IdeError, IdeResult, WorkspaceEdit};
use std::path::{Path, PathBuf};
mod project;
pub fn copy(from: &Path, to: &Path) -> IdeResult<()> {
project::copy(from, to).unwrap_or_else(|| ide_vfs::copy(from, to))
}
pub fn delete(path: &Path) -> IdeResult<()> {
project::delete(path).unwrap_or_else(|| ide_vfs::delete(path))
}
/// Files Local History must capture before deletion.
pub fn deletion_paths(path: &Path) -> IdeResult<Vec<PathBuf>> {
project::deletion_paths(path).unwrap_or_else(|| Ok(vec![path.to_path_buf()]))
}
pub fn rename(from: &Path, to: &Path) -> IdeResult<()> {
if !project::has_move_rules(from, to) {
return ide_vfs::rename(from, to);
}
let moves = expand_moves(&[FileMove { from: from.into(), to: to.into() }])?;
move_files(&moves)
}
pub(crate) fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
let expanded = project::expand_moves(moves)?;
validate_moves(&expanded)?;
Ok(expanded)
}
fn path_key(path: &Path) -> PathBuf {
#[cfg(windows)]
return path.to_string_lossy().replace('\\', "/").to_lowercase().into();
#[cfg(not(windows))]
path.to_path_buf()
}
fn validate_moves(moves: &[FileMove]) -> IdeResult<()> {
let mut sources = std::collections::HashSet::new();
let mut targets = std::collections::HashSet::new();
for m in moves {
ide_vfs::validate_rename(&m.from, &m.to)?;
let from = path_key(&m.from);
let to = path_key(&m.to);
if to != from && to.starts_with(&from) {
return Err(IdeError::invalid("Cannot move a directory into itself"));
}
if !sources.insert(from) || !targets.insert(to) {
return Err(IdeError::invalid("Duplicate source or destination in file operation"));
}
}
Ok(())
}
pub(crate) fn move_files(moves: &[FileMove]) -> IdeResult<()> {
execute_moves(moves, &|from, to| project::rename_entry(from, to).unwrap_or_else(|| ide_vfs::rename(from, to)))
}
/// Executes an expanded batch. Rollback never overwrites a newly occupied path;
/// failures report where the caller can recover the remaining files.
pub(crate) fn execute_moves(moves: &[FileMove], rename: &dyn Fn(&Path, &Path) -> IdeResult<()>) -> IdeResult<()> {
validate_moves(moves)?;
for (i, m) in moves.iter().enumerate() {
if let Err(mut error) = rename(&m.from, &m.to) {
for done in moves[..i].iter().rev() {
if let Err(rollback) =
ide_vfs::validate_rename(&done.to, &done.from).and_then(|()| rename(&done.to, &done.from))
{
error.message.push_str(&format!(
"; rollback {} -> {} failed: {rollback}",
done.to.display(),
done.from.display()
));
}
}
return Err(error);
}
}
Ok(())
}
pub(crate) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> {
project::validate_workspace_edit_paths(edit)
}
pub(crate) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> {
project::expand_workspace_edit(edit)
}
-52
Переглянути файл
@@ -1,52 +0,0 @@
//! The integration boundary for project-specific resource rules. These rules
//! never replace the plain VFS path unless an operation touches project assets.
use crate::{FileMove, unity::assets};
use ide_core::{FileEdit, IdeResult, WorkspaceEdit};
use std::path::{Path, PathBuf};
pub(super) fn has_move_rules(from: &Path, to: &Path) -> bool {
assets::project_root(from).is_some() || assets::project_root(to).is_some()
}
pub(super) fn copy(from: &Path, to: &Path) -> Option<IdeResult<()>> {
has_move_rules(from, to).then(|| assets::copy(from, to))
}
pub(super) fn delete(path: &Path) -> Option<IdeResult<()>> {
assets::project_root(path).map(|_| assets::delete(path))
}
pub(super) fn deletion_paths(path: &Path) -> Option<IdeResult<Vec<PathBuf>>> {
assets::project_root(path).map(|_| assets::deletion_paths(path))
}
pub(super) fn rename_entry(from: &Path, to: &Path) -> Option<IdeResult<()>> {
has_move_rules(from, to).then(|| assets::move_one(from, to))
}
pub(super) fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
if moves.iter().any(|m| has_move_rules(&m.from, &m.to)) { assets::expand_moves(moves) } else { Ok(moves.to_vec()) }
}
fn has_edit_rules(edit: &WorkspaceEdit) -> bool {
edit.files.iter().any(|file| match file {
FileEdit::Rename { from, to } => has_move_rules(from, to),
FileEdit::Delete { path, .. } => assets::project_root(path).is_some(),
_ => false,
})
}
pub(super) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> {
if has_edit_rules(edit) {
assets::validate_workspace_edit_paths(edit)?;
}
Ok(())
}
pub(super) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> {
if has_edit_rules(edit) {
assets::expand_workspace_edit(edit)?;
}
Ok(())
}
-2
Переглянути файл
@@ -7,7 +7,6 @@ pub mod documents;
pub mod editorconfig;
pub mod file_defaults;
pub mod file_moves;
pub mod file_ops;
pub mod file_refs;
pub mod history;
pub mod memory;
@@ -16,7 +15,6 @@ pub mod recent;
pub mod roots;
pub mod settings;
pub mod text_refs;
pub mod unity;
pub mod window_groups;
pub mod workspace;
+1 -64
Переглянути файл
@@ -62,10 +62,7 @@ const ROOT_NAMES: &[&str] = &[
pub fn affects_roots(rel: &Path) -> bool {
let mut names = rel.iter().rev().filter_map(|c| c.to_str());
let Some(name) = names.next() else { return false };
BUILD_FILES.contains(&name)
|| ROOT_NAMES.contains(&name)
|| matches!(name, "Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt")
|| names.next() == Some("src")
BUILD_FILES.contains(&name) || ROOT_NAMES.contains(&name) || names.next() == Some("src")
}
/// Detects roots below `root`; paths in the result are unique and sorted.
@@ -80,29 +77,6 @@ pub fn detect(root: &Path) -> Vec<SourceFolder> {
break;
}
let dir = if rel.is_empty() { root.to_path_buf() } else { root.join(&rel) };
if crate::unity::is_project(&dir) {
for (name, kind) in
[("Assets", RootKind::Source), ("Packages", RootKind::Source), ("ProjectSettings", RootKind::Resources)]
{
if dir.join(name).is_dir() {
out.push(SourceFolder {
path: join(&rel, name),
kind,
detected_from: Some(join(&rel, crate::unity::VERSION_FILE)),
});
}
}
// Exclude before the index/watchers start, even if Unity creates its caches later.
for name in crate::unity::GENERATED_DIRS {
out.push(SourceFolder {
path: join(&rel, name),
kind: RootKind::Excluded,
detected_from: Some(join(&rel, crate::unity::VERSION_FILE)),
});
}
// Assets and PackageCache may contain arbitrary build files; they aren't nested modules.
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
let mut files: Vec<String> = Vec::new();
let mut dirs: Vec<String> = Vec::new();
@@ -270,43 +244,6 @@ mod tests {
roots.iter().map(|r| format!("{}={:?}@{}", r.path, r.kind, r.detected_from.as_deref().unwrap_or(""))).collect()
}
#[test]
fn unity_roots_exclude_future_caches_and_do_not_scan_assets_as_modules() {
let dir = tree(&[
"Assets/Scripts/Player.cs",
"Assets/Plugins/Tool/package.json",
"ProjectSettings/ProjectVersion.txt",
"Packages/manifest.json",
"Library/PackageCache/tool/Cargo.toml",
]);
let roots = detect(dir.path());
assert!(roots.iter().any(|r| r.path == "Assets" && r.kind == RootKind::Source));
assert!(roots.iter().any(|r| r.path == "Packages" && r.kind == RootKind::Source));
assert!(!roots.iter().any(|r| r.path.starts_with("Assets/") || r.path.starts_with("Library/")));
let effective = super::super::ProjectRoots::compute(roots, &Default::default());
for name in crate::unity::GENERATED_DIRS {
assert!(effective.excluded_globs().contains(&format!("/{name}")));
}
for path in ["Assets", "Packages", "ProjectSettings", crate::unity::VERSION_FILE] {
assert!(affects_roots(Path::new(path)));
}
}
#[test]
fn nested_unity_roots_are_anchored_without_excluding_other_projects() {
let dir = tree(&[
"game/Assets/Scripts/Player.cs",
"game/ProjectSettings/ProjectVersion.txt",
"server/Cargo.toml",
"server/src/main.rs",
"Library/Source.cs",
]);
let roots = super::super::ProjectRoots::compute(detect(dir.path()), &Default::default());
assert!(roots.excluded_globs().contains(&"/game/Library".to_owned()));
assert!(!roots.excluded_globs().contains(&"/Library".to_owned()));
assert!(roots.roots.iter().any(|r| r.path == "server/src" && r.kind == RootKind::Source));
}
#[test]
fn maven_conventional_roots_and_target() {
let dir = tree(&[
-94
Переглянути файл
@@ -1,94 +0,0 @@
//! Unity project identity. Generated C# solutions are optional, not root markers.
pub(crate) mod assets;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::io::Read;
use std::path::Path;
pub const VERSION_FILE: &str = "ProjectSettings/ProjectVersion.txt";
/// Root-relative caches and build outputs, including folders not created yet.
pub const GENERATED_DIRS: &[&str] = &["Library", "Temp", "Obj", "obj", "Logs", "Build", "Builds", "UserSettings"];
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UnityProjectInfo {
pub editor_version: Option<String>,
/// File names relative to the Unity root, in stable order.
pub solutions: Vec<String>,
pub projects: Vec<String>,
}
pub fn is_project(root: &Path) -> bool {
root.join("Assets").is_dir() && root.join(VERSION_FILE).is_file()
}
pub fn detect(root: &Path) -> Option<UnityProjectInfo> {
if !is_project(root) {
return None;
}
let mut text = String::new();
let _ = std::fs::File::open(root.join(VERSION_FILE)).and_then(|f| f.take(64 * 1024).read_to_string(&mut text));
let editor_version = text.lines().find_map(|line| {
let (key, value) = line.trim_start_matches('\u{feff}').split_once(':')?;
let value = value.trim();
(key.trim() == "m_EditorVersion" && !value.is_empty() && value.len() <= 128).then(|| value.to_owned())
});
let mut info = UnityProjectInfo { editor_version, solutions: Vec::new(), projects: Vec::new() };
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|t| t.is_file()) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
match entry.path().extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
Some("sln" | "slnx") => info.solutions.push(name),
Some("csproj") => info.projects.push(name),
_ => {}
}
}
}
info.solutions.sort();
info.projects.sort();
Some(info)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_fresh_and_legacy_projects_without_packages_or_solutions() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::create_dir(root.join("Assets")).unwrap();
assert!(detect(root).is_none());
std::fs::create_dir(root.join("ProjectSettings")).unwrap();
std::fs::write(
root.join(VERSION_FILE),
"\u{feff}m_EditorVersion: 2022.3.62f1\r\nm_EditorVersionWithRevision: ignored\r\n",
)
.unwrap();
let info = detect(root).unwrap();
assert_eq!(info.editor_version.as_deref(), Some("2022.3.62f1"));
assert!(info.solutions.is_empty() && info.projects.is_empty());
std::fs::write(root.join("Game.sln"), "").unwrap();
std::fs::write(root.join("Game.slnx"), "").unwrap();
std::fs::write(root.join("Assembly-CSharp.csproj"), "").unwrap();
std::fs::create_dir(root.join("NotAProject.csproj")).unwrap();
let info = detect(root).unwrap();
assert_eq!(info.solutions, ["Game.sln", "Game.slnx"]);
assert_eq!(info.projects, ["Assembly-CSharp.csproj"]);
std::fs::write(root.join(VERSION_FILE), "malformed").unwrap();
assert_eq!(detect(root).unwrap().editor_version, None);
}
#[test]
fn ordinary_csharp_projects_are_not_unity_projects() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("App.csproj"), "").unwrap();
std::fs::create_dir(temp.path().join("Assets")).unwrap();
assert!(detect(temp.path()).is_none());
}
}
-254
Переглянути файл
@@ -1,254 +0,0 @@
//! Filesystem operations for assets under a Unity project's Assets directory.
//! Metadata moves with the asset; duplication gets new GUIDs before publication.
use crate::{file_moves::FileMove, file_ops::execute_moves};
use ide_core::{IdeError, IdeResult, error::ErrorKind};
use std::{
collections::HashMap,
fs,
path::{Component, Path, PathBuf},
};
mod duplicate;
mod workspace_edit;
pub(crate) use workspace_edit::{expand_workspace_edit, validate_workspace_edit_paths};
#[cfg(test)]
mod tests;
pub fn meta_path(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".meta");
name.into()
}
/// Includes Assets itself so callers can protect the asset root.
pub fn project_root(path: &Path) -> Option<PathBuf> {
path.ancestors().find_map(|p| {
(p.file_name().is_some_and(|n| if cfg!(windows) { n.eq_ignore_ascii_case("Assets") } else { n == "Assets" })
&& p.parent().is_some_and(super::is_project))
.then(|| p.parent().unwrap().to_path_buf())
})
}
fn exists(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok()
}
fn is_meta(path: &Path) -> bool {
path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("meta"))
}
fn validate_asset(path: &Path) -> IdeResult<()> {
if let Some(root) = project_root(path) {
if key(path) == key(&root.join("Assets")) {
return Err(IdeError::invalid("The Unity Assets root cannot be moved, copied or deleted"));
}
if is_meta(path) && exists(&path.with_extension("")) {
return Err(IdeError::invalid(
"Select the Unity asset instead of its .meta file; metadata is handled together",
));
}
}
no_links(path)
}
fn no_links(path: &Path) -> IdeResult<()> {
if path.components().any(|c| matches!(c, Component::ParentDir)) {
return Err(IdeError::invalid("Unity asset operations require paths without '..'"));
}
// Trust the opened project's location, including a linked root or parent
// (macOS /var and /tmp). Check inside it, including Library staging paths.
// The outermost root prevents a nested Unity project from hiding a link.
let root = path.ancestors().filter(|p| super::is_project(p)).last();
for p in path.ancestors().take_while(|p| Some(*p) != root) {
match fs::symlink_metadata(p) {
Ok(m) if is_link(&m) => {
return Err(IdeError::invalid(format!("Unity asset operations do not follow links: {}", p.display())));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
fn is_link(metadata: &fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
// Also reject directory junctions and other reparse points.
metadata.file_attributes() & 0x400 != 0
}
#[cfg(not(windows))]
{
metadata.file_type().is_symlink()
}
}
fn key(path: &Path) -> String {
let s = path.to_string_lossy().replace('\\', "/");
if cfg!(windows) { s.to_lowercase() } else { s }
}
fn vacant(from: &Path, to: &Path) -> IdeResult<()> {
if exists(to) {
if !exists(from) {
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{} already exists", to.display())));
}
ide_vfs::validate_rename(from, to)?;
}
Ok(())
}
/// Expand before applying reference edits, so orphan metadata conflicts cannot
/// leave a partially refactored asset. Metadata goes first on publication.
pub fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
let mut out = Vec::new();
for m in moves {
let unity = project_root(&m.from).is_some() || project_root(&m.to).is_some();
if unity {
// A caller may explicitly select both an asset and its metadata.
if is_meta(&m.from)
&& moves
.iter()
.any(|a| meta_path(&a.from) == m.from.to_path_buf() && meta_path(&a.to) == m.to.to_path_buf())
{
continue;
}
validate_asset(&m.from)?;
validate_asset(&m.to)?;
let from = meta_path(&m.from);
let to = meta_path(&m.to);
no_links(&from)?;
no_links(&to)?;
vacant(&from, &to)?;
if exists(&from) {
out.push(FileMove { from: from.into(), to: to.into() });
}
}
out.push(m.clone());
}
Ok(out)
}
pub(crate) fn move_one(from: &Path, to: &Path) -> IdeResult<()> {
if project_root(from).is_some() || project_root(to).is_some() {
no_links(from)?;
no_links(to)?;
if let Some(parent) = to.parent() {
fs::create_dir_all(parent)?;
}
// A failed cross-volume copy/delete cannot be rolled back reliably.
// Keep paired Unity moves atomic per entry and let rename report it.
fs::rename(from, to)?;
Ok(())
} else {
ide_vfs::rename(from, to)
}
}
/// The paths Local History must snapshot before an asset deletion.
pub fn deletion_paths(path: &Path) -> IdeResult<Vec<PathBuf>> {
validate_asset(path)?;
let meta = meta_path(path);
no_links(&meta)?;
let mut paths = vec![path.to_path_buf()];
if exists(&meta) {
paths.push(meta);
}
Ok(paths)
}
fn staging(root: &Path) -> IdeResult<PathBuf> {
let parent = root.join("Library/OpenIDEA/AssetOperations");
no_links(&parent)?;
fs::create_dir_all(&parent)?;
let dir = parent.join(uuid::Uuid::new_v4().simple().to_string());
fs::create_dir(&dir)?;
Ok(dir)
}
pub fn delete(path: &Path) -> IdeResult<()> {
delete_with(path, &ide_vfs::delete)
}
fn delete_with(path: &Path, remove: &dyn Fn(&Path) -> IdeResult<()>) -> IdeResult<()> {
let paths = deletion_paths(path)?;
let root = project_root(path).ok_or_else(|| IdeError::invalid("Expected a Unity asset path"))?;
let stage = staging(&root)?;
let payload = stage.join("files");
fs::create_dir(&payload)?;
let moves: Vec<_> = paths
.iter()
.map(|p| FileMove { from: p.clone().into(), to: payload.join(p.file_name().unwrap()).into() })
.collect();
// Recycle-bin restore returns the group to staging. Keep its destinations
// with it so originals can also be recovered without the IDE's history.
let manifest = stage.join("restore-paths.json");
fs::write(&manifest, serde_json::to_vec_pretty(&paths).map_err(|e| IdeError::internal(e.to_string()))?)?;
// Never automatically delete this staging directory on failure: it may
// contain originals when rollback fails.
if let Err(e) = execute_moves(&moves, &move_one) {
if fs::remove_dir(&payload).is_ok() {
let _ = fs::remove_file(&manifest);
let _ = fs::remove_dir(&stage);
}
return Err(e);
}
if let Err(mut e) = remove(&stage) {
let back: Vec<_> = moves.iter().rev().map(|m| FileMove { from: m.to.clone(), to: m.from.clone() }).collect();
if let Err(rollback) = execute_moves(&back, &move_one) {
e.message.push_str(&format!("; recovery files at {}: {rollback}", stage.display()));
}
if fs::remove_dir(&payload).is_ok() {
let _ = fs::remove_file(&manifest);
let _ = fs::remove_dir(&stage);
}
return Err(e);
}
Ok(())
}
pub fn copy(from: &Path, to: &Path) -> IdeResult<()> {
let root = project_root(to)
.or_else(|| project_root(from))
.ok_or_else(|| IdeError::invalid("Expected a Unity asset path"))?;
validate_asset(from)?;
validate_asset(to)?;
if key(from) == key(to) || key(to).starts_with(&(key(from) + "/")) {
return Err(IdeError::invalid("Cannot copy an asset onto or into itself"));
}
if exists(to) {
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{} already exists", to.display())));
}
let src_meta = meta_path(from);
let dst_meta = meta_path(to);
no_links(&src_meta)?;
no_links(&dst_meta)?;
// Copies never overwrite metadata, including orphan destination metadata.
if exists(&dst_meta) {
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{} already exists", dst_meta.display())));
}
let stage = staging(&root)?;
let result = (|| {
let asset = stage.join(from.file_name().ok_or_else(|| IdeError::invalid("Missing asset name"))?);
duplicate::copy_tree(from, &asset)?;
let meta = meta_path(&asset);
if exists(&src_meta) {
duplicate::copy_tree(&src_meta, &meta)?;
}
if project_root(to).is_some() {
duplicate::regenerate(&stage)?;
}
let mut moves = Vec::new();
if exists(&meta) {
moves.push(FileMove { from: meta.into(), to: dst_meta.into() });
}
moves.push(FileMove { from: asset.into(), to: to.into() });
execute_moves(&moves, &move_one)
})();
// Only disposable copies live here, never the source assets.
let _ = fs::remove_dir_all(&stage);
result
}
-236
Переглянути файл
@@ -1,236 +0,0 @@
use super::*;
use std::io::Read;
const TEXT_LIMIT: u64 = 32 * 1024 * 1024;
pub(super) fn copy_tree(from: &Path, to: &Path) -> IdeResult<()> {
no_links(from)?;
let metadata = fs::symlink_metadata(from)?;
if metadata.is_dir() {
fs::create_dir(to)?;
for entry in fs::read_dir(from)? {
let entry = entry?;
copy_tree(&entry.path(), &to.join(entry.file_name()))?;
}
} else if metadata.is_file() {
fs::copy(from, to)?;
} else {
return Err(IdeError::invalid(format!("Not a regular Unity asset: {}", from.display())));
}
Ok(())
}
fn files(dir: &Path, out: &mut Vec<PathBuf>) -> IdeResult<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
files(&entry.path(), out)?;
} else {
out.push(entry.path());
}
}
Ok(())
}
fn read_text(path: &Path) -> IdeResult<String> {
let mut bytes = Vec::new();
fs::File::open(path)?.take(TEXT_LIMIT + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > TEXT_LIMIT {
return Err(IdeError::invalid(format!(
"Unity serialized asset exceeds the 32 MiB copy limit: {}",
path.display()
)));
}
String::from_utf8(bytes)
.map_err(|_| IdeError::invalid(format!("Cannot rewrite binary Unity metadata: {}", path.display())))
}
fn guid(value: &str) -> bool {
value.len() == 32 && value.bytes().all(|b| b.is_ascii_hexdigit()) && value.bytes().any(|b| b != b'0')
}
pub(super) fn regenerate(stage: &Path) -> IdeResult<()> {
let mut paths = Vec::new();
files(stage, &mut paths)?;
let mut ids = HashMap::new();
for path in paths.iter().filter(|p| is_meta(p)) {
let text = read_text(path)?;
let values: Vec<_> = text
.trim_start_matches('\u{feff}')
.lines()
.filter_map(|l| l.strip_prefix("guid:").map(str::trim))
.collect();
if values.len() != 1 || !guid(values[0]) {
return Err(IdeError::invalid(format!("Missing or invalid GUID in {}", path.display())));
}
if ids.insert(values[0].to_ascii_lowercase(), uuid::Uuid::new_v4().simple().to_string()).is_some() {
return Err(IdeError::invalid("Duplicate source GUIDs; resolve them in Unity before copying"));
}
}
for path in paths {
let ext = path.extension().unwrap_or_default().to_string_lossy().to_ascii_lowercase();
if ext == "asmdef" || ext == "asmref" {
let text = read_text(&path)?;
let mut json: serde_json::Value =
serde_json::from_str(&text).map_err(|e| IdeError::invalid(e.to_string()))?;
if rewrite_json(&mut json, &ids) {
fs::write(&path, serde_json::to_string_pretty(&json).map_err(|e| IdeError::internal(e.to_string()))?)?;
}
continue;
}
let mut header = [0; 32];
let n = fs::File::open(&path)?.read(&mut header)?;
let header = String::from_utf8_lossy(&header[..n]);
let header = header.trim_start_matches('\u{feff}');
let yaml = header.starts_with("%YAML") || header.starts_with("--- !u!");
if !yaml && !is_meta(&path) {
// These formats may be binary under Mixed/Force Binary. We cannot
// safely preserve internal references without Unity deserialization.
if matches!(
ext.as_str(),
"asset"
| "prefab"
| "unity"
| "mat"
| "anim"
| "controller"
| "overridecontroller"
| "playable"
| "mask"
| "rendertexture"
| "physicmaterial"
| "physicsmaterial2d"
| "lighting"
| "terrainlayer"
| "guiskin"
| "flare"
| "cubemap"
| "spriteatlas"
| "spriteatlasv2"
| "preset"
| "shadervariants"
| "giparams"
| "lightingdata"
| "fontsettings"
| "texture2d"
| "mesh"
) {
return Err(IdeError::invalid(format!(
"Cannot copy binary Unity serialized asset {}; use Force Text serialization in Unity",
path.display()
)));
}
continue;
}
let before = read_text(&path)?;
let after = rewrite_yaml(&before, &ids);
if before != after {
fs::write(&path, after)?;
}
}
Ok(())
}
fn rewrite_json(value: &mut serde_json::Value, ids: &HashMap<String, String>) -> bool {
match value {
serde_json::Value::String(s) => {
if let Some(id) = s.strip_prefix("GUID:").and_then(|id| ids.get(&id.to_ascii_lowercase())) {
*s = format!("GUID:{id}");
true
} else {
false
}
}
serde_json::Value::Array(a) => {
let mut changed = false;
for v in a {
changed |= rewrite_json(v, ids);
}
changed
}
serde_json::Value::Object(o) => {
let mut changed = false;
for v in o.values_mut() {
changed |= rewrite_json(v, ids);
}
changed
}
_ => false,
}
}
/// Patch GUID scalar tokens without reserializing Unity's YAML dialect. Quoted
/// strings, comments and indented block scalars are not asset references.
fn rewrite_yaml(text: &str, ids: &HashMap<String, String>) -> String {
let mut bytes = text.as_bytes().to_vec();
let mut offset = 0;
let mut block_indent = None;
let mut quote = None;
for line in text.split_inclusive('\n') {
let raw = line.as_bytes();
let indent = raw.iter().take_while(|b| **b == b' ').count();
if block_indent.is_some_and(|n| indent > n || line.trim().is_empty()) {
offset += raw.len();
continue;
}
block_indent = None;
let mut i = 0;
while i < raw.len() {
let b = raw[i];
if let Some(q) = quote {
if q == b'"' && b == b'\\' {
i += 2;
continue;
}
if b == q {
if q == b'\'' && raw.get(i + 1) == Some(&q) {
i += 2;
continue;
}
quote = None;
}
i += 1;
continue;
}
if b == b'#' && (i == 0 || raw[i - 1].is_ascii_whitespace()) {
break;
}
if b == b'"' || b == b'\'' {
let prefix = line[..i].trim();
// Apostrophes inside plain scalars (e.g. Hero's Sword) do not
// open a quoted scalar or suppress references on later lines.
if prefix.is_empty() || prefix.ends_with([':', '-', '[', '{', ',']) {
quote = Some(b);
i += 1;
continue;
}
}
if (b == b'|' || b == b'>') && line[..i].trim_end().ends_with([':', '-']) {
block_indent = Some(indent);
break;
}
if raw[i..].starts_with(b"guid:") {
let prefix = line[..i].trim_start_matches('\u{feff}').trim_end();
if prefix.is_empty() || prefix.ends_with(['{', ',']) {
let start = i + 5 + raw[i + 5..].iter().take_while(|b| **b == b' ' || **b == b'\t').count();
let end = start + 32;
if end <= raw.len()
&& raw[start..end].iter().all(u8::is_ascii_hexdigit)
&& (end == raw.len()
|| raw[end].is_ascii_whitespace()
|| matches!(raw[end], b',' | b'}' | b'#'))
&& let Some(new) = ids.get(&line[start..end].to_ascii_lowercase())
{
bytes[offset + start..offset + end].copy_from_slice(new.as_bytes());
i = end;
continue;
}
}
}
// Non-ASCII bytes cannot begin any token handled above.
i += 1;
}
offset += raw.len();
}
String::from_utf8(bytes).expect("GUID substitutions preserve UTF-8")
}
-217
Переглянути файл
@@ -1,217 +0,0 @@
use super::*;
use crate::file_ops::{move_files, rename};
use crate::{documents::Documents, file_moves::FileMoves};
use ide_core::{LanguageId, WorkspaceEdit};
use std::sync::Arc;
mod links;
const A: &str = "11111111111111111111111111111111";
const B: &str = "22222222222222222222222222222222";
const C: &str = "33333333333333333333333333333333";
fn project() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir(tmp.path().join("Assets")).unwrap();
fs::create_dir(tmp.path().join("ProjectSettings")).unwrap();
fs::write(tmp.path().join(super::super::VERSION_FILE), "m_EditorVersion: 6000.0.1f1").unwrap();
tmp
}
fn asset(root: &Path, name: &str, content: &str, id: &str) -> PathBuf {
let path = root.join("Assets").join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, content).unwrap();
fs::write(meta_path(&path), format!("fileFormatVersion: 2\r\nguid: {id}\r\nImporter:\r\n setting: keep\r\n"))
.unwrap();
path
}
fn id(path: &Path) -> String {
fs::read_to_string(meta_path(path))
.unwrap()
.lines()
.find_map(|l| l.strip_prefix("guid: ").map(str::to_owned))
.unwrap()
}
#[test]
fn rename_preserves_metadata_and_undo_retries_after_conflict() {
let tmp = project();
let src = asset(tmp.path(), "Player.cs", "class Player {}", A);
let dst = src.with_file_name("Hero.cs");
let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
let stack = FileMoves::new();
let moves = [FileMove { from: src.clone().into(), to: dst.clone().into() }];
let token = stack.perform(&docs, None, &moves, &WorkspaceEdit::default(), "Rename", &|_| Ok(())).unwrap();
assert_eq!(id(&dst), A);
assert!(!meta_path(&src).exists());
fs::write(meta_path(&src), "conflict").unwrap();
assert!(stack.undo(&docs, token).is_err());
assert!(stack.can_undo(token));
assert!(dst.exists());
fs::remove_file(meta_path(&src)).unwrap();
let undone = stack.undo(&docs, token).unwrap();
assert_eq!(undone.moves.len(), 2);
assert_eq!(id(&src), A);
assert!(!dst.exists());
}
#[test]
fn orphan_metadata_conflicts_and_standalone_metadata_are_protected() {
let tmp = project();
let src = asset(tmp.path(), "Player.cs", "code", A);
let dst = src.with_file_name("Hero.cs");
fs::write(meta_path(&dst), "orphan").unwrap();
assert!(rename(&src, &dst).is_err());
assert!(copy(&src, &dst).is_err());
assert!(src.exists() && !dst.exists());
assert_eq!(id(&src), A);
assert!(rename(&meta_path(&src), &src.with_file_name("Other.meta")).is_err());
assert!(delete_with(&meta_path(&src), &|_| panic!("must reject before deletion")).is_err());
assert!(rename(&tmp.path().join("Assets"), &tmp.path().join("Renamed")).is_err());
}
#[test]
fn folder_copy_regenerates_guids_and_only_internal_references() {
let tmp = project();
let root = tmp.path();
let text = format!(
"%YAML 1.1\n--- !u!1 &1\nObject:\n internal: {{fileID: 1, guid: {B}, type: 3}}\n external: {{fileID: 2, guid: {C}, type: 3}}\n quoted: \"guid: {B}\"\n block: |\n guid: {B}\n"
);
let src = asset(root, "Group/Scene.prefab", &text, A);
asset(root, "Group/Script.cs", "// untouched", B);
fs::write(root.join("Assets/Group.meta"), format!("guid: {C}\nfolderAsset: yes\n")).unwrap();
// An actual external id not copied into Group.
let external = "44444444444444444444444444444444";
fs::write(
&src,
text.replace(&format!("external: {{fileID: 2, guid: {C}"), &format!("external: {{fileID: 2, guid: {external}")),
)
.unwrap();
fs::write(root.join("Assets/Group/Assembly.asmdef"), format!(r#"{{"references":["GUID:{B}","GUID:{external}"]}}"#))
.unwrap();
let to = root.join("Assets/Group Copy");
copy(&root.join("Assets/Group"), &to).unwrap();
let script = id(&to.join("Script.cs"));
assert_ne!(script, B);
assert_ne!(id(&to.join("Scene.prefab")), A);
assert_ne!(id(&to), C);
assert_eq!(id(&src), A);
let copied = fs::read_to_string(to.join("Scene.prefab")).unwrap();
assert!(copied.contains(&format!("internal: {{fileID: 1, guid: {script}")));
assert!(copied.contains(&format!("external: {{fileID: 2, guid: {external}")));
assert!(copied.contains(&format!("quoted: \"guid: {B}\"")));
assert!(copied.contains(&format!("block: |\n guid: {B}")));
assert!(fs::read_to_string(meta_path(&to.join("Script.cs"))).unwrap().contains("setting: keep\r\n"));
let assembly = fs::read_to_string(to.join("Assembly.asmdef")).unwrap();
assert!(assembly.contains(&format!("GUID:{script}")) && assembly.contains(external));
}
#[test]
fn invalid_or_binary_copy_never_publishes_partial_assets() {
let tmp = project();
let src = asset(tmp.path(), "Group/Data.asset", "binary data", A);
let from = src.parent().unwrap();
let to = from.with_file_name("Copy");
assert!(copy(from, &to).is_err());
assert!(!to.exists() && !meta_path(&to).exists());
assert_eq!(id(&src), A);
fs::write(&src, "%YAML 1.1\n--- !u!1 &1\n").unwrap();
fs::write(meta_path(&src), "guid: invalid\n").unwrap();
assert!(copy(from, &to).is_err());
assert!(!to.exists());
}
#[test]
fn deletion_groups_sidecar_and_restores_both_on_trash_failure() {
let tmp = project();
let src = asset(tmp.path(), "Player.cs", "code", A);
assert_eq!(deletion_paths(&src).unwrap(), vec![src.clone(), meta_path(&src)]);
let error = delete_with(&src, &|stage| {
assert!(stage.join("files/Player.cs").exists());
assert!(stage.join("files/Player.cs.meta").exists());
let destinations: Vec<PathBuf> =
serde_json::from_slice(&fs::read(stage.join("restore-paths.json")).unwrap()).unwrap();
assert_eq!(destinations, vec![src.clone(), meta_path(&src)]);
Err(IdeError::internal("trash unavailable"))
})
.unwrap_err();
assert!(error.message.contains("trash unavailable"));
assert!(src.exists());
assert_eq!(id(&src), A);
delete_with(&src, &ide_vfs::delete_permanently).unwrap();
assert!(!src.exists() && !meta_path(&src).exists());
}
#[test]
fn selecting_both_asset_and_sidecar_does_not_move_twice() {
let tmp = project();
let src = asset(tmp.path(), "Player.cs", "code", A);
let dst = src.with_file_name("Hero.cs");
let expanded = expand_moves(&[
FileMove { from: src.clone().into(), to: dst.clone().into() },
FileMove { from: meta_path(&src).into(), to: meta_path(&dst).into() },
])
.unwrap();
assert_eq!(expanded.len(), 2);
move_files(&expanded).unwrap();
assert_eq!(id(&dst), A);
}
#[test]
fn non_unity_operations_do_not_touch_adjacent_metadata() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("file.txt");
let dst = tmp.path().join("copy.txt");
fs::write(&src, "text").unwrap();
fs::write(meta_path(&src), "unrelated").unwrap();
crate::file_ops::copy(&src, &dst).unwrap();
assert!(!meta_path(&dst).exists());
rename(&src, &tmp.path().join("moved.txt")).unwrap();
assert!(meta_path(&src).exists());
}
#[test]
fn failed_second_move_rolls_back_metadata() {
let tmp = project();
let src = asset(tmp.path(), "Player.cs", "code", A);
let meta_dst = src.with_file_name("Moved.cs.meta");
let blocker = tmp.path().join("Assets/blocked");
fs::write(&blocker, "not a directory").unwrap();
let moves = [
FileMove { from: meta_path(&src).into(), to: meta_dst.clone().into() },
FileMove { from: src.clone().into(), to: blocker.join("Moved.cs").into() },
];
assert!(move_files(&moves).is_err());
assert_eq!(id(&src), A);
assert!(src.exists() && !meta_dst.exists());
}
#[test]
fn yaml_scalar_text_does_not_change_reference_detection() {
let tmp = project();
let text = format!(
"%YAML 1.1\n--- !u!1 &1\nObject:\n name: Hero's Sword\n self: {{fileID: 1, guid: {A}, type: 3}}\n text: \"escaped \\\" guid: {A}\"\n multiline: 'value\n guid: {A}'\n selfAgain: {{fileID: 1, guid: {A}, type: 3}}\n"
);
let src = asset(tmp.path(), "Player.prefab", &text, A);
let dst = src.with_file_name("Copied.prefab");
copy(&src, &dst).unwrap();
let new_id = id(&dst);
let copied = fs::read_to_string(&dst).unwrap();
assert!(copied.contains(&format!("self: {{fileID: 1, guid: {new_id}")));
assert!(copied.contains(&format!("selfAgain: {{fileID: 1, guid: {new_id}")));
assert!(copied.contains(&format!("multiline: 'value\n guid: {A}'")));
assert!(copied.contains(&format!("text: \"escaped \\\" guid: {A}\"")));
}
#[cfg(windows)]
#[test]
fn case_only_rename_keeps_metadata() {
let tmp = project();
let src = asset(tmp.path(), "Player.cs", "code", A);
let dst = src.with_file_name("player.cs");
rename(&src, &dst).unwrap();
assert_eq!(id(&dst), A);
assert!(fs::read_dir(dst.parent().unwrap()).unwrap().any(|e| e.unwrap().file_name() == "player.cs.meta"));
}
-233
Переглянути файл
@@ -1,233 +0,0 @@
use super::*;
use ide_core::FileEdit;
fn dir_link(target: &Path, link: &Path) {
#[cfg(unix)]
std::os::unix::fs::symlink(target, link).unwrap();
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let output = std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(link.to_string_lossy().replace('/', "\\"))
.arg(target.to_string_lossy().replace('/', "\\"))
.creation_flags(0x08000000)
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
}
}
#[test]
fn linked_parent_and_project_root_support_asset_operations_and_undo() {
for parent_alias in [false, true] {
let tmp = project();
let aliases = tempfile::tempdir().unwrap();
let link = aliases.path().join("alias");
let root = if parent_alias {
dir_link(tmp.path().parent().unwrap(), &link);
link.join(tmp.path().file_name().unwrap())
} else {
dir_link(tmp.path(), &link);
link.clone()
};
let from = asset(&root, "Player.cs", "class Player {}", A);
let to = root.join("Assets/New/Nested/Hero.cs");
let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
let moves = FileMoves::new();
let token = moves
.perform(
&docs,
None,
&[FileMove { from: from.clone().into(), to: to.clone().into() }],
&WorkspaceEdit::default(),
"Move",
&|_| Ok(()),
)
.unwrap();
assert_eq!(id(&to), A);
moves.undo(&docs, token).unwrap();
assert_eq!(id(&from), A);
copy(&from, &to).unwrap();
assert_ne!(id(&to), A);
assert_eq!(id(&from), A);
delete_with(&to, &ide_vfs::delete_permanently).unwrap();
assert!(!to.exists() && !meta_path(&to).exists());
let intermediate = root.join("Assets/Intermediate.cs");
docs.apply_workspace_edit(&WorkspaceEdit {
files: vec![
FileEdit::Rename { from: from.clone().into(), to: intermediate.clone().into() },
FileEdit::Rename { from: intermediate.into(), to: to.clone().into() },
],
..Default::default()
})
.unwrap();
assert_eq!(id(&to), A);
assert!(!from.exists() && !meta_path(&from).exists());
}
}
#[test]
fn internal_directory_links_reject_asset_operations_before_mutation() {
for relative in ["Assets/Linked", "Library", "Library/OpenIDEA", "Library/OpenIDEA/AssetOperations"] {
let tmp = project();
let outside = tempfile::tempdir().unwrap();
fs::write(outside.path().join("sentinel"), "keep").unwrap();
let from = asset(tmp.path(), "Player.cs", "code", A);
let link = tmp.path().join(relative);
fs::create_dir_all(link.parent().unwrap()).unwrap();
dir_link(outside.path(), &link);
let to = if relative.starts_with("Assets") { link.join("Copy.cs") } else { from.with_file_name("Copy.cs") };
assert!(copy(&from, &to).unwrap_err().message.contains("do not follow links"));
if relative.starts_with("Assets") {
assert!(rename(&from, &to).is_err());
let docs =
Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
assert!(
docs.apply_workspace_edit(&WorkspaceEdit {
files: vec![FileEdit::Rename { from: from.clone().into(), to: to.clone().into() },],
..Default::default()
})
.is_err()
);
let sentinel = link.join("sentinel");
assert!(
docs.apply_workspace_edit(&WorkspaceEdit {
files: vec![FileEdit::Delete { path: sentinel.into(), recursive: false },],
..Default::default()
})
.is_err()
);
} else {
assert!(delete_with(&from, &|_| panic!("must reject before removal")).is_err());
}
assert_eq!(id(&from), A);
assert!(from.exists() && !to.exists() && !meta_path(&to).exists());
assert_eq!(fs::read_to_string(outside.path().join("sentinel")).unwrap(), "keep");
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 1);
}
}
#[test]
fn linked_assets_root_and_recursive_copy_links_are_rejected() {
let tmp = project();
let outside = tempfile::tempdir().unwrap();
fs::remove_dir(tmp.path().join("Assets")).unwrap();
dir_link(outside.path(), &tmp.path().join("Assets"));
let from = asset(tmp.path(), "Player.cs", "code", A);
assert!(rename(&from, &from.with_file_name("Hero.cs")).is_err());
assert!(deletion_paths(&from).is_err());
assert_eq!(id(&from), A);
let tmp = project();
let from = asset(tmp.path(), "Group/Player.cs", "code", A);
dir_link(outside.path(), &tmp.path().join("Assets/Group/Linked"));
let to = tmp.path().join("Assets/Copy");
assert!(copy(from.parent().unwrap(), &to).is_err());
assert!(!to.exists() && !meta_path(&to).exists());
assert_eq!(id(&from), A);
}
#[cfg(unix)]
#[test]
fn linked_asset_and_sidecar_leaves_are_rejected() {
for sidecar in [false, true] {
let tmp = project();
let outside = tempfile::NamedTempFile::new().unwrap();
let from = asset(tmp.path(), "Player.cs", "code", A);
let path = if sidecar { meta_path(&from) } else { from.clone() };
fs::remove_file(&path).unwrap();
std::os::unix::fs::symlink(outside.path(), &path).unwrap();
assert!(rename(&from, &from.with_file_name("Hero.cs")).is_err());
assert!(copy(&from, &from.with_file_name("Copy.cs")).is_err());
assert!(deletion_paths(&from).is_err());
assert!(fs::symlink_metadata(&path).unwrap().file_type().is_symlink());
}
}
#[test]
fn cross_project_operations_validate_each_root_and_keep_metadata() {
let source = project();
let destination = project();
let aliases = tempfile::tempdir().unwrap();
let a = aliases.path().join("source");
let b = aliases.path().join("destination");
dir_link(source.path(), &a);
dir_link(destination.path(), &b);
let from = asset(&a, "Player.cs", "code", A);
let copied = b.join("Assets/Copied.cs");
copy(&from, &copied).unwrap();
assert_ne!(id(&copied), A);
assert_eq!(id(&from), A);
let moved = b.join("Assets/Moved.cs");
rename(&from, &moved).unwrap();
assert_eq!(id(&moved), A);
assert!(!from.exists() && !meta_path(&from).exists());
}
#[test]
fn external_links_do_not_become_trusted_by_importing_into_unity() {
let tmp = project();
let external = tempfile::tempdir().unwrap();
let aliases = tempfile::tempdir().unwrap();
fs::write(external.path().join("File.cs"), "code").unwrap();
let link = aliases.path().join("external");
dir_link(external.path(), &link);
let from = link.join("File.cs");
let to = tmp.path().join("Assets/Imported.cs");
assert!(copy(&from, &to).is_err());
assert!(rename(&from, &to).is_err());
assert!(from.exists() && !to.exists());
}
#[test]
fn folder_rename_cannot_hide_linked_children_in_later_transaction_operations() {
let tmp = project();
let outside = tempfile::tempdir().unwrap();
fs::write(outside.path().join("sentinel"), "keep").unwrap();
let from = tmp.path().join("Assets/Group");
fs::create_dir(&from).unwrap();
dir_link(outside.path(), &from.join("Linked"));
let to = tmp.path().join("Assets/Moved");
let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
let error = docs
.apply_workspace_edit(&WorkspaceEdit {
files: vec![
FileEdit::Rename { from: from.clone().into(), to: to.clone().into() },
FileEdit::Delete {
path: (if cfg!(windows) { tmp.path().join("Assets/moved") } else { to.clone() })
.join("Linked/sentinel")
.into(),
recursive: false,
},
],
..Default::default()
})
.unwrap_err();
assert!(error.message.contains("do not follow links"));
assert!(from.exists() && !to.exists());
assert_eq!(fs::read_to_string(outside.path().join("sentinel")).unwrap(), "keep");
}
#[test]
fn directory_transactions_reject_nested_links_before_snapshotting() {
for delete in [false, true] {
let tmp = project();
let outside = tempfile::tempdir().unwrap();
fs::write(outside.path().join("sentinel"), "keep").unwrap();
let from = tmp.path().join("Assets/Group");
fs::create_dir(&from).unwrap();
dir_link(outside.path(), &from.join("Linked"));
let to = tmp.path().join("Assets/Moved");
let file = if delete {
FileEdit::Delete { path: from.clone().into(), recursive: true }
} else {
FileEdit::Rename { from: from.clone().into(), to: to.clone().into() }
};
let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
let error = docs.apply_workspace_edit(&WorkspaceEdit { files: vec![file], ..Default::default() }).unwrap_err();
assert!(error.message.contains("do not follow links"));
assert!(from.exists() && !to.exists());
assert_eq!(fs::read_to_string(outside.path().join("sentinel")).unwrap(), "keep");
}
}
-99
Переглянути файл
@@ -1,99 +0,0 @@
//! Include sidecars in the document transaction's preflight, recovery and undo.
use super::{exists, fs, is_link, key, meta_path, no_links, project_root, validate_asset};
use ide_core::{FileEdit, IdeError, IdeResult, WorkspaceEdit};
use std::path::Path;
pub(crate) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> {
for file in &edit.files {
let validate = |path: &Path| -> IdeResult<()> {
validate_asset(path)?;
no_links(&meta_path(path))?;
no_nested_links(path)
};
match file {
FileEdit::Rename { from, to } if project_root(from).is_some() || project_root(to).is_some() => {
validate(from)?;
validate(to)?;
}
FileEdit::Delete { path, .. } if project_root(path).is_some() => validate(path)?,
_ => {}
}
}
Ok(())
}
// Transactions snapshot directories recursively. Reject linked descendants
// before backup, including junctions and children renamed by a later operation.
fn no_nested_links(path: &Path) -> IdeResult<()> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
};
if is_link(&metadata) {
return Err(IdeError::invalid(format!("Unity asset operations do not follow links: {}", path.display())));
}
if metadata.is_dir() {
for entry in fs::read_dir(path)? {
no_nested_links(&entry?.path())?;
}
}
Ok(())
}
// Resolve against the state produced by earlier operations without touching disk.
// This also handles renaming a folder before renaming/deleting a child asset.
fn present(path: &Path, preceding: &[FileEdit]) -> bool {
let mut path = path.to_path_buf();
for op in preceding.iter().rev() {
match op {
FileEdit::Rename { from, to } => {
if let Ok(relative) = path.strip_prefix(to) {
// Joining an empty suffix adds a separator to a file path;
// that makes an existing sidecar look like a missing directory.
path = if relative.as_os_str().is_empty() { from.to_path_buf() } else { from.join(relative) };
} else if path.starts_with(from) {
return false;
}
}
FileEdit::Delete { path: deleted, .. } if path.starts_with(deleted) => return false,
FileEdit::Create { path: created, .. } if path == created.to_path_buf() => return true,
_ => {}
}
}
exists(&path)
}
pub(crate) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> {
let mut files = Vec::new();
for file in &edit.files {
match file {
FileEdit::Rename { from, to } if project_root(from).is_some() || project_root(to).is_some() => {
validate_asset(from)?;
validate_asset(to)?;
let source = meta_path(from);
let destination = meta_path(to);
no_links(&source)?;
no_links(&destination)?;
if present(&destination, &files) && key(&source) != key(&destination) {
return Err(IdeError::invalid(format!("{} already exists", destination.display())));
}
if present(&source, &files) {
files.push(FileEdit::Rename { from: source.into(), to: destination.into() });
}
}
FileEdit::Delete { path, .. } if project_root(path).is_some() => {
validate_asset(path)?;
let meta = meta_path(path);
no_links(&meta)?;
if present(&meta, &files) {
files.push(FileEdit::Delete { path: meta.into(), recursive: false });
}
}
_ => {}
}
files.push(file.clone());
}
edit.files = files;
Ok(())
}
-147
Переглянути файл
@@ -1,147 +0,0 @@
use ide_workspace::file_ops;
use std::{fs, path::Path};
fn metadata_path(path: &Path) -> std::path::PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".meta");
name.into()
}
fn unity_project(root: &Path) {
fs::create_dir_all(root.join("Assets")).unwrap();
fs::create_dir_all(root.join("ProjectSettings")).unwrap();
fs::write(root.join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 2022.3.40f1").unwrap();
}
fn directory_alias(target: &Path, alias: &Path) {
#[cfg(unix)]
std::os::unix::fs::symlink(target, alias).unwrap();
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let output = std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(alias.to_string_lossy().replace('/', "\\"))
.arg(target.to_string_lossy().replace('/', "\\"))
.creation_flags(0x08000000)
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
}
}
#[test]
fn ordinary_rename_through_an_alias_keeps_vfs_same_file_behavior() {
let dir = tempfile::tempdir().unwrap();
let aliases = tempfile::tempdir().unwrap();
let alias = aliases.path().join("project");
directory_alias(dir.path(), &alias);
let from = dir.path().join("File.txt");
let to = alias.join("File.txt");
fs::write(&from, "keep").unwrap();
ide_vfs::rename(&from, &to).unwrap();
file_ops::rename(&from, &to).unwrap();
let copied = alias.join("Copy.txt");
file_ops::copy(&to, &copied).unwrap();
assert_eq!(fs::read_to_string(copied).unwrap(), "keep");
assert_eq!(fs::read_to_string(from).unwrap(), "keep");
}
#[test]
fn plain_files_and_non_assets_in_unity_keep_adjacent_metadata_untouched() {
for unity in [false, true] {
let dir = tempfile::tempdir().unwrap();
if unity {
unity_project(dir.path());
}
let from = dir.path().join("File.txt");
let copied = dir.path().join("Copy.txt");
let renamed = dir.path().join("file.txt");
fs::write(&from, "data").unwrap();
fs::write(metadata_path(&from), "unrelated").unwrap();
file_ops::copy(&from, &copied).unwrap();
assert!(!metadata_path(&copied).exists());
file_ops::rename(&from, &renamed).unwrap();
assert_eq!(fs::read_to_string(&renamed).unwrap(), "data");
assert_eq!(file_ops::deletion_paths(&renamed).unwrap(), [renamed]);
assert_eq!(fs::read_to_string(metadata_path(&from)).unwrap(), "unrelated");
}
}
#[test]
fn mixed_move_and_undo_apply_rules_only_to_unity_assets() {
use ide_core::{LanguageId, WorkspaceEdit};
use ide_workspace::{Documents, FileMove, FileMoves};
use std::sync::Arc;
let unity = tempfile::tempdir().unwrap();
unity_project(unity.path());
let plain = tempfile::tempdir().unwrap();
let aliases = tempfile::tempdir().unwrap();
let alias = aliases.path().join("plain");
directory_alias(plain.path(), &alias);
let source = unity.path().join("Assets/Player.cs");
let destination = unity.path().join("Assets/Hero.cs");
let ordinary = alias.join("plain.txt");
let ordinary_to = alias.join("moved.txt");
fs::write(&source, "class Player {}").unwrap();
fs::write(metadata_path(&source), "guid: 11111111111111111111111111111111\n").unwrap();
fs::write(&ordinary, "plain").unwrap();
fs::write(metadata_path(&ordinary), "unrelated").unwrap();
let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
let moves = FileMoves::new();
let token = moves
.perform(
&docs,
None,
&[
FileMove { from: ordinary.clone().into(), to: ordinary_to.clone().into() },
FileMove { from: source.clone().into(), to: destination.clone().into() },
],
&WorkspaceEdit::default(),
"Move",
&|_| Ok(()),
)
.unwrap();
assert!(destination.exists() && metadata_path(&destination).exists());
assert!(ordinary_to.exists() && !metadata_path(&ordinary_to).exists());
assert!(metadata_path(&ordinary).exists());
moves.undo(&docs, token).unwrap();
assert!(source.exists() && metadata_path(&source).exists());
assert!(ordinary.exists() && metadata_path(&ordinary).exists());
assert!(!destination.exists() && !metadata_path(&destination).exists());
let blocker = alias.join("blocked");
fs::write(&blocker, "not a directory").unwrap();
assert!(
moves
.perform(
&docs,
None,
&[
FileMove { from: source.clone().into(), to: destination.clone().into() },
FileMove { from: ordinary.clone().into(), to: blocker.join("file.txt").into() },
],
&WorkspaceEdit::default(),
"Move",
&|_| Ok(()),
)
.is_err()
);
assert!(source.exists() && metadata_path(&source).exists());
assert!(ordinary.exists() && metadata_path(&ordinary).exists());
assert!(!destination.exists() && !metadata_path(&destination).exists());
}
#[test]
fn vfs_operations_remain_unaware_of_unity_metadata() {
let dir = tempfile::tempdir().unwrap();
unity_project(dir.path());
let from = dir.path().join("Assets/Player.cs");
let to = dir.path().join("Assets/Hero.cs");
fs::write(&from, "code").unwrap();
fs::write(metadata_path(&from), "metadata").unwrap();
ide_vfs::rename(&from, &to).unwrap();
assert!(to.exists() && !metadata_path(&to).exists());
assert!(metadata_path(&from).exists());
}
-89
Переглянути файл
@@ -9,95 +9,6 @@ fn documents() -> Documents {
Documents::new(Arc::new(NullSink), Box::new(|_, _| LanguageId::plain_text()))
}
fn meta(path: &std::path::Path) -> std::path::PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".meta");
name.into()
}
fn unity_project() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("Assets")).unwrap();
std::fs::create_dir_all(tmp.path().join("ProjectSettings")).unwrap();
std::fs::write(tmp.path().join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 2022.3.40f1\n").unwrap();
tmp
}
#[test]
fn unity_rename_chain_and_delete_share_metadata_undo() {
let tmp = unity_project();
let original = tmp.path().join("Assets/Player.cs");
let renamed = tmp.path().join("Assets/Hero.cs");
std::fs::write(&original, "old").unwrap();
std::fs::write(meta(&original), "guid: unchanged").unwrap();
let (docs, events) = recorded_documents();
docs.apply_workspace_edit(&WorkspaceEdit {
files: vec![
FileEdit::Rename { from: original.clone().into(), to: renamed.clone().into() },
FileEdit::Delete { path: renamed.clone().into(), recursive: false },
],
..Default::default()
})
.unwrap();
for path in [&original, &renamed, &meta(&original), &meta(&renamed)] {
assert!(!path.exists(), "{} should be removed", path.display());
}
docs.undo_workspace_edit(undo_id(&events)).unwrap();
assert_eq!(std::fs::read_to_string(&original).unwrap(), "old");
assert_eq!(std::fs::read_to_string(meta(&original)).unwrap(), "guid: unchanged");
assert!(!renamed.exists() && !meta(&renamed).exists());
}
#[test]
fn unity_metadata_conflict_keeps_earlier_edits_unchanged() {
let tmp = unity_project();
let original = tmp.path().join("Assets/Player.cs");
let renamed = tmp.path().join("Assets/Hero.cs");
std::fs::write(&original, "old").unwrap();
std::fs::write(meta(&original), "guid: original").unwrap();
std::fs::write(meta(&renamed), "guid: orphan").unwrap();
assert!(
documents()
.apply_workspace_edit(&WorkspaceEdit {
files: vec![
edit(&original, "new"),
FileEdit::Rename { from: original.clone().into(), to: renamed.clone().into() }
],
..Default::default()
})
.is_err()
);
assert_eq!(std::fs::read_to_string(&original).unwrap(), "old");
assert_eq!(std::fs::read_to_string(meta(&original)).unwrap(), "guid: original");
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), "guid: orphan");
}
#[test]
fn unity_folder_rename_then_child_rename_preserves_metadata() {
let tmp = unity_project();
let folder = tmp.path().join("Assets/Before");
let moved = tmp.path().join("Assets/After");
std::fs::create_dir(&folder).unwrap();
std::fs::write(meta(&folder), "folder guid").unwrap();
std::fs::write(folder.join("Player.cs"), "old").unwrap();
std::fs::write(meta(&folder.join("Player.cs")), "script guid").unwrap();
let (docs, events) = recorded_documents();
docs.apply_workspace_edit(&WorkspaceEdit {
files: vec![
FileEdit::Rename { from: folder.clone().into(), to: moved.clone().into() },
FileEdit::Rename { from: moved.join("Player.cs").into(), to: moved.join("Hero.cs").into() },
],
..Default::default()
})
.unwrap();
assert_eq!(std::fs::read_to_string(meta(&moved)).unwrap(), "folder guid");
assert_eq!(std::fs::read_to_string(meta(&moved.join("Hero.cs"))).unwrap(), "script guid");
assert!(!meta(&moved.join("Player.cs")).exists());
docs.undo_workspace_edit(undo_id(&events)).unwrap();
assert_eq!(std::fs::read_to_string(meta(&folder.join("Player.cs"))).unwrap(), "script guid");
assert!(!moved.exists() && !meta(&moved).exists());
}
fn edit(path: &Path, text: &str) -> FileEdit {
FileEdit::Edit {
path: path.to_owned().into(),
-23
Переглянути файл
@@ -1,23 +0,0 @@
# Unity projects
Open the folder containing `Assets` and `ProjectSettings/ProjectVersion.txt`.
OpenIDEA detects Unity without requiring a generated solution and shows the editor
version in the Unity project view.
The Unity view hides `.meta` files, generated C# project files and cache/build
folders. **Show Meta Files** and **Show Generated Files** reveal them independently;
the regular Project view is still available. Generated folders are excluded from
indexing and search, including folders Unity creates after the project is opened.
Operations on assets inside `Assets` include their `.meta` files. Rename and move
preserve GUIDs, deletion removes both files, and copy generates new GUIDs and updates
references within the copied group. References to assets outside the group stay
unchanged. File move undo and transactional workspace edits include metadata too.
Destination metadata conflicts are rejected before making changes.
Projects may be opened through a linked root or parent directory, including macOS
`/var` and `/tmp`. Links inside the project (including Windows junctions) are not
followed by asset operations. For moves/copies to or from ordinary directories,
the external path must have no linked ancestors. Cross-volume asset moves and
copies that require rewriting binary serialized assets are rejected; use Unity
for those operations.
+1 -3
Переглянути файл
@@ -20,9 +20,7 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
language servers, and `smoke/run.test.ts` runs each project's run and test configurations
(the intentionally failing test included, then Rerun Failed Tests): when you change a
fixture, run both for that language.
language servers: when you change a fixture, run it for that language.
## Setup
-1
Переглянути файл
@@ -9,7 +9,6 @@ const config: KnipConfig = {
"scripts/**/*.ts",
"perf/**/*.ts",
"e2e/**/*.ts",
"smoke/**/*.ts",
],
// Plugins' web pages are projects of their own (own package.json, checked by plugins/build.sh and CI).
ignore: ["plugins/**/web-src/**"],
+1 -1
Переглянути файл
@@ -10,7 +10,7 @@ tree-sitter = "csharp"
[[language-servers]]
name = "csharp-ls"
command = "csharp-ls"
root-markers = ["*.sln", "*.slnx", "*.csproj", "global.json", "Directory.Build.props", "ProjectSettings/ProjectVersion.txt"]
root-markers = ["*.sln", "*.slnx", "*.csproj", "global.json", "Directory.Build.props"]
install-hint = "dotnet tool install --global csharp-ls"
[debugger]
-8
Переглянути файл
@@ -24,14 +24,6 @@ useAliasesForRenames = false
[language-servers.initialization-options.javascript.preferences]
useAliasesForRenames = false
# Moving a file: the IDE asks for the import updates up front (preview, undo); the server
# doesn't prompt after the move.
[language-servers.initialization-options.typescript.updateImportsOnFileMove]
enabled = "never"
[language-servers.initialization-options.javascript.updateImportsOnFileMove]
enabled = "never"
[[language-servers]]
name = "typescript-language-server"
tsserver-project = true
-8
Переглянути файл
@@ -23,14 +23,6 @@ useAliasesForRenames = false
[language-servers.initialization-options.javascript.preferences]
useAliasesForRenames = false
# Moving a file: the IDE asks for the import updates up front (preview, undo); the server
# doesn't prompt after the move.
[language-servers.initialization-options.typescript.updateImportsOnFileMove]
enabled = "never"
[language-servers.initialization-options.javascript.updateImportsOnFileMove]
enabled = "never"
[[language-servers]]
name = "typescript-language-server"
tsserver-project = true
-8
Переглянути файл
@@ -23,14 +23,6 @@ useAliasesForRenames = false
[language-servers.initialization-options.javascript.preferences]
useAliasesForRenames = false
# Moving a file: the IDE asks for the import updates up front (preview, undo); the server
# doesn't prompt after the move.
[language-servers.initialization-options.typescript.updateImportsOnFileMove]
enabled = "never"
[language-servers.initialization-options.javascript.updateImportsOnFileMove]
enabled = "never"
[[language-servers]]
name = "typescript-language-server"
tsserver-project = true
-8
Переглянути файл
@@ -23,14 +23,6 @@ useAliasesForRenames = false
[language-servers.initialization-options.javascript.preferences]
useAliasesForRenames = false
# Moving a file: the IDE asks for the import updates up front (preview, undo); the server
# doesn't prompt after the move.
[language-servers.initialization-options.typescript.updateImportsOnFileMove]
enabled = "never"
[language-servers.initialization-options.javascript.updateImportsOnFileMove]
enabled = "never"
[[language-servers]]
name = "typescript-language-server"
tsserver-project = true
-8
Переглянути файл
@@ -30,14 +30,6 @@ useAliasesForRenames = false
[language-servers.initialization-options.javascript.preferences]
useAliasesForRenames = false
# Moving a file: the IDE asks for the import updates up front (preview, undo); the server
# doesn't prompt after the move.
[language-servers.initialization-options.typescript.updateImportsOnFileMove]
enabled = "never"
[language-servers.initialization-options.javascript.updateImportsOnFileMove]
enabled = "never"
[[language-servers]]
name = "oxlint"
command = "oxlint"
+1 -2
Переглянути файл
@@ -12,12 +12,11 @@
"tauri": "node scripts/tauri.ts",
"test": "vitest run",
"test:watch": "vitest",
"smoke": "vitest run --config smoke/vitest.config.ts",
"lint": "oxlint",
"format": "oxfmt",
"format:check": "oxfmt --check",
"knip": "knip",
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit && tsrs -p smoke --noEmit",
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit",
"gen:bindings": "cargo test -p openidea --test export_bindings",
"gen:windows-icons": "node scripts/gen-windows-icons.ts",
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
+17 -18
Переглянути файл
@@ -125,23 +125,23 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
### Contribution points
| Point | Declaration | Code needed |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name, or an SVG of the package), `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
| Point | Declaration | Code needed |
| --------------- | -------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
Declared contributions are live as soon as the plugin is enabled — commands
show in Find Action, menus and the status bar, shortcuts work, themes and icon
@@ -313,7 +313,6 @@ then run `openidea-plugin check`.
| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. |
| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. |
| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. |
| `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. |
| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. |
`run-command` receives an optional string argument: `executeAction("plugin.<plugin id>.<command id>", "arg")`
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
9ad412cb7b853d40467c67305a1126bb87910d179ecfa1c88e13d4a900367c50
28e4bf10a41bdda011111b0e150ebe96205c57f410bad98c19eea3f1286349d6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457
7e015486cc916f5edbac542863373fd7204f5bcd534f6c408d5f9d2b72913862
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c199dc52e00d3943882d532e7e6095f7b2f8d7d6026cf1ccaa685dd6300f2319
a6cf59e74595e13908e2cbf1752c58665c6963de258413c9d7332d2a7dd9cbd0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6047be5eeaa1330d7aa1f182e88ae2f352f244829ad9a01fdc3a2a081bdca95d
b8dde206a74b222b93e61056d4cbe403617ae240d142e01e91420a2aacdb2da4
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7a3ee410d228d1e39155aa042237d222d23d7f5a45e261086cffc45c6ee1ad32
a681c2abd4a65d6aeb62efd514e9b170f4fca52b0c14d2d380a3b04be0a3fbc6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7658a95eb9fc27468cae4b1d20e3b5ab299fde478e07f69ab0fdb4cc10caf11d
3c83bc9559df54457e19e650cdd21bab9d609e22daaa1fc4f596910b70834a34
Бінарний файл не відображається.
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff. Show more