feat: Third pass aligning core experience with IntelliJ IDEA

Project & files
- Project view modes (Project Files / Open Files / All Changed Files /
  Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go,
  Maven/Gradle jars; read-only jar/zip browsing
- ⌘N New popup with type search and more file kinds; New Project, Rename
  Project, Reload All from Disk, Export/Import Settings, Power Save Mode
- File type associations, extra content roots and exclude patterns (indexed
  and watched without reopening the project)

Editor & navigation
- Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline,
  history; Find in Files: Module scope, custom pattern scopes, multiline
- Search Everywhere filters, Assign Shortcut, settings results; named bookmark
  lists, file/folder bookmarks; Structure filters and Show Inherited; Recent
  Locations timestamps and breadcrumbs; Code Vision author
- Completion options, editor tab placement/layout, inline completion (LSP 3.18)
- Spell checking (Typo inspection, ide-spell) and Inspections settings with
  custom profiles; Code Style schemes, formatter markers, Do not format

Run, VCS, services, database
- Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker,
  Compound), Modify options, Before launch, env dialog, Current File,
  Emulate terminal (PTY)
- Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the
  graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees
- Generic Services host (Docker connections/registries, run sessions); TODO
  scope/changes tabs with changelists; Coverage report import
- Data sources: wire-compatible vendors, URL import, SSL certs, Options,
  Schemas and Advanced tabs, color and comment

Workbench
- Run Anything, Customize Main Toolbar, named tool window layouts, menu
  completeness, Code Cleanup, macros, "Don't Show Again", editor banners
- Chinese translations for the new UI

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
Цей коміт міститься в:
l0ng-ai committed 2026-10-25 21:24:00 +08:00
1 джерело 294198a727
коміт 1e00ea5ee2
426 files changed
+85241 -2886

No files matched your search

