дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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
Цей коміт міститься в:
1 джерело
294198a727
коміт
1e00ea5ee2
426 files changed
+85241
-2886
No files matched your search
@@ -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 || {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, ®istry.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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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, ®ions, &|| 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
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Заблокувати користувача