+2 -2
Переглянути файл
@@ -189,7 +189,7 @@ pub async fn analysis_locate_duplicates(
) -> IdeResult<DuplicatesReport> {
let project = state.project(window.label())?;
let cancel = state.begin_request(request);
let languages = state.languages.clone();
let languages = project.languages.clone();
let syntax = state.syntax();
let result = blocking(move || {
let files = files_in_scope(&project, &scope, &cancel)?;
@@ -256,7 +256,7 @@ pub async fn structural_search(
) -> IdeResult<()> {
let project = state.project(window.label())?;
let cancel = state.begin_request(request);
let languages = state.languages.clone();
let languages = project.languages.clone();
let syntax = state.syntax();
let events = on_event.clone();
let result = blocking(move || {
+82 -2
Переглянути файл
@@ -69,14 +69,33 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
}
fn project_info(project: &crate::state::Project) -> ProjectInfo {
let settings = project.settings.effective();
let custom = settings.general.project_name.trim();
ProjectInfo {
name: project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
name: if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
},
is_git: !project.repos.read().is_empty(),
settings: project.settings.effective(),
settings,
root: project.root.clone().into(),
}
}
/// File › Rename Project…: the project's display name (title bar, recent
/// projects); an empty `name` restores the folder name. Stored in the project's settings.
#[tauri::command]
#[specta::specta]
pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) -> IdeResult<ProjectInfo> {
let project = state.project(window.label())?;
let name = name.trim();
let value = if name.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(name.to_owned()) };
project.settings.set(SettingsScope::Project, "general.projectName", value)?;
state.recent.rename(&project.root, (!name.is_empty()).then_some(name))?;
Ok(project_info(&project))
}
#[tauri::command]
#[specta::specta]
pub fn close_project(window: Window, state: State<'_, AppState>) {
@@ -156,6 +175,15 @@ pub fn settings_set(
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
}
}
// File type associations re-detect open documents (off the command thread).
if key == "files" || key.starts_with("files.fileType") {
let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() };
tauri::async_runtime::spawn_blocking(move || {
for project in affected {
project.apply_file_types(&project.settings.effective());
}
});
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
if ["languages", "tools"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() };
@@ -165,6 +193,16 @@ pub fn settings_set(
}
}
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
// index and the watchers without reopening the project (the rescan runs in the background).
if roots_changed || key == "files" || key.starts_with("files.") {
let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() };
tauri::async_runtime::spawn_blocking(move || {
for project in affected {
project.refresh_index_scope();
}
});
}
// A project value concerns only that project's window (and its settings window).
let event = ide_core::IdeEvent::SettingsChanged { key };
if project_scope {
@@ -182,6 +220,48 @@ pub fn settings_set(
Ok(settings)
}
/// File › Manage IDE Settings › Export Settings…: the global settings plus the
/// frontend's IDE-level `storage` (live / file templates, macros) to `path`.
#[tauri::command]
#[specta::specta]
pub async fn settings_export(
state: State<'_, AppState>,
path: PathBuf,
storage: std::collections::BTreeMap<String, String>,
) -> IdeResult<()> {
state.global_settings.export_bundle(&path, storage)
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SettingsImport {
/// Effective settings of the calling window after the import.
pub settings: Settings,
/// IDE state the frontend keeps (written back to its storage).
pub storage: std::collections::BTreeMap<String, String>,
}
/// Import Settings…: replaces the global settings with the file's; every window re-reads them.
#[tauri::command]
#[specta::specta]
pub async fn settings_import(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<SettingsImport> {
let (_, storage) = state.global_settings.import_bundle(&path)?;
for project in state.projects() {
let effective = project.settings.reload();
project
.documents
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
let lsp = project.lsp.clone();
tauri::async_runtime::spawn(async move { lsp.reconfigure().await });
}
state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() });
let settings = match state.project(window.label()) {
Ok(p) => p.settings.effective(),
Err(_) => state.global_settings.effective(),
};
Ok(SettingsImport { settings, storage })
}
/// Effective source / test / resource / excluded roots of the window's project:
/// auto-detected from build files, overridden by Settings › Project Structure.
#[tauri::command]
+10 -1
Переглянути файл
@@ -1,7 +1,7 @@
//! Run with Coverage: launch plans and coverage reports.
use crate::state::AppState;
use ide_core::{IdeError, IdeResult};
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite};
use ide_run::RunConfig;
use tauri::{State, Window};
@@ -37,3 +37,12 @@ pub async fn coverage_prepare(
pub async fn coverage_collect(report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
blocking(move || ide_coverage::collect(&report)).await
}
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
/// with paths mapped into this project.
#[tauri::command]
#[specta::specta]
pub async fn coverage_import(window: Window, state: State<'_, AppState>, path: IpcPath) -> IdeResult<CoverageSuite> {
let root = state.project(window.label())?.root.clone();
blocking(move || ide_coverage::import(&path, &root)).await
}
+19 -9
Переглянути файл
@@ -102,22 +102,32 @@ pub fn doc_set_line_ending(
state.project(window.label())?.documents.set_line_ending(doc, line_ending)
}
/// Structure view: LSP document symbols when available, tree-sitter otherwise.
/// Structure view: LSP document symbols when available (with tree-sitter
/// visibility), tree-sitter otherwise.
/// Tree-sitter answers come from the per-document tree cache (incremental
/// re-parse); large documents get no syntax structure.
#[tauri::command]
#[specta::specta]
pub async fn doc_outline(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
let project = state.project(window.label())?;
if let Ok(Some(items)) = project.lsp.document_symbols(doc).await {
return Ok(items);
}
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(Vec::new());
}
Ok(project.syntax.outline(doc, &snap.language, snap.version, &snap.text))
let snap = match project.documents.text_snapshot(doc) {
Ok(snap) => snap,
Err(e) => return lsp.ok_or(e),
};
let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text);
Ok(match lsp {
// Server symbols carry no visibility; borrow it from the syntax tree.
Some(mut items) => {
if !snap.large {
ide_language::merge_visibility(&mut items, &syntax());
}
items
}
None if snap.large => Vec::new(),
None => syntax(),
})
})
.await
}
+87 -14
Переглянути файл
@@ -2,7 +2,10 @@
use crate::state::AppState;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_docker::{ComposeOp, ContainerOp, DockerEvent, DockerLogLine, DockerObject, DockerSnapshot, PruneTarget};
use ide_docker::{
ComposeOp, ContainerOp, DockerEndpoint, DockerEvent, DockerLogLine, DockerObject, DockerRegistry, DockerSettings,
DockerSnapshot, PruneTarget, RegistryAuth,
};
use tauri::State;
use tauri::ipc::Channel;
@@ -25,35 +28,42 @@ async fn cancellable<T: Send + 'static>(
/// Availability plus containers, images, volumes and networks.
#[tauri::command]
#[specta::specta]
pub async fn docker_snapshot() -> IdeResult<DockerSnapshot> {
blocking(|| Ok(ide_docker::snapshot())).await
pub async fn docker_snapshot(endpoint: DockerEndpoint) -> IdeResult<DockerSnapshot> {
blocking(move || Ok(ide_docker::snapshot(&endpoint))).await
}
#[tauri::command]
#[specta::specta]
pub async fn docker_container_op(ids: Vec<String>, op: ContainerOp) -> IdeResult<()> {
blocking(move || ide_docker::container_op(&ids, op)).await
pub async fn docker_container_op(endpoint: DockerEndpoint, ids: Vec<String>, op: ContainerOp) -> IdeResult<()> {
blocking(move || ide_docker::container_op(&endpoint, &ids, op)).await
}
#[tauri::command]
#[specta::specta]
pub async fn docker_remove(object: DockerObject, ids: Vec<String>, force: bool) -> IdeResult<()> {
blocking(move || ide_docker::remove(object, &ids, force)).await
pub async fn docker_remove(
endpoint: DockerEndpoint,
object: DockerObject,
ids: Vec<String>,
force: bool,
) -> IdeResult<()> {
blocking(move || ide_docker::remove(&endpoint, object, &ids, force)).await
}
/// Pretty-printed `docker inspect` JSON.
#[tauri::command]
#[specta::specta]
pub async fn docker_inspect(object: DockerObject, id: String) -> IdeResult<String> {
blocking(move || ide_docker::inspect(object, &id)).await
pub async fn docker_inspect(endpoint: DockerEndpoint, object: DockerObject, id: String) -> IdeResult<String> {
blocking(move || ide_docker::inspect(&endpoint, object, &id)).await
}
/// Streams a container's log lines until it stops or the request is cancelled.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn docker_logs(
state: State<'_, AppState>,
request: RequestId,
endpoint: DockerEndpoint,
id: String,
tail: u32,
follow: bool,
@@ -61,7 +71,7 @@ pub async fn docker_logs(
on_line: Channel<DockerLogLine>,
) -> IdeResult<()> {
cancellable(&state, request, move |cancel| {
ide_docker::logs(&id, tail, follow, timestamps, cancel, &|line| {
ide_docker::logs(&endpoint, &id, tail, follow, timestamps, cancel, &|line| {
let _ = on_line.send(line);
})
})
@@ -74,26 +84,31 @@ pub async fn docker_logs(
pub async fn docker_events(
state: State<'_, AppState>,
request: RequestId,
endpoint: DockerEndpoint,
on_event: Channel<DockerEvent>,
) -> IdeResult<()> {
cancellable(&state, request, move |cancel| {
ide_docker::watch_events(cancel, &|e| {
ide_docker::watch_events(&endpoint, cancel, &|e| {
let _ = on_event.send(e);
})
})
.await
}
/// `docker pull`, logging in first when a saved registry with a password serves the image.
#[tauri::command]
#[specta::specta]
pub async fn docker_pull(
state: State<'_, AppState>,
request: RequestId,
endpoint: DockerEndpoint,
reference: String,
on_output: Channel<String>,
) -> IdeResult<()> {
let data_dir = state.data_dir.clone();
cancellable(&state, request, move |cancel| {
ide_docker::pull(&reference, cancel, &|line| {
let auth = ide_docker::auth_for(&data_dir, &reference);
ide_docker::pull(&endpoint, &reference, auth.as_ref(), cancel, &|line| {
let _ = on_output.send(line);
})
})
@@ -105,11 +120,12 @@ pub async fn docker_pull(
pub async fn docker_prune(
state: State<'_, AppState>,
request: RequestId,
endpoint: DockerEndpoint,
target: PruneTarget,
on_output: Channel<String>,
) -> IdeResult<()> {
cancellable(&state, request, move |cancel| {
ide_docker::prune(target, cancel, &|line| {
ide_docker::prune(&endpoint, target, cancel, &|line| {
let _ = on_output.send(line);
})
})
@@ -123,6 +139,7 @@ pub async fn docker_prune(
pub async fn docker_compose_op(
state: State<'_, AppState>,
request: RequestId,
endpoint: DockerEndpoint,
project: String,
files: Vec<String>,
working_dir: Option<String>,
@@ -130,9 +147,65 @@ pub async fn docker_compose_op(
on_output: Channel<String>,
) -> IdeResult<()> {
cancellable(&state, request, move |cancel| {
ide_docker::compose_op(&project, &files, working_dir.as_deref(), op, cancel, &|line| {
ide_docker::compose_op(&endpoint, &project, &files, working_dir.as_deref(), op, cancel, &|line| {
let _ = on_output.send(line);
})
})
.await
}
/// Docker connections and registries (app-wide).
#[tauri::command]
#[specta::specta]
pub async fn docker_settings(state: State<'_, AppState>) -> IdeResult<DockerSettings> {
let data_dir = state.data_dir.clone();
blocking(move || Ok(ide_docker::load_settings(&data_dir))).await
}
#[tauri::command]
#[specta::specta]
pub async fn docker_save_settings(state: State<'_, AppState>, settings: DockerSettings) -> IdeResult<()> {
let data_dir = state.data_dir.clone();
blocking(move || ide_docker::save_settings(&data_dir, &settings)).await
}
/// Saves (`None`: forgets) a registry's password.
#[tauri::command]
#[specta::specta]
pub async fn docker_set_registry_password(
state: State<'_, AppState>,
id: String,
password: Option<String>,
) -> IdeResult<()> {
let data_dir = state.data_dir.clone();
blocking(move || ide_docker::set_registry_password(&data_dir, &id, password.as_deref())).await
}
/// Test login to `registry` (`password`, else the saved one) through `endpoint`'s daemon.
#[tauri::command]
#[specta::specta]
pub async fn docker_registry_login(
state: State<'_, AppState>,
endpoint: DockerEndpoint,
registry: DockerRegistry,
password: Option<String>,
) -> IdeResult<String> {
let data_dir = state.data_dir.clone();
blocking(move || {
let password = password
.or_else(|| ide_docker::registry_password(&data_dir, &registry.id))
.ok_or_else(|| IdeError::invalid("enter the registry password"))?;
let auth = RegistryAuth { address: registry.address, username: registry.username, password };
ide_docker::login(&endpoint, &auth)
})
.await
}
/// Environment for a Docker run configuration's command line: selects `endpoint`'s
/// daemon and puts the docker executable on `PATH`.
#[tauri::command]
#[specta::specta]
pub async fn docker_run_env(endpoint: DockerEndpoint) -> IdeResult<std::collections::BTreeMap<String, String>> {
endpoint.validate()?;
blocking(move || Ok(ide_docker::run_env(&endpoint))).await
}
+18
Переглянути файл
@@ -26,6 +26,24 @@ pub async fn lsp_completion(
result
}
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
#[tauri::command]
#[specta::specta]
pub async fn lsp_inline_completion(
window: Window,
state: State<'_, AppState>,
request: RequestId,
doc: DocId,
pos: Pos,
automatic: bool,
) -> IdeResult<Vec<InlineCompletionItem>> {
let lsp = project!(window, state).lsp.clone();
let cancel = state.begin_request(request);
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
state.end_request(request);
result
}
#[tauri::command]
#[specta::specta]
pub async fn lsp_resolve_completion(
+27
Переглянути файл
@@ -30,6 +30,7 @@ pub mod refactor;
pub mod run;
pub mod sdk;
pub mod search;
pub mod spell;
pub mod syntax;
pub mod term;
pub mod tools;
@@ -51,6 +52,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
app::settings_get,
app::settings_get_global,
app::settings_set,
app::settings_export,
app::settings_import,
app::project_rename,
app::project_roots,
app::cancel_request,
app::log_frontend,
@@ -108,11 +112,14 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::fs_resolve_reference,
nav::path_completions,
nav::web_unresolved_references,
spell::spell_check,
spell::spell_suggest,
search::search_start,
search::replace_in_files,
search::replace_preview,
search::search_validate,
lsp::lsp_completion,
lsp::lsp_inline_completion,
lsp::lsp_resolve_completion,
lsp::lsp_hover,
lsp::lsp_signature_help,
@@ -170,12 +177,20 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_diff_hunks,
vcs::vcs_stage_hunks,
vcs::vcs_clone,
vcs::vcs_init,
vcs::vcs_git_version,
vcs::vcs_detected_git,
vcs::vcs_config_get,
vcs::vcs_operation,
vcs::vcs_create_tag,
vcs::vcs_tags,
vcs::vcs_merge_with,
vcs::vcs_rebase_with,
vcs::vcs_remotes,
vcs::vcs_edit_remote,
vcs::vcs_worktrees,
vcs::vcs_add_worktree,
vcs::vcs_remove_worktree,
vcs::vcs_diff_with_worktree,
vcs::vcs_diff_revisions,
vcs::vcs_create_patch,
@@ -199,11 +214,15 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
term::term_available_shells,
run::run_configs,
run::run_save_configs,
run::run_templates,
run::run_save_templates,
run::run_system_env,
run::run_start,
run::run_stop,
run::run_stdin,
run::run_list,
run::run_close_stdin,
run::run_resize,
run::run_test_reports,
build::build_model,
build::build_sync,
@@ -231,6 +250,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::tools_uninstall,
sdk::sdk_detect,
sdk::sdk_python_effective,
sdk::sdk_project_libraries,
sdk::sdk_locate_tools,
sdk::sdk_python_create_venv,
sdk::sdk_python_packages,
@@ -248,6 +268,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
window::menu_configure,
window::menu_refresh,
window::menu_model,
window::menu_set_disabled,
window::app_log_location,
window::app_build_info,
window::app_check_update,
@@ -268,6 +289,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
debug::debug_redefine_classes,
coverage::coverage_prepare,
coverage::coverage_collect,
coverage::coverage_import,
web::web_browsers,
web::web_url,
web::web_open,
@@ -299,6 +321,11 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
docker::docker_pull,
docker::docker_prune,
docker::docker_compose_op,
docker::docker_settings,
docker::docker_save_settings,
docker::docker_set_registry_password,
docker::docker_registry_login,
docker::docker_run_env,
github::gh_availability,
github::gh_pr_list,
github::gh_pr_view,
+29
Переглянути файл
@@ -26,6 +26,28 @@ pub fn run_save_configs(window: Window, state: State<'_, AppState>, configs: Vec
ide_run::save_user_configs(&root, &configs)
}
/// Configuration templates: per-type defaults applied to new configurations.
#[tauri::command]
#[specta::specta]
pub fn run_templates(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<RunConfig>> {
let root = state.project(window.label())?.root.clone();
ide_run::load_templates(&root)
}
#[tauri::command]
#[specta::specta]
pub fn run_save_templates(window: Window, state: State<'_, AppState>, templates: Vec<RunConfig>) -> IdeResult<()> {
let root = state.project(window.label())?.root.clone();
ide_run::save_templates(&root, &templates)
}
/// The environment runs inherit when "Include system environment variables" is on.
#[tauri::command]
#[specta::specta]
pub fn run_system_env() -> std::collections::BTreeMap<String, String> {
ide_run::system_env()
}
#[tauri::command]
#[specta::specta]
pub fn run_start(
@@ -69,6 +91,13 @@ pub fn run_stdin(state: State<'_, AppState>, id: RunId, data: String) -> IdeResu
state.processes.write_stdin(id, &data)
}
/// Terminal size of a run started with "Emulate terminal in output console".
#[tauri::command]
#[specta::specta]
pub fn run_resize(state: State<'_, AppState>, id: RunId, cols: u16, rows: u16) -> IdeResult<()> {
state.processes.resize(id, cols, rows)
}
#[tauri::command]
#[specta::specta]
pub fn run_list(state: State<'_, AppState>) -> Vec<RunInfo> {
+27
Переглянути файл
@@ -28,6 +28,33 @@ pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeRe
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
#[tauri::command]
#[specta::specta]
pub async fn sdk_project_libraries(
window: Window,
state: State<'_, AppState>,
) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
let project = state.project(window.label())?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tauri::async_runtime::spawn_blocking(move || {
let model = crate::commands::build::load(&root, &cache, &settings);
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
.into_iter()
.map(|c| ide_sdk::libraries::BuildDependency {
gradle: c.gradle,
group: c.group_id,
artifact: c.artifact_id,
version: c.version,
})
.collect();
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
})
.await
.map_err(join_err)
}
/// Executables found for "run this file", and where single files are compiled to.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
+31 -12
Переглянути файл
@@ -40,7 +40,14 @@ pub async fn search_start(
let cancel = state.begin_request(request);
let documents = project.documents.clone();
let root = project.root.clone();
let languages = state.languages.clone();
// The whole project also covers the Project Structure content roots.
let content_roots = match query.scope {
ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => {
project.content_roots()
}
_ => Vec::new(),
};
let languages = project.languages.clone();
let syntax = state.syntax();
let events = on_event.clone();
let summary = tauri::async_runtime::spawn_blocking(move || {
@@ -55,17 +62,29 @@ pub async fn search_start(
let found = syntax.literals(&language, text)?;
Some(found.into_iter().map(literal_range).collect())
};
ide_search::search_in_context(
&root,
&query,
Some(&filter),
Some(&literals),
&|p| documents.text_of(p),
&cancel,
&|file| {
let _ = events.send(SearchEvent::File { file });
},
)
let mut total = ide_search::SearchSummary::default();
for dir in std::iter::once(&root).chain(&content_roots) {
let s = ide_search::search_in_context(
dir,
&query,
Some(&filter),
Some(&literals),
&|p| documents.text_of(p),
&cancel,
&|file| {
let _ = events.send(SearchEvent::File { file });
},
)?;
total.files += s.files;
total.matches += s.matches;
total.elapsed_ms += s.elapsed_ms;
total.limit_hit |= s.limit_hit;
total.cancelled |= s.cancelled;
if total.limit_hit || total.cancelled {
break;
}
}
Ok::<_, ide_core::IdeError>(total)
})
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
+90
Переглянути файл
@@ -0,0 +1,90 @@
//! Proofreading › Typo: spell checking of open documents.
use super::doc::blocking;
use crate::state::{AppState, Project};
use ide_core::settings::SpellingSettings;
use ide_core::{DocId, IdeError, IdeResult, RequestId};
use ide_language::SpellingKind;
use ide_spell::{Region, RegionKind, Speller, Typo};
use std::path::{Path, PathBuf};
use tauri::{State, Window};
/// Languages checked as prose (all text but code spans).
const PROSE: &[&str] = &["plaintext", "markdown", "restructuredtext", "asciidoc", "latex"];
/// Custom dictionary paths: `~/…` from home, relative ones from the project root.
fn dictionary_paths(settings: &SpellingSettings, root: &Path) -> Vec<PathBuf> {
settings
.custom_dictionaries
.iter()
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| match p.strip_prefix("~/") {
Some(rest) => dirs::home_dir().unwrap_or_default().join(rest),
None => root.join(p),
})
.collect()
}
fn speller(project: &Project) -> Speller {
let s = project.settings.effective().spelling;
let words = s.ide_words.iter().chain(&s.project_words);
Speller::new(words, &dictionary_paths(&s, &project.root))
}
/// Misspelt words in the comments, string literals and declared names of
/// `doc` (all of its text for prose). Cancel with `cancel_request(request)`.
#[tauri::command]
#[specta::specta]
pub async fn spell_check(
window: Window,
state: State<'_, AppState>,
doc: DocId,
request: RequestId,
) -> IdeResult<Vec<Typo>> {
let project = state.project(window.label())?;
let syntax = state.syntax();
let cancel = state.begin_request(request);
let result = blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(Vec::new());
}
let regions = if PROSE.contains(&snap.language.as_str()) {
ide_spell::prose_regions(&snap.text)
} else {
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
found
.into_iter()
.map(|r| Region {
start: r.start,
end: r.end,
kind: match r.kind {
SpellingKind::Comment => RegionKind::Comment,
SpellingKind::String => RegionKind::String,
SpellingKind::Name => RegionKind::Name,
},
})
.collect()
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
})
.await;
state.end_request(request);
result
}
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
#[tauri::command]
#[specta::specta]
pub async fn spell_suggest(window: Window, state: State<'_, AppState>, word: String) -> IdeResult<Vec<String>> {
let project = state.project(window.label()).ok();
blocking(move || {
let speller = project.as_deref().map(speller).unwrap_or_default();
Ok(speller.suggest(&word, 8))
})
.await
}
+1 -1
Переглянути файл
@@ -129,7 +129,7 @@ pub async fn syntax_identifiers_in_files(
request: RequestId,
) -> IdeResult<Vec<FileIdentifiers>> {
let project = state.project(window.label())?;
let languages = state.languages.clone();
let languages = project.languages.clone();
let cancel = state.begin_request(request);
let result = blocking(move || {
let load = |path: &std::path::Path| {
+113 -1
Переглянути файл
@@ -374,6 +374,13 @@ pub enum RemoteOp {
UpdateBranch {
branch: String,
},
/// Pull dialog: `branches` of `remote` (none = the configured merge branch).
PullFrom {
remote: String,
branches: Vec<String>,
rebase: bool,
options: MergeOptions,
},
}
/// Network operation; progress lines stream through `on_output`. Cancellable.
@@ -393,7 +400,7 @@ pub async fn vcs_remote(
let out = |line: String| {
let _ = on_output.send(line);
};
let recorded = matches!(op, RemoteOp::Pull { .. }).then_some("Git pull");
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull");
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
RemoteOp::Fetch => r.fetch(&cancel, &out),
RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out),
@@ -408,6 +415,9 @@ pub async fn vcs_remote(
None => r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out),
},
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
RemoteOp::PullFrom { remote, branches, rebase, options } => {
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
}
})
})
.await
@@ -486,6 +496,14 @@ pub async fn vcs_clone(
result
}
/// `git init` in `dir` (File › New Project… "Create Git repository").
#[tauri::command]
#[specta::specta]
pub async fn vcs_init(state: State<'_, AppState>, dir: PathBuf) -> IdeResult<()> {
apply_git_executable(&state.global_settings.effective());
blocking(move || ide_vcs::init(&dir)).await
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GitExecutableInfo {
@@ -629,6 +647,100 @@ pub async fn vcs_tags(window: Window, state: State<'_, AppState>, repo: Option<P
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
}
/// Merge dialog: merges `branches` into the current branch.
#[tauri::command]
#[specta::specta]
pub async fn vcs_merge_with(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
branches: Vec<String>,
options: MergeOptions,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options))
})
.await
}
/// Rebase dialog: rebases the current branch onto `upstream` (or `--onto`).
#[tauri::command]
#[specta::specta]
pub async fn vcs_rebase_with(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
upstream: String,
options: RebaseOptions,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options))
})
.await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_remotes(window: Window, state: State<'_, AppState>, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_edit_remote(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
edit: RemoteEdit,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_worktrees(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
) -> IdeResult<Vec<Worktree>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.worktrees())).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_add_worktree(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
path: PathBuf,
commitish: String,
new_branch: Option<String>,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || {
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
})
.await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_remove_worktree(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
path: PathBuf,
force: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
}
/// Compare with Local: tracked files that differ between `rev` and the working tree.
#[tauri::command]
#[specta::specta]
+18 -2
Переглянути файл
@@ -150,13 +150,29 @@ pub fn menu_refresh(app: AppHandle, menu_state: State<'_, MenuState>, state: Sta
/// The menu bar for a title bar drawn by the frontend (Windows).
#[tauri::command]
#[specta::specta]
pub fn menu_model(app: AppHandle, state: State<'_, AppState>) -> Vec<menu::MenuNode> {
pub fn menu_model(app: AppHandle, state: State<'_, AppState>, menu_state: State<'_, MenuState>) -> Vec<menu::MenuNode> {
let mut spec = menu::spec(menu::Os::current());
if !updates_configured(&app) {
spec = menu::without_action(spec, "app.checkForUpdates");
}
let home = dirs::home_dir();
menu::model(&spec, &menu::recent_items(&state.recent.list(), home.as_deref()))
let config = menu_state.config.lock().clone();
menu::model(&spec, &menu::recent_items(&state.recent.list(), home.as_deref()), &config)
}
/// Action ids the focused window cannot run now: their native menu items are
/// disabled in place (no rebuild).
#[tauri::command]
#[specta::specta]
pub fn menu_set_disabled(app: AppHandle, menu_state: State<'_, MenuState>, ids: Vec<String>) {
let disabled: std::collections::HashSet<String> = ids.into_iter().collect();
if *menu_state.disabled.lock() == disabled {
return;
}
*menu_state.disabled.lock() = disabled.clone();
if menu::native_menu_bar() {
menu::apply_disabled(&app, &disabled);
}
}
#[derive(Serialize, Deserialize, Type)]
+374 -42
Переглянути файл
@@ -20,15 +20,17 @@
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::menu::{IsMenuItem, Menu, MenuItem, MenuItemKind, PredefinedMenuItem, Submenu};
use tauri::{AppHandle, Runtime};
/// Event emitted to the focused window when an IDE menu item is clicked.
pub const MENU_EVENT: &str = "openidea://menu";
/// Prefix of Open Recent item ids; the rest of the id is the project path.
const RECENT_PREFIX: &str = "recent:";
/// Prefix of items the frontend lists ([`MenuConfig::dynamic`]): `dyn:<action>` or `dyn:<action>|<arg>`.
const DYNAMIC_PREFIX: &str = "dyn:";
/// Action run for an Open Recent entry (argument: the project path).
pub const OPEN_RECENT_ACTION: &str = "file.openRecentProject";
const MAX_RECENT: usize = 15;
@@ -145,6 +147,9 @@ pub enum Entry {
Separator,
/// Open Recent entries, filled from the recent-projects list.
RecentProjects,
/// Items the frontend lists under this slot name (registered tool windows,
/// saved layouts), from [`MenuConfig::dynamic`].
Dynamic(&'static str),
}
fn sub(id: &'static str, label: &'static str, children: Vec<Entry>) -> Entry {
@@ -161,6 +166,10 @@ fn hinted(id: &'static str, label: &'static str, hint: &'static str) -> Entry {
}
const SEP: Entry = Entry::Separator;
/// [`Entry::Dynamic`] slots the frontend fills.
pub const DYNAMIC_TOOL_WINDOWS: &str = "toolWindows";
pub const DYNAMIC_LAYOUTS: &str = "toolWindowLayouts";
/// The menu bar for `os`. Labels are English defaults; the frontend replaces
/// them with `menu.<id>` strings from the active locale catalog.
pub fn spec(os: Os) -> Vec<Entry> {
@@ -194,6 +203,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
}
let mut file = vec![
act("file.newProject", "New Project…"),
act("file.new", "New…"),
act("file.newScratchFile", "New Scratch File…"),
act("file.openProject", "Open…"),
@@ -204,16 +214,26 @@ pub fn spec(os: Os) -> Vec<Entry> {
vec![Entry::RecentProjects, SEP, act("welcome.recentProjects", "Manage Projects…")],
),
act("file.closeProject", "Close Project"),
act("file.renameProject", "Rename Project…"),
SEP,
act("file.save", "Save All"),
act("file.reloadAllFromDisk", "Reload All from Disk"),
sub("fileProperties", "File Properties", vec![act("file.toggleReadOnly", "Make File Read-only / Writable")]),
SEP,
act("file.invalidateCaches", "Invalidate Caches…"),
SEP,
sub(
"manageIdeSettings",
"Manage IDE Settings",
vec![act("file.exportSettings", "Export Settings…"), act("file.importSettings", "Import Settings…")],
),
sub(
"newProjectsSetup",
"New Projects Setup",
vec![act("file.newProjectsSettings", "Settings for New Projects…")],
),
act("file.invalidateCaches", "Invalidate Caches…"),
SEP,
act("file.togglePowerSave", "Power Save Mode"),
];
if !mac {
// macOS keeps Settings… and Quit in the application menu.
@@ -237,6 +257,34 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("find.showUsages", "Show Usages"),
],
);
let macros = || {
sub(
"macros",
"Macros",
vec![
act("macro.startRecording", "Start Macro Recording"),
act("macro.stopRecording", "Stop Macro Recording"),
act("macro.playBack", "Play Back Last Macro"),
SEP,
act("macro.playSaved", "Play Saved Macros…"),
act("macro.edit", "Edit Macros…"),
],
)
};
let bookmarks = || {
sub(
"bookmarks",
"Bookmarks",
vec![
act("navigate.toggleBookmark", "Toggle Bookmark"),
act("navigate.toggleBookmarkWithMnemonic", "Toggle Bookmark with Mnemonic"),
SEP,
act("navigate.showBookmarks", "Show All Bookmarks…"),
act("navigate.nextBookmark", "Next Bookmark"),
act("navigate.prevBookmark", "Previous Bookmark"),
],
)
};
let edit = if mac {
vec![
Entry::Native(Native::Undo),
@@ -258,6 +306,9 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("code.reverseLines", "Reverse Lines"),
SEP,
act("editor.toggleColumnSelection", "Column Selection Mode"),
SEP,
macros(),
bookmarks(),
]
} else {
// GTK/Win32 accelerators would swallow these keys before the webview, so
@@ -282,6 +333,9 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("code.reverseLines", "Reverse Lines"),
SEP,
act("editor.toggleColumnSelection", "Column Selection Mode"),
SEP,
macros(),
bookmarks(),
]
};
menus.push(sub("edit", "Edit", edit));
@@ -293,37 +347,39 @@ pub fn spec(os: Os) -> Vec<Entry> {
sub(
"toolWindows",
"Tool Windows",
vec![
act("toolwindow.project", "Project"),
act("toolwindow.bookmarks", "Bookmarks"),
act("toolwindow.find", "Find"),
act("toolwindow.run", "Run"),
act("toolwindow.build", "Build"),
act("toolwindow.debug", "Debug"),
act("toolwindow.coverage", "Coverage"),
act("toolwindow.profiler", "Profiler"),
act("toolwindow.problems", "Problems"),
act("toolwindow.structure", "Structure"),
act("toolwindow.services", "Services"),
act("toolwindow.git", "Git"),
act("toolwindow.commit", "Commit"),
act("toolwindow.terminal", "Terminal"),
SEP,
act("toolwindow.hideAll", "Hide All Tool Windows"),
],
vec![Entry::Dynamic(DYNAMIC_TOOL_WINDOWS), SEP, act("toolwindow.hideAll", "Hide All Tool Windows")],
),
sub(
"appearance",
"Appearance",
vec![
act("editor.toggleZen", "Enter Distraction Free Mode"),
act("window.toggleFullScreen", "Toggle Full Screen"),
act("view.toggleCompactMode", "Compact Mode"),
SEP,
act("view.zoomIn", "Zoom In IDE"),
act("view.zoomOut", "Zoom Out IDE"),
act("view.resetZoom", "Reset IDE Zoom"),
SEP,
act("view.toggleTheme", "Toggle Light/Dark Theme"),
act("toolbar.customize", "Customize Main Toolbar…"),
],
),
act("view.notifications", "Notifications"),
SEP,
act("window.toggleFullScreen", "Toggle Full Screen"),
act("code.quickDefinition", "Quick Definition"),
act("code.quickTypeDefinition", "Quick Type Definition"),
SEP,
act("navigate.recentLocations", "Recent Locations"),
act("navigate.recentFiles", "Recent Files"),
act("localHistory.recentChanges", "Recent Changes"),
SEP,
act("diff.compareWithClipboard", "Compare with Clipboard"),
SEP,
act("view.quickSwitchScheme", "Quick Switch Scheme…"),
SEP,
act("editor.resetFontSize", "Reset Font Size in All Editors"),
SEP,
act("view.notifications", "Notifications"),
],
));
@@ -337,8 +393,6 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("navigate.gotoSymbol", "Symbol…"),
act("navigate.gotoLine", "Line/Column…"),
SEP,
act("navigate.recentFiles", "Recent Files"),
act("navigate.recentLocations", "Recent Locations"),
act("navigate.back", "Back"),
act("navigate.forward", "Forward"),
act("navigate.lastEditLocation", "Last Edit Location"),
@@ -360,8 +414,6 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("navigate.nextError", "Next Highlighted Error"),
act("navigate.prevError", "Previous Highlighted Error"),
SEP,
act("navigate.toggleBookmark", "Toggle Bookmark"),
act("navigate.showBookmarks", "Show Bookmarks"),
act("navigate.selectIn", "Select In…"),
],
));
@@ -394,6 +446,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
SEP,
act("code.reformat", "Reformat Code"),
act("code.optimizeImports", "Optimize Imports"),
act("code.cleanup", "Code Cleanup…"),
SEP,
act("code.commentLine", "Comment with Line Comment"),
act("code.commentBlock", "Comment with Block Comment"),
@@ -469,6 +522,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("run.stop", "Stop"),
SEP,
act("run.chooseConfig", "Run…"),
act("runAnything.show", "Run Anything…"),
act("run.editConfigs", "Edit Configurations…"),
act("debug.attachToProcess", "Attach to Process…"),
SEP,
@@ -508,13 +562,20 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("vcs.commit", "Commit…"),
act("vcs.push", "Push…"),
act("vcs.update", "Update Project…"),
act("vcs.pull", "Pull…"),
act("vcs.fetch", "Fetch"),
SEP,
act("vcs.merge", "Merge…"),
act("vcs.rebase", "Rebase…"),
SEP,
act("vcs.branches", "Branches…"),
act("vcs.newBranch", "New Branch…"),
act("vcs.newTag", "New Tag…"),
act("vcs.resetHead", "Reset HEAD…"),
SEP,
act("vcs.newWorktree", "New Worktree…"),
act("vcs.worktrees", "Worktrees…"),
SEP,
act("vcs.showDiff", "Show Diff"),
act("vcs.rollback", "Rollback…"),
act("vcs.annotate", "Annotate with Git Blame"),
@@ -552,6 +613,10 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("github.openInBrowser", "Open on GitHub"),
],
),
act("vcs.manageRemotes", "Manage Remotes…"),
act("vcs.clone", "Clone…"),
SEP,
act("vcs.operations", "VCS Operations Popup…"),
],
));
@@ -573,14 +638,64 @@ pub fn spec(os: Os) -> Vec<Entry> {
window.extend([Entry::Native(Native::Minimize), Entry::Native(Native::Zoom), SEP]);
}
window.extend([
act("window.new", "New Window"),
accel("window.next", "Next Project Window", "CmdOrCtrl+`"),
accel("window.previous", "Previous Project Window", "CmdOrCtrl+Shift+`"),
sub(
"toolWindowLayouts",
"Tool Window Layouts",
vec![
act("layout.save", "Save Current Layout as…"),
act("layout.restoreDefault", "Restore Default Layout"),
SEP,
Entry::Dynamic(DYNAMIC_LAYOUTS),
SEP,
act("layout.rename", "Rename Layout…"),
act("layout.delete", "Delete Layout…"),
],
),
SEP,
act("editor.splitRight", "Split Right"),
act("editor.splitDown", "Split Down"),
act("editor.unsplit", "Unsplit"),
sub(
"activeToolWindow",
"Active Tool Window",
vec![
act("toolwindow.hideActive", "Hide Active Tool Window"),
act("toolwindow.maximize", "Maximize Tool Window"),
act("toolwindow.closeContent", "Close Active Tab"),
SEP,
act("toolwindow.jumpToLast", "Jump to Last Tool Window"),
act("toolwindow.focusEditor", "Focus Editor"),
],
),
sub(
"editorTabs",
"Editor Tabs",
vec![
act("editor.nextTab", "Select Next Tab"),
act("editor.prevTab", "Select Previous Tab"),
SEP,
act("editor.pinTab", "Pin Tab"),
act("editor.closeTab", "Close Tab"),
act("editor.closeOtherTabs", "Close Other Tabs"),
act("editor.closeAllTabs", "Close All Tabs"),
act("editor.reopenClosedTab", "Reopen Closed Tab"),
SEP,
act("editor.splitRight", "Split Right"),
act("editor.splitDown", "Split Down"),
act("editor.unsplit", "Unsplit"),
act("editor.moveToOppositeGroup", "Move to Opposite Group"),
],
),
SEP,
act("window.new", "New Window"),
]);
// ⌘` is macOS's window cycling; elsewhere Ctrl+` is Quick Switch Scheme and the keymap's
// Ctrl+Alt+] / Ctrl+Alt+[ switch windows.
if mac {
window.extend([
accel("window.next", "Next Project Window", "Cmd+`"),
accel("window.previous", "Previous Project Window", "Cmd+Shift+`"),
]);
} else {
window.extend([act("window.next", "Next Project Window"), act("window.previous", "Previous Project Window")]);
}
if mac {
window.extend([SEP, Entry::Native(Native::BringAllToFront)]);
}
@@ -613,6 +728,13 @@ pub fn route(spec: &[Entry], id: &str) -> Option<MenuRoute> {
if let Some(path) = id.strip_prefix(RECENT_PREFIX) {
return Some(MenuRoute { action: OPEN_RECENT_ACTION.into(), arg: Some(path.into()) });
}
if let Some(rest) = id.strip_prefix(DYNAMIC_PREFIX) {
let (action, arg) = match rest.split_once('|') {
Some((action, arg)) => (action, Some(arg.to_owned())),
None => (rest, None),
};
return (!action.is_empty()).then(|| MenuRoute { action: action.into(), arg });
}
action_ids(spec).into_iter().any(|a| a == id).then(|| MenuRoute { action: id.into(), arg: None })
}
@@ -660,6 +782,28 @@ pub struct MenuConfig {
pub labels: HashMap<String, String>,
/// Action id → display shortcut from the JS keymap, e.g. `⇧⌘O`.
pub shortcuts: HashMap<String, String>,
/// [`Entry::Dynamic`] slot → its items, already localised.
#[serde(default)]
pub dynamic: HashMap<String, Vec<DynamicItem>>,
}
/// An item of a frontend-listed menu slot.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DynamicItem {
pub action: String,
pub arg: Option<String>,
pub label: String,
}
impl DynamicItem {
/// Native menu item id, decoded again by [`route`].
pub fn item_id(&self) -> String {
match &self.arg {
Some(arg) => format!("{DYNAMIC_PREFIX}{}|{arg}", self.action),
None => format!("{DYNAMIC_PREFIX}{}", self.action),
}
}
}
impl MenuConfig {
@@ -667,6 +811,18 @@ impl MenuConfig {
self.labels.get(&format!("menu.{id}")).cloned().unwrap_or_else(|| default.to_owned())
}
fn dynamic_items(&self, slot: &str) -> &[DynamicItem] {
self.dynamic.get(slot).map(Vec::as_slice).unwrap_or_default()
}
/// A dynamic item's text: its label, plus the action's shortcut unless it carries an argument.
fn dynamic_text(&self, item: &DynamicItem) -> String {
match self.shortcuts.get(&item.action).filter(|s| item.arg.is_none() && !s.is_empty()) {
Some(shortcut) => format!("{}\t{shortcut}", item.label),
None => item.label.clone(),
}
}
/// Item text with the keymap shortcut appended after a tab (display only).
pub fn item_text(&self, id: &str, default: &str, hint: Option<&str>) -> String {
let label = self.label(id, default);
@@ -680,6 +836,48 @@ impl MenuConfig {
#[derive(Default)]
pub struct MenuState {
pub config: Mutex<MenuConfig>,
/// Action ids the focused window reports as disabled (no context / not registered).
pub disabled: Mutex<HashSet<String>>,
}
/// The action an item id stands for, if it is an IDE action item.
fn item_action(id: &str) -> Option<&str> {
if id.starts_with(RECENT_PREFIX) || id == "recent.none" || id.starts_with("submenu:") {
return None;
}
Some(id.strip_prefix(DYNAMIC_PREFIX).map_or(id, |rest| rest.split_once('|').map_or(rest, |(a, _)| a)))
}
/// Whether the item `id` is enabled given the disabled action ids.
pub fn item_enabled(id: &str, disabled: &HashSet<String>) -> bool {
item_action(id).is_none_or(|a| !disabled.contains(a))
}
/// Updates the enabled state of the installed menu's items in place (no rebuild).
pub fn apply_disabled<R: Runtime>(app: &AppHandle<R>, disabled: &HashSet<String>) {
fn walk<R: Runtime>(items: Vec<MenuItemKind<R>>, disabled: &HashSet<String>) {
for item in items {
match item {
MenuItemKind::MenuItem(i) => {
let enabled = item_enabled(i.id().as_ref(), disabled);
if i.is_enabled().is_ok_and(|e| e != enabled) {
let _ = i.set_enabled(enabled);
}
}
MenuItemKind::Submenu(s) => {
if let Ok(children) = s.items() {
walk(children, disabled)
}
}
_ => {}
}
}
}
if let Some(menu) = app.menu()
&& let Ok(items) = menu.items()
{
walk(items, disabled);
}
}
pub struct RecentItem {
@@ -707,6 +905,7 @@ pub fn build<R: Runtime>(
os: Os,
config: &MenuConfig,
recent: &[RecentItem],
disabled: &HashSet<String>,
) -> tauri::Result<Menu<R>> {
let menu = Menu::new(app)?;
let mut spec = spec(os);
@@ -715,7 +914,7 @@ pub fn build<R: Runtime>(
}
for entry in spec {
if let Entry::Submenu { id, label, children } = entry {
let submenu = build_submenu(app, config, recent, id, label, &children)?;
let submenu = build_submenu(app, config, recent, disabled, id, label, &children)?;
#[cfg(target_os = "macos")]
match id {
"window" => submenu.set_as_windows_menu_for_nsapp()?,
@@ -732,6 +931,7 @@ fn build_submenu<R: Runtime>(
app: &AppHandle<R>,
config: &MenuConfig,
recent: &[RecentItem],
disabled: &HashSet<String>,
id: &str,
label: &str,
children: &[Entry],
@@ -740,15 +940,23 @@ fn build_submenu<R: Runtime>(
for child in children {
match child {
Entry::Submenu { id, label, children } => {
submenu.append(&build_submenu(app, config, recent, id, label, children)?)?
submenu.append(&build_submenu(app, config, recent, disabled, id, label, children)?)?
}
Entry::Action { id, label, accel, hint } => {
let enabled = item_enabled(id, disabled);
let item = match accel {
Some(a) => MenuItem::with_id(app, *id, config.label(id, label), true, Some(*a))?,
None => MenuItem::with_id(app, *id, config.item_text(id, label, *hint), true, None::<&str>)?,
Some(a) => MenuItem::with_id(app, *id, config.label(id, label), enabled, Some(*a))?,
None => MenuItem::with_id(app, *id, config.item_text(id, label, *hint), enabled, None::<&str>)?,
};
submenu.append(&item)?;
}
Entry::Dynamic(slot) => {
for d in config.dynamic_items(slot) {
let id = d.item_id();
let enabled = item_enabled(&id, disabled);
submenu.append(&MenuItem::with_id(app, id, config.dynamic_text(d), enabled, None::<&str>)?)?;
}
}
Entry::Native(n) => submenu.append(native(app, config, *n)?.as_ref())?,
Entry::Separator => submenu.append(&PredefinedMenuItem::separator(app)?)?,
Entry::RecentProjects => {
@@ -814,15 +1022,21 @@ pub enum MenuNode {
}
/// The spec as [`MenuNode`]s; native role items (macOS only) are left out.
pub fn model(spec: &[Entry], recent: &[RecentItem]) -> Vec<MenuNode> {
pub fn model(spec: &[Entry], recent: &[RecentItem], config: &MenuConfig) -> Vec<MenuNode> {
let mut out = Vec::new();
for e in spec {
match e {
Entry::Submenu { id, label, children } => out.push(MenuNode::Submenu {
id: (*id).into(),
label: (*label).into(),
children: model(children, recent),
children: model(children, recent, config),
}),
Entry::Dynamic(slot) => out.extend(config.dynamic_items(slot).iter().map(|d| MenuNode::Action {
id: d.action.clone(),
label: d.label.clone(),
hint: None,
arg: d.arg.clone(),
})),
Entry::Action { id, label, hint, .. } => out.push(MenuNode::Action {
id: (*id).into(),
label: (*label).into(),
@@ -859,7 +1073,8 @@ pub fn install<R: Runtime>(app: &AppHandle<R>, menu_state: &MenuState, recent: &
let home = dirs::home_dir();
let items = recent_items(recent, home.as_deref());
let config = menu_state.config.lock().clone();
match build(app, Os::current(), &config, &items).and_then(|m| app.set_menu(m)) {
let disabled = menu_state.disabled.lock().clone();
match build(app, Os::current(), &config, &items, &disabled).and_then(|m| app.set_menu(m)) {
Ok(_) => {}
Err(e) => tracing::warn!("failed to build the app menu: {e}"),
}
@@ -1107,14 +1322,131 @@ mod tests {
vec![Entry::Native(Native::Undo), act("file.save", "Save All"), SEP, Entry::RecentProjects],
)];
let recent = [RecentItem { path: "/p/a".into(), label: "a — ~/a".into() }];
let MenuNode::Submenu { children, .. } = &model(&spec, &recent)[0] else { panic!("not a submenu") };
let MenuNode::Submenu { children, .. } = &model(&spec, &recent, &MenuConfig::default())[0] else {
panic!("not a submenu")
};
assert_eq!(children.len(), 3);
assert!(matches!(&children[0], MenuNode::Action { id, arg: None, .. } if id == "file.save"));
assert!(
matches!(&children[2], MenuNode::Action { id, arg: Some(p), .. } if id == OPEN_RECENT_ACTION && p == "/p/a")
);
let MenuNode::Submenu { children, .. } = &model(&spec, &[])[0] else { panic!("not a submenu") };
let MenuNode::Submenu { children, .. } = &model(&spec, &[], &MenuConfig::default())[0] else {
panic!("not a submenu")
};
assert!(matches!(&children[2], MenuNode::Placeholder { id, .. } if id == "recent.none"));
}
fn dynamic_item(action: &str, arg: Option<&str>, label: &str) -> DynamicItem {
DynamicItem { action: action.into(), arg: arg.map(Into::into), label: label.into() }
}
#[test]
fn dynamic_items_route_with_their_argument() {
let spec = spec(Os::Mac);
let plain = dynamic_item("toolwindow.project", None, "Project");
let named = dynamic_item("layout.restore", Some("Review | wide"), "Review | wide");
assert_eq!(route(&spec, &plain.item_id()), Some(MenuRoute { action: "toolwindow.project".into(), arg: None }));
assert_eq!(
route(&spec, &named.item_id()),
Some(MenuRoute { action: "layout.restore".into(), arg: Some("Review | wide".into()) })
);
assert_eq!(route(&spec, "dyn:"), None);
}
#[test]
fn tool_windows_and_layouts_are_listed_by_the_frontend() {
fn slots(entries: &[Entry], out: &mut Vec<&'static str>) {
for e in entries {
match e {
Entry::Submenu { children, .. } => slots(children, out),
Entry::Dynamic(slot) => out.push(slot),
_ => {}
}
}
}
for os in ALL_OS {
let spec = spec(os);
let mut found = Vec::new();
slots(&spec, &mut found);
assert_eq!(found, [DYNAMIC_TOOL_WINDOWS, DYNAMIC_LAYOUTS], "{os:?}");
assert!(!action_ids(&spec).iter().any(|id| {
id.starts_with("toolwindow.")
&& *id != "toolwindow.hideAll"
&& ![
"toolwindow.hideActive",
"toolwindow.maximize",
"toolwindow.closeContent",
"toolwindow.jumpToLast",
"toolwindow.focusEditor",
]
.contains(id)
}));
}
let mut config = MenuConfig::default();
config.dynamic.insert(
DYNAMIC_TOOL_WINDOWS.into(),
vec![dynamic_item("toolwindow.project", None, "Project"), dynamic_item("toolwindow.git", None, "Git")],
);
config.shortcuts.insert("toolwindow.project".into(), "⌘1".into());
assert_eq!(config.dynamic_text(&config.dynamic[DYNAMIC_TOOL_WINDOWS][0]), "Project\t⌘1");
assert_eq!(config.dynamic_text(&dynamic_item("toolwindow.project", Some("x"), "X")), "X");
let spec = vec![sub("toolWindows", "Tool Windows", vec![Entry::Dynamic(DYNAMIC_TOOL_WINDOWS), SEP])];
let MenuNode::Submenu { children, .. } = &model(&spec, &[], &config)[0] else { panic!("not a submenu") };
assert!(
matches!(&children[1], MenuNode::Action { id, arg: None, label, .. } if id == "toolwindow.git" && label == "Git")
);
let MenuNode::Submenu { children, .. } = &model(&spec, &[], &MenuConfig::default())[0] else { panic!() };
assert_eq!(children, &[MenuNode::Separator]);
}
#[test]
fn disabled_actions_disable_their_items_only() {
let disabled: HashSet<String> = ["refactor.rename".to_owned(), "layout.restore".to_owned()].into();
assert!(!item_enabled("refactor.rename", &disabled));
assert!(item_enabled("refactor.inline", &disabled));
assert!(!item_enabled("dyn:layout.restore|Review", &disabled));
assert!(item_enabled("dyn:toolwindow.git", &disabled));
assert!(item_enabled("recent:/p/refactor.rename", &disabled));
assert!(item_enabled("submenu:refactor", &disabled));
}
#[test]
fn idea_menu_items_are_present() {
for os in ALL_OS {
let ids = action_ids(&spec(os));
for required in [
"file.newProject",
"file.renameProject",
"file.reloadAllFromDisk",
"file.exportSettings",
"file.importSettings",
"file.togglePowerSave",
"macro.startRecording",
"macro.stopRecording",
"macro.playBack",
"macro.playSaved",
"macro.edit",
"navigate.toggleBookmark",
"navigate.showBookmarks",
"code.quickDefinition",
"code.quickTypeDefinition",
"localHistory.recentChanges",
"editor.resetFontSize",
"diff.compareWithClipboard",
"view.quickSwitchScheme",
"code.cleanup",
"runAnything.show",
"layout.save",
"layout.restoreDefault",
"layout.rename",
"layout.delete",
"toolwindow.hideActive",
"editor.closeOtherTabs",
"toolbar.customize",
] {
assert!(ids.contains(&required), "{required} missing on {os:?}");
}
}
}
}
+121 -10
Переглянути файл
@@ -2,7 +2,7 @@ use crate::events::TauriSink;
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, RequestId, SharedSink};
use ide_dap::DebugManager;
use ide_index::ProjectIndex;
use ide_language::{LanguageRegistry, Syntax, SyntaxCache};
use ide_language::{FileTypes, LanguageRegistry, Syntax, SyntaxCache};
use ide_lsp::LspManager;
use ide_run::ProcessManager;
use ide_syntax::StructureService;
@@ -51,6 +51,8 @@ pub struct Project {
pub root: PathBuf,
/// Window-scoped event sink, for commands that emit project events.
pub sink: SharedSink,
/// The app's language definitions with this project's File Types applied.
pub languages: Arc<LanguageRegistry>,
pub settings: Arc<SettingsStore>,
pub documents: Arc<Documents>,
pub history: Arc<LocalHistory>,
@@ -72,7 +74,9 @@ pub struct Project {
pub db: Arc<ide_db::DbManager>,
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
pub http_history: Arc<ide_http::History>,
_watcher: Watcher,
watcher: Watcher,
/// Watchers of the Project Structure content roots outside the project folder.
content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
_scratch_watcher: Option<Watcher>,
}
@@ -214,10 +218,19 @@ impl AppState {
let settings = SettingsStore::load(&self.config_dir, Some(&root));
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
let excludes = exclude_globs(&settings.effective(), &detected_roots.read());
let extra_roots = content_roots(&settings.effective(), &root);
mark("roots");
let languages = self.languages.clone();
let documents = Arc::new(Documents::new(sink.clone(), Box::new(move |p: &Path| languages.detect(p, None))));
// The project's own registry: File Types associations may be project-scoped.
let languages = Arc::new(self.languages.fork());
languages.set_file_types(FileTypes::from_settings(&settings.effective().files, Some(&root)));
let documents = {
let languages = languages.clone();
Arc::new(Documents::new(
sink.clone(),
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
))
};
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings.effective(), Some(&root)));
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &root)?);
{
@@ -233,13 +246,14 @@ impl AppState {
let index = ProjectIndex::open(
&root,
&self.data_dir.join("index").join(&project_key),
extra_roots.clone(),
excludes.clone(),
self.languages.clone(),
languages.clone(),
self.syntax(),
sink.clone(),
)?;
mark("index");
let lsp = LspManager::new(root.clone(), self.languages.clone(), sink.clone());
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
let settings = Arc::new(settings);
{
let settings = settings.clone();
@@ -301,7 +315,7 @@ impl AppState {
let history = history.clone();
Watcher::start(
&root.clone(),
excludes,
excludes.clone(),
sink.clone(),
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
@@ -314,6 +328,9 @@ impl AppState {
if touched.into_iter().any(ide_workspace::roots::affects_roots) {
let fresh = ide_workspace::roots::detect(&root);
if *detected_roots.read() != fresh {
// Detected excluded folders (`target`, `build`) leave the index too.
let effective = settings.effective();
index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh));
*detected_roots.write() = fresh;
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
}
@@ -356,6 +373,13 @@ impl AppState {
})
};
let content_watchers = extra_roots
.iter()
.filter_map(|r| {
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?;
Some((r.clone(), w))
})
.collect();
mark("watcher");
let branch = repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head);
{
@@ -366,6 +390,10 @@ impl AppState {
}
mark("git");
self.recent.touch(&root, branch)?;
let display_name = settings.effective().general.project_name;
if !display_name.trim().is_empty() {
self.recent.rename(&root, Some(display_name.trim()))?;
}
mark("recent");
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
@@ -374,6 +402,7 @@ impl AppState {
let project = Arc::new(Project {
root,
sink,
languages,
settings,
documents,
history,
@@ -387,7 +416,8 @@ impl AppState {
cache_dir: self.data_dir.join("cache").join(&project_key),
db,
http_history,
_watcher: watcher,
watcher,
content_watchers: Mutex::new(content_watchers),
_scratch_watcher: scratch_watcher,
});
self.projects.write().insert(window.to_owned(), project.clone());
@@ -538,11 +568,15 @@ impl Project {
}
/// File masks (`ide_search::FileMask`) for Find in Files and inspections: the
/// excluded roots and the ignored names (files, and everything under such folders).
/// excluded roots, the ignored names and the "Exclude files" patterns (files,
/// and everything under such folders).
pub fn search_excludes(&self) -> Vec<String> {
let mut out: Vec<String> =
self.roots().excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))).collect();
for name in &self.settings.effective().files.ignored_names {
let settings = self.settings.effective();
let names =
settings.files.ignored_names.iter().cloned().chain(settings.project_structure.exclude_file_patterns());
for name in names {
let name = name.trim();
if !name.is_empty() && !name.contains(['/', ',']) {
out.push(name.to_owned());
@@ -557,6 +591,45 @@ impl Project {
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
}
/// Project Structure content roots besides the project folder (existing directories).
pub fn content_roots(&self) -> Vec<PathBuf> {
content_roots(&self.settings.effective(), &self.root)
}
/// Applies Settings › File Types; open documents whose language changed are
/// re-detected (language servers and syntax trees follow) and the index
/// re-parses the files whose language changed.
pub fn apply_file_types(&self, settings: &ide_core::settings::Settings) {
let before = Arc::new(self.languages.snapshot());
if self.languages.set_file_types(FileTypes::from_settings(&settings.files, Some(&self.root))) {
self.documents.redetect_languages();
self.index.languages_changed(before);
}
}
/// Applies changed content roots and exclude settings to the index (which
/// rescans in the background) and the file watchers, without reopening the project.
pub fn refresh_index_scope(&self) {
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
self.index.set_scope(roots.clone(), excludes.clone());
self.watcher.set_excludes(&excludes);
let mut watchers = self.content_watchers.lock();
watchers.retain(|(p, _)| roots.contains(p));
for (_, w) in watchers.iter() {
w.set_excludes(&excludes);
}
for r in roots {
if watchers.iter().any(|(p, _)| *p == r) {
continue;
}
if let Some(w) =
content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history)
{
watchers.push((r, w));
}
}
}
/// Current text of `path`: the open buffer when available, else the file on disk.
pub fn text_of(&self, path: &Path) -> IdeResult<String> {
match self.documents.text_of(path) {
@@ -624,6 +697,44 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa
}
}
/// Project Structure content roots outside `root` (existing absolute directories).
fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
for r in &settings.project_structure.content_roots {
let p = PathBuf::from(r.trim());
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
}
}
out
}
/// Watches a content root outside the project: the index, open documents and
/// Local History follow its changes like the project folder's.
fn content_root_watcher(
dir: &Path,
excludes: &[String],
sink: &SharedSink,
index: &Arc<ProjectIndex>,
documents: &Arc<Documents>,
history: &Arc<LocalHistory>,
) -> Option<Watcher> {
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
Watcher::start(
dir,
excludes.to_vec(),
sink.clone(),
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
index.update(&changed, &batch.removed);
documents.on_disk_changed(&changed);
record_external_changes(&history, &changed, &batch.removed);
}),
)
.map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display()))
.ok()
}
fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
let mut globs = settings.file_excludes();
globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs());