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

+558 -15
Переглянути файл
@@ -33,6 +33,18 @@ export const commands = {
* re-reads its effective settings.
*/
settingsSet: (scope: SettingsScope, key: string, value: string) => typedError<Settings_Serialize, IdeError>(__TAURI_INVOKE("settings_set", { scope, key, value })),
/**
* File › Manage IDE Settings › Export Settings…: the global settings plus the
* frontend's IDE-level `storage` (live / file templates, macros) to `path`.
*/
settingsExport: (path: string, storage: { [key in string]: string }) => typedError<null, IdeError>(__TAURI_INVOKE("settings_export", { path, storage })),
/** Import Settings…: replaces the global settings with the file's; every window re-reads them. */
settingsImport: (path: string) => typedError<SettingsImport_Serialize, IdeError>(__TAURI_INVOKE("settings_import", { path })),
/**
* 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.
*/
projectRename: (name: string) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("project_rename", { name })),
/**
* Effective source / test / resource / excluded roots of the window's project:
* auto-detected from build files, overridden by Settings › Project Structure.
@@ -91,7 +103,8 @@ export const commands = {
docSetEncoding: (doc: DocId, encoding: string, reload: boolean) => typedError<string, IdeError>(__TAURI_INVOKE("doc_set_encoding", { doc, encoding, reload })),
docSetLineEnding: (doc: DocId, lineEnding: LineEnding) => typedError<null, IdeError>(__TAURI_INVOKE("doc_set_line_ending", { doc, lineEnding })),
/**
* 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.
*/
@@ -198,6 +211,13 @@ export const commands = {
pathCompletions: (from: IpcPath, prefix: string) => typedError<PathCompletion[], IdeError>(__TAURI_INVOKE("path_completions", { from, prefix })),
/** Paths in an HTML document or stylesheet that name no existing file. */
webUnresolvedReferences: (doc: DocId) => typedError<FileReference[], IdeError>(__TAURI_INVOKE("web_unresolved_references", { doc })),
/**
* Misspelt words in the comments, string literals and declared names of
* `doc` (all of its text for prose). Cancel with `cancel_request(request)`.
*/
spellCheck: (doc: DocId, request: RequestId) => typedError<Typo[], IdeError>(__TAURI_INVOKE("spell_check", { doc, request })),
/** Replacements for a misspelt `word`, best first (Typo › Change to…). */
spellSuggest: (word: string) => typedError<string[], IdeError>(__TAURI_INVOKE("spell_suggest", { word })),
/**
* Find in Files. Results stream through `on_event`; ends with `Done`.
* Cancel with `cancel_request(request)`.
@@ -218,6 +238,8 @@ export const commands = {
/** Validates a query (e.g. regex syntax) while the user types. */
searchValidate: (query: SearchQuery) => typedError<null, IdeError>(__TAURI_INVOKE("search_validate", { query })),
lspCompletion: (request: RequestId, doc: DocId, pos: Pos, trigger: string | null) => typedError<CompletionList, IdeError>(__TAURI_INVOKE("lsp_completion", { request, doc, pos, trigger })),
/** Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing. */
lspInlineCompletion: (request: RequestId, doc: DocId, pos: Pos, automatic: boolean) => typedError<InlineCompletionItem[], IdeError>(__TAURI_INVOKE("lsp_inline_completion", { request, doc, pos, automatic })),
lspResolveCompletion: (doc: DocId, item: CompletionItem) => typedError<CompletionItem, IdeError>(__TAURI_INVOKE("lsp_resolve_completion", { doc, item })),
lspHover: (request: RequestId, doc: DocId, pos: Pos) => typedError<{
contents: Markup,
@@ -328,6 +350,8 @@ export const commands = {
vcsStageHunks: (path: string, hunks: number[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_hunks", { path, hunks })),
/** Clones a repository (Get from VCS). Works without an open project. */
vcsClone: (request: RequestId, url: string, dest: string, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_clone", { request, url, dest, onOutput })),
/** `git init` in `dir` (File › New Project… "Create Git repository"). */
vcsInit: (dir: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_init", { dir })),
/**
* Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected
* executable when `path` is empty.
@@ -344,6 +368,15 @@ export const commands = {
vcsOperation: (repo: string | null, request: RequestId, op: GitOperation, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_operation", { repo, request, op, onOutput })),
vcsCreateTag: (repo: string | null, name: string, commit: string, message: string | null, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_tag", { repo, name, commit, message, force })),
vcsTags: (repo: string | null) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_tags", { repo })),
/** Merge dialog: merges `branches` into the current branch. */
vcsMergeWith: (repo: string | null, branches: string[], options: MergeOptions) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_merge_with", { repo, branches, options })),
/** Rebase dialog: rebases the current branch onto `upstream` (or `--onto`). */
vcsRebaseWith: (repo: string | null, upstream: string, options: RebaseOptions) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rebase_with", { repo, upstream, options })),
vcsRemotes: (repo: string | null) => typedError<Remote[], IdeError>(__TAURI_INVOKE("vcs_remotes", { repo })),
vcsEditRemote: (repo: string | null, edit: RemoteEdit) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_edit_remote", { repo, edit })),
vcsWorktrees: (repo: string | null) => typedError<Worktree[], IdeError>(__TAURI_INVOKE("vcs_worktrees", { repo })),
vcsAddWorktree: (repo: string | null, path: string, commitish: string, newBranch: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_add_worktree", { repo, path, commitish, newBranch })),
vcsRemoveWorktree: (repo: string | null, path: string, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_remove_worktree", { repo, path, force })),
/** Compare with Local: tracked files that differ between `rev` and the working tree. */
vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })),
/** Files that differ between two revisions (the "N files updated" of Update Project). */
@@ -382,11 +415,18 @@ export const commands = {
/** Detected configs followed by user-defined ones (user ids win on conflict). */
runConfigs: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_configs")),
runSaveConfigs: (configs: RunConfig_Deserialize[]) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs })),
/** Configuration templates: per-type defaults applied to new configurations. */
runTemplates: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_templates")),
runSaveTemplates: (templates: RunConfig_Deserialize[]) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_templates", { templates })),
/** The environment runs inherit when "Include system environment variables" is on. */
runSystemEnv: () => __TAURI_INVOKE<{ [key in string]: string }>("run_system_env"),
runStart: (config: RunConfig_Deserialize, onOutput: Channel<OutputChunk>) => typedError<RunInfo, IdeError>(__TAURI_INVOKE("run_start", { config, onOutput })),
runStop: (id: RunId) => typedError<null, IdeError>(__TAURI_INVOKE("run_stop", { id })),
runStdin: (id: RunId, data: string) => typedError<null, IdeError>(__TAURI_INVOKE("run_stdin", { id, data })),
runList: () => __TAURI_INVOKE<RunInfo[]>("run_list"),
runCloseStdin: (id: RunId) => typedError<null, IdeError>(__TAURI_INVOKE("run_close_stdin", { id })),
/** Terminal size of a run started with "Emulate terminal in output console". */
runResize: (id: RunId, cols: number, rows: number) => typedError<null, IdeError>(__TAURI_INVOKE("run_resize", { id, cols, rows })),
/**
* Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
* `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
@@ -449,6 +489,11 @@ export const commands = {
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
/** The interpreter the project uses: the configured one, else its virtualenv. */
sdkPythonEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective")),
/**
* SDK / library source folders shown under "External Libraries" in the Project view,
* with the Maven / Gradle dependency jars of the build model.
*/
sdkProjectLibraries: () => typedError<LibraryGroup[], IdeError>(__TAURI_INVOKE("sdk_project_libraries")),
/** Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path. */
sdkLocateTools: (names: string[], near: string | null) => typedError<LocatedTools, IdeError>(__TAURI_INVOKE("sdk_locate_tools", { names, near })),
/** `base -m venv location`; output lines stream through `on_output`. */
@@ -494,6 +539,11 @@ export const commands = {
menuRefresh: () => __TAURI_INVOKE<void>("menu_refresh"),
/** The menu bar for a title bar drawn by the frontend (Windows). */
menuModel: () => __TAURI_INVOKE<MenuNode[]>("menu_model"),
/**
* Action ids the focused window cannot run now: their native menu items are
* disabled in place (no rebuild).
*/
menuSetDisabled: (ids: string[]) => __TAURI_INVOKE<void>("menu_set_disabled", { ids }),
appLogLocation: () => typedError<LogLocation, IdeError>(__TAURI_INVOKE("app_log_location")),
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("app_build_info"),
appCheckUpdate: () => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update")),
@@ -541,6 +591,11 @@ export const commands = {
coveragePrepare: (config: RunConfig_Deserialize) => typedError<CoveragePrepared_Serialize, IdeError>(__TAURI_INVOKE("coverage_prepare", { config })),
/** Reads the report a coverage run left behind. */
coverageCollect: (report: CoverageReportSpec) => typedError<CoverageSuite, IdeError>(__TAURI_INVOKE("coverage_collect", { report })),
/**
* Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
* with paths mapped into this project.
*/
coverageImport: (path: IpcPath) => typedError<CoverageSuite, IdeError>(__TAURI_INVOKE("coverage_import", { path })),
/** The browsers installed on this machine. */
webBrowsers: () => typedError<Browser[], IdeError>(__TAURI_INVOKE("web_browsers")),
/** The built-in server's URL for `path` (started on first use). */
@@ -607,19 +662,32 @@ export const commands = {
*/
structuralSearch: (request: RequestId, scope: SearchScope, query: StructuralQuery, onEvent: Channel<StructuralEvent>) => typedError<null, IdeError>(__TAURI_INVOKE("structural_search", { request, scope, query, onEvent })),
/** Availability plus containers, images, volumes and networks. */
dockerSnapshot: () => typedError<DockerSnapshot, IdeError>(__TAURI_INVOKE("docker_snapshot")),
dockerContainerOp: (ids: string[], op: ContainerOp) => typedError<null, IdeError>(__TAURI_INVOKE("docker_container_op", { ids, op })),
dockerRemove: (object: DockerObject, ids: string[], force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("docker_remove", { object, ids, force })),
dockerSnapshot: (endpoint: DockerEndpoint) => typedError<DockerSnapshot, IdeError>(__TAURI_INVOKE("docker_snapshot", { endpoint })),
dockerContainerOp: (endpoint: DockerEndpoint, ids: string[], op: ContainerOp) => typedError<null, IdeError>(__TAURI_INVOKE("docker_container_op", { endpoint, ids, op })),
dockerRemove: (endpoint: DockerEndpoint, object: DockerObject, ids: string[], force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("docker_remove", { endpoint, object, ids, force })),
/** Pretty-printed `docker inspect` JSON. */
dockerInspect: (object: DockerObject, id: string) => typedError<string, IdeError>(__TAURI_INVOKE("docker_inspect", { object, id })),
dockerInspect: (endpoint: DockerEndpoint, object: DockerObject, id: string) => typedError<string, IdeError>(__TAURI_INVOKE("docker_inspect", { endpoint, object, id })),
/** Streams a container's log lines until it stops or the request is cancelled. */
dockerLogs: (request: RequestId, id: string, tail: number, follow: boolean, timestamps: boolean, onLine: Channel<DockerLogLine>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_logs", { request, id, tail, follow, timestamps, onLine })),
dockerLogs: (request: RequestId, endpoint: DockerEndpoint, id: string, tail: number, follow: boolean, timestamps: boolean, onLine: Channel<DockerLogLine>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_logs", { request, endpoint, id, tail, follow, timestamps, onLine })),
/** Follows `docker events` until the daemon goes away or the request is cancelled. */
dockerEvents: (request: RequestId, onEvent: Channel<DockerEvent>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_events", { request, onEvent })),
dockerPull: (request: RequestId, reference: string, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_pull", { request, reference, onOutput })),
dockerPrune: (request: RequestId, target: PruneTarget, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_prune", { request, target, onOutput })),
dockerEvents: (request: RequestId, endpoint: DockerEndpoint, onEvent: Channel<DockerEvent>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_events", { request, endpoint, onEvent })),
/** `docker pull`, logging in first when a saved registry with a password serves the image. */
dockerPull: (request: RequestId, endpoint: DockerEndpoint, reference: string, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_pull", { request, endpoint, reference, onOutput })),
dockerPrune: (request: RequestId, endpoint: DockerEndpoint, target: PruneTarget, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_prune", { request, endpoint, target, onOutput })),
/** `docker compose` start / stop / restart / down of a project found through labels. */
dockerComposeOp: (request: RequestId, project: string, files: string[], workingDir: string | null, op: ComposeOp, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_compose_op", { request, project, files, workingDir, op, onOutput })),
dockerComposeOp: (request: RequestId, endpoint: DockerEndpoint, project: string, files: string[], workingDir: string | null, op: ComposeOp, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_compose_op", { request, endpoint, project, files, workingDir, op, onOutput })),
/** Docker connections and registries (app-wide). */
dockerSettings: () => typedError<DockerSettings, IdeError>(__TAURI_INVOKE("docker_settings")),
dockerSaveSettings: (settings: DockerSettings) => typedError<null, IdeError>(__TAURI_INVOKE("docker_save_settings", { settings })),
/** Saves (`None`: forgets) a registry's password. */
dockerSetRegistryPassword: (id: string, password: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("docker_set_registry_password", { id, password })),
/** Test login to `registry` (`password`, else the saved one) through `endpoint`'s daemon. */
dockerRegistryLogin: (endpoint: DockerEndpoint, registry: DockerRegistry, password: string | null) => typedError<string, IdeError>(__TAURI_INVOKE("docker_registry_login", { endpoint, registry, password })),
/**
* Environment for a Docker run configuration's command line: selects `endpoint`'s
* daemon and puts the docker executable on `PATH`.
*/
dockerRunEnv: (endpoint: DockerEndpoint) => typedError<{ [key in string]: string }, IdeError>(__TAURI_INVOKE("docker_run_env", { endpoint })),
ghAvailability: () => typedError<GhAvailability, IdeError>(__TAURI_INVOKE("gh_availability")),
ghPrList: (query: PrListQuery) => typedError<PullRequestSummary[], IdeError>(__TAURI_INVOKE("gh_pr_list", { query })),
ghPrView: (number: number) => typedError<PullRequestDetails, IdeError>(__TAURI_INVOKE("gh_pr_view", { number })),
@@ -778,6 +846,8 @@ export type AppearanceSettings = {
* (status bar context menu); widgets not listed use their default.
*/
statusBarWidgets?: { [key in string]: boolean },
/** Customize Main Toolbar; `None` = the default toolbar. */
mainToolbar?: MainToolbarSettings | null,
};
export type ApplyOutcome = {
@@ -808,6 +878,16 @@ export type AutoImportSettings = {
optimizeOnTheFly?: boolean,
};
/** A step run before a configuration launches; a failing step aborts the launch. */
export type BeforeLaunchTask = {
/**
* `run` (another configuration, `target` = its id) | `build` | `tool` (external tool id) |
* `command` (a shell command line) | `toolWindow` (tool window id).
*/
kind: string,
target?: string,
};
export type BlameLine = {
commit: string,
author: string,
@@ -1095,6 +1175,14 @@ export type CodeStyleSettings_Deserialize = {
/** Enable EditorConfig support. */
editorconfig?: boolean,
languages?: { [key in string]: LanguageCodeStyle_Deserialize },
/** Formatter › Do not format: globs (`*.min.js`, `generated/**`) Reformat Code skips. */
doNotFormat?: string[],
/** Formatter › Turn formatter on/off with markers in code comments. */
formatterTagsEnabled?: boolean,
formatterOffTag?: string,
formatterOnTag?: string,
/** The markers are regular expressions. */
formatterTagsRegexp?: boolean,
};
/** Editor › Code Style (General tab + per-language overrides). */
@@ -1108,6 +1196,14 @@ export type CodeStyleSettings_Serialize = {
/** Enable EditorConfig support. */
editorconfig: boolean,
languages: { [key in string]: LanguageCodeStyle_Serialize },
/** Formatter › Do not format: globs (`*.min.js`, `generated/**`) Reformat Code skips. */
doNotFormat: string[],
/** Formatter › Turn formatter on/off with markers in code comments. */
formatterTagsEnabled: boolean,
formatterOffTag: string,
formatterOnTag: string,
/** The markers are regular expressions. */
formatterTagsRegexp: boolean,
};
export type Command = {
@@ -1132,6 +1228,8 @@ export type Commit = {
summary: string,
message: string,
refs: string[],
/** Committer time (Log › View Options › Commit Timestamp). */
commitTimeMs?: number | null,
};
/** Commit Options (IDEA's commit options popup). */
@@ -1209,6 +1307,12 @@ export type CompletionSettings = {
autoPopup?: boolean,
/** Insert the selected suggestion by pressing space, dot, or other context-dependent keys. */
insertOnSpecialKeys?: boolean,
/** Insert a sole suggestion of explicit (⌃Space) completion right away. */
autoInsertSingle?: boolean,
/** Sort suggestions by name instead of relevance. */
sortAlphabetically?: boolean,
/** Add `()` after function and method suggestions. */
insertParentheses?: boolean,
showDocumentation?: boolean,
documentationDelay?: number,
parameterInfo?: boolean,
@@ -1272,6 +1376,12 @@ export type CreatePullRequest = {
push: boolean,
};
/** A named profile made with Copy. */
export type CustomInspectionProfile = {
name?: string,
overrides?: { [key in string]: InspectionOverride },
};
export type CustomKeymap = {
name?: string,
/** Bundled preset the keymap derives from, e.g. `intellij-macos`. */
@@ -1317,6 +1427,20 @@ export type DataSource = {
readOnly?: boolean,
/** SSH tunnel settings (`None`: never configured; kept but unused when not `enabled`). */
ssh?: DbSsh | null,
/** Wire-compatible product preset (`cockroachdb`, `tidb`…; empty: the driver itself). */
vendor?: string,
color?: DbColor | null,
comment?: string,
/** Connect with [`DataSource::url`] instead of the host / port / database fields. */
urlOnly?: boolean,
url?: string,
/** PEM files; `~/` is the home directory. */
sslCaFile?: string,
sslCertFile?: string,
sslKeyFile?: string,
options?: DbOptions,
introspection?: DbIntrospection,
properties?: DbProperty[],
};
/** System Settings › Date Formats (Git log, Local History, blame). */
@@ -1336,6 +1460,9 @@ export type DbCellValue = {
value: string | null,
};
/** IDEA's data source colours (tree node and console tab tint), e.g. red for production. */
export type DbColor = "red" | "orange" | "yellow" | "green" | "blue" | "violet";
export type DbColumnInfo = {
name: string,
dataType: string,
@@ -1394,6 +1521,41 @@ export type DbIndexInfo = {
columns: string[],
};
/** Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`. */
export type DbIntrospection = {
allDatabases?: boolean,
databases?: string[],
allSchemas?: boolean,
schemas?: string[],
/** `pg_catalog`, `information_schema`, `mysql`…, and PostgreSQL template databases. */
showSystem?: boolean,
};
/** Options tab: session settings applied to every connection. */
export type DbOptions = {
transaction?: DbTxMode,
/** PostgreSQL `search_path` (unqualified names resolve there). */
defaultSchema?: string,
/** Session time zone (`TimeZone` / `time_zone`); empty keeps the default. */
timeZone?: string,
/** Statements run on every new connection. */
startupScript?: string,
/** Run `SELECT 1` this often while connected. */
keepAliveSecs?: number | null,
/** Disconnect after this long without statements (enforced by the UI, which sees the activity). */
autoDisconnectSecs?: number | null,
};
/**
* Advanced tab: a driver property. PostgreSQL: run-time parameters (and
* `application_name`); MySQL: session variables (and `charset`, `collation`);
* SQLite: pragmas. See [`crate::conn`].
*/
export type DbProperty = {
name?: string,
value?: string,
};
export type DbQueryResult = {
statement: string,
columns: DbResultColumn[],
@@ -1443,7 +1605,11 @@ export type DbSshAuth = "password" |
/** `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent. */
"openSsh";
export type DbSslMode = "disable" | "prefer" | "require";
export type DbSslMode = "disable" | "prefer" | "require" |
/** Require, and check the server certificate against the CA file. */
"verifyCa" |
/** [`DbSslMode::VerifyCa`], and check that the certificate names the host. */
"verifyFull";
export type DbStatementOutcome = {
statement: SqlStatement,
@@ -1470,6 +1636,9 @@ export type DbTablePage = {
offset: number | null,
};
/** Transaction control of the data source's consoles. */
export type DbTxMode = "auto" | "manual";
/** What a session's adapter supports, for enabling actions. */
export type DebugCapabilities = {
restartFrame: boolean,
@@ -1534,6 +1703,11 @@ export type Diagnostic = {
code: string | null,
unnecessary?: boolean,
deprecated?: boolean,
/**
* Language server (or tool) that reported it, e.g. `basedpyright`; `source`
* is what the producer calls itself. Inspection profiles key on this.
*/
server?: string | null,
};
/** Changes under a folder (Show History on a directory). */
@@ -1563,6 +1737,12 @@ context: string | null } |
/** The CLI is there but cannot reach a daemon (not started, wrong context…). */
{ kind: "daemonDown"; message: string; context: string | null };
export type DockerConnection = {
id: string,
name: string,
endpoint: DockerEndpoint,
};
export type DockerContainer = {
id: string,
name: string,
@@ -1583,6 +1763,20 @@ export type DockerContainer = {
composeFiles: string[],
};
/** Which daemon a docker command talks to. */
export type DockerEndpoint =
/** Whatever the CLI is configured for (`DOCKER_HOST`, the current context). */
{ kind: "default" } |
/** A local daemon socket, e.g. `/var/run/docker.sock` (or a `npipe://` URL on Windows). */
{ kind: "socket"; path: string } |
/**
* A remote daemon, e.g. `tcp://build:2376` or `ssh://me@build`; with TLS when
* `tls_cert_dir` holds `ca.pem`, `cert.pem` and `key.pem`.
*/
{ kind: "tcp"; host: string; tlsCertDir: string | null } |
/** A named `docker context`. */
{ kind: "context"; name: string };
/** One relevant `docker events` entry. */
export type DockerEvent = {
/** `container`, `image`, `volume`, `network`. */
@@ -1617,6 +1811,20 @@ export type DockerNetwork = {
export type DockerObject = "container" | "image" | "volume" | "network";
/** A registry to log in to (Docker Hub, GHCR, a private one). The password is stored separately. */
export type DockerRegistry = {
id: string,
name: string,
/** `docker.io`, `ghcr.io`, `registry.example.com:5000`. */
address: string,
username?: string,
};
export type DockerSettings = {
connections?: DockerConnection[],
registries?: DockerRegistry[],
};
/** Everything the Services tree shows, fetched in one go. */
export type DockerSnapshot = {
availability: DockerAvailability,
@@ -1628,6 +1836,8 @@ export type DockerSnapshot = {
networks: DockerNetwork[],
/** Lists that failed although the daemon answered (shown, not fatal). */
errors: string[],
/** Global CLI options selecting the connection's daemon (for `docker exec` in a terminal). */
globalArgs?: string[],
};
export type DockerVolume = {
@@ -1705,6 +1915,13 @@ export type DuplicatesReport = {
elapsedMs: number,
};
/** An item of a frontend-listed menu slot. */
export type DynamicItem = {
action: string,
arg: string | null,
label: string,
};
/** Editor › General › Appearance. */
export type EditorAppearanceSettings = {
caretBlinking?: boolean,
@@ -1795,6 +2012,7 @@ export type EditorSettings_Deserialize = {
smartKeys?: SmartKeysSettings,
codeEditing?: CodeEditingSettings,
inlay?: InlayHintsSettings,
inlineCompletion?: InlineCompletionSettings,
/** Gutter icon groups turned off (`run`, `breakpoints`, `bookmarks`, `vcs`, `blame`). */
gutterIconsDisabled?: string[],
showGutterIcons?: boolean,
@@ -1830,6 +2048,7 @@ export type EditorSettings_Serialize = {
smartKeys: SmartKeysSettings,
codeEditing: CodeEditingSettings,
inlay: InlayHintsSettings,
inlineCompletion: InlineCompletionSettings,
/** Gutter icon groups turned off (`run`, `breakpoints`, `bookmarks`, `vcs`, `blame`). */
gutterIconsDisabled: string[],
showGutterIcons: boolean,
@@ -1839,13 +2058,22 @@ export type EditorSettings_Serialize = {
/** Editor › General › Editor Tabs. */
export type EditorTabsSettings = {
/** `top` | `none`. */
/** `top` | `bottom` | `none`. */
placement?: string,
multipleRows?: boolean,
/** One row that doesn't fit: `scroll` the tabs panel | `squeeze` the tabs. */
oneRowOverflow?: string,
pinnedSeparateRow?: boolean,
showFileIcon?: boolean,
showExtension?: boolean,
showDirectoryForNonUnique?: boolean,
/** Mark modified tabs with an asterisk. */
markModified?: boolean,
showFullPathOnHover?: boolean,
showCloseButton?: boolean,
/** `right` | `left` (with `show_close_button`). */
closeButtonPosition?: string,
sortAlphabetically?: boolean,
previewTab?: boolean,
openAtEnd?: boolean,
tabLimit?: number,
@@ -2026,6 +2254,10 @@ export type FileSettings = {
localHistoryDays?: number,
/** Upper bound for a project's Local History in MB; the oldest revisions go first. */
localHistoryMaxMb?: number,
/** File Types › Recognized File Types: the user's changes, by language id. */
fileTypes?: { [key in string]: FileTypeAssociation },
/** Override File Type: file (project-relative and `/`-separated, or absolute) → language id. */
fileTypeOverrides?: { [key in string]: string },
};
export type FileStatus = {
@@ -2038,6 +2270,16 @@ export type FileStatus = {
renamedFrom: string | null,
};
/** One language's file name and hashbang patterns on top of its definition. */
export type FileTypeAssociation = {
/** Added file name patterns: `*.foo`, `Jenkinsfile`, `Dockerfile.*` (`*` and `?` wildcards). */
patterns?: string[],
/** Patterns of the definition (`*.ext`, file names) the user removed. */
removedPatterns?: string[],
/** Interpreters run by a `#!` first line, e.g. `node` for `#!/usr/bin/env node`. */
hashbangs?: string[],
};
export type FoldKind = "region" | "comment" | "imports";
export type FoldRange = {
@@ -2091,6 +2333,13 @@ export type GeneralSettings = {
saveOnIdle?: boolean,
idleSeconds?: number,
dateFormat?: DateFormatSettings,
/**
* File › Power Save Mode: no automatic popups (completion, parameter info,
* quick documentation) or background highlighting (usages, code vision).
*/
powerSaveMode?: boolean,
/** File › Rename Project… (project layer): the name shown for the project; empty = its folder name. */
projectName?: string,
};
/** Whether pull requests can be shown for the project, and why not. */
@@ -2332,6 +2581,8 @@ export type IdeEvent =
{ type: "docPatched"; doc: DocId; baseVersion: number; version: number; changes: TextChange[] } |
/** A document's dirty flag changed (saved / modified externally). */
{ type: "docDirty"; doc: DocId; dirty: boolean } |
/** An open document's language changed (Settings › File Types, Override File Type). */
{ type: "docLanguage"; doc: DocId; language: LanguageId } |
/** Full replacement of diagnostics for one file from one producer. */
{ type: "diagnostics"; path: IpcPath; source: string; items: Diagnostic[] } |
/** Progress of a background task; `fraction` is `None` when indeterminate. */
@@ -2411,6 +2662,8 @@ export type InlayHintsSettings = {
disabledLanguages?: string[],
/** Code vision: usages above declarations. */
codeVisionUsages?: boolean,
/** Code vision: last author of a declaration (VCS blame) beside its usages. */
codeVisionAuthor?: boolean,
};
export type InlayKind = "type" | "parameter" | "other";
@@ -2423,6 +2676,31 @@ export type InlayLabelPart = {
location: Location | null,
};
/** One `textDocument/inlineCompletion` suggestion (ghost text at the caret). */
export type InlineCompletionItem = {
/** Text that replaces `range`; snippets arrive as their plain text. */
insertText: string,
/** Replaced range; `None` inserts at the caret. */
range: Range | null,
/** What the typed text is matched against while the suggestion stays shown (default: `insert_text`). */
filterText: string | null,
/** Run after the suggestion is accepted. */
command: Command | null,
server: ServerId,
};
/**
* Editor › General › Inline Completion: ghost text from language servers that
* support `textDocument/inlineCompletion` (LSP 3.18).
*/
export type InlineCompletionSettings = {
enabled?: boolean,
/** Ask only once typing has paused for `pause_ms`, instead of right after each change. */
onlyOnPause?: boolean,
pauseMs?: number,
disabledLanguages?: string[],
};
export type InspectEvent =
/** Files about to be inspected. */
{ type: "started"; total: number } |
@@ -2447,6 +2725,33 @@ export type InspectedFile = {
diagnostics: Diagnostic[],
};
/** IDEA's highlighting levels. */
export type InspectionLevel = "error" | "warning" | "weakWarning" | "serverProblem" | "typo" |
/** Reported (Problems, Inspect Code) but not highlighted in the editor. */
"none";
/** Changes to one inspection; absent fields keep the inspection's defaults. */
export type InspectionOverride = {
enabled?: boolean | null,
severity?: InspectionLevel | null,
};
export type InspectionSettings = {
/**
* Profile in effect for the project: `default`, `project`, or a copy
* as `ide:<key>` / `project:<key>`.
*/
profile?: string,
/** The `Default` profile (IDE level): inspection id → override. */
default?: { [key in string]: InspectionOverride },
/** The `Project Default` profile (stored with the project). */
project?: { [key in string]: InspectionOverride },
/** Profiles copied at IDE level (global settings), by key. */
ideProfiles?: { [key in string]: CustomInspectionProfile },
/** Profiles copied at project level (project settings), by key. */
projectProfiles?: { [key in string]: CustomInspectionProfile },
};
/** A command line that installs a missing tool (run in the Run tool window). */
export type InstallCommand = {
command: string,
@@ -2578,6 +2883,12 @@ export type LanguageInfo_Deserialize = {
indent: IndentConfig_Deserialize | null,
/** A debug adapter is configured (breakpoints make sense in these files). */
debuggable?: boolean,
/** Names of the configured language servers (Settings › Inspections). */
languageServers?: string[],
/** Exact file names and file-name patterns of the definition (Settings › File Types). */
filenames?: string[],
/** First-line regex of the definition (shebangs, `<?php`). */
firstLine?: string | null,
};
/** The UI-facing view of a language. */
@@ -2591,6 +2902,12 @@ export type LanguageInfo_Serialize = {
indent: IndentConfig_Serialize | null,
/** A debug adapter is configured (breakpoints make sense in these files). */
debuggable?: boolean,
/** Names of the configured language servers (Settings › Inspections). */
languageServers?: string[],
/** Exact file names and file-name patterns of the definition (Settings › File Types). */
filenames?: string[],
/** First-line regex of the definition (shebangs, `<?php`). */
firstLine?: string | null,
};
export type LanguagesSettings = {
@@ -2632,6 +2949,25 @@ export type LaunchConfig = {
extraJson: string | null,
};
/** One SDK or library with its source folders (`< Python 3.12 (.venv) >`). */
export type LibraryGroup = {
/** Display name, e.g. `Python 3.12 (.venv)`. */
name: string,
/** `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle`. */
kind: string,
roots: LibraryRoot[],
};
export type LibraryRoot = {
/**
* Folder name, e.g. `python3.12` or `site-packages`, the package (`serde 1.0.200`)
* or the archive (`src.zip`, `guava-33.0.0-jre-sources.jar`).
*/
name: string,
/** A folder, or an archive's root (`<archive>!`). */
path: IpcPath,
};
/** Coverage of one source line. */
export type LineCoverage = {
/** 0-based. */
@@ -2740,6 +3076,13 @@ export type LogLocation = {
file: IpcPath | null,
};
/** Order of the Git Log (Graph Options › Sort). */
export type LogOrder =
/** `--topo-order`: lines of history are not intermixed. */
"topo" |
/** `--date-order`: commit time, but never a parent before its children. */
"date";
export type LogQuery = {
branch: string | null,
path: IpcPath | null,
@@ -2760,10 +3103,34 @@ export type LogQuery = {
branches?: string[],
/** More author needles next to `author`; a commit matches any of them (Log User filter). */
authors?: string[],
order?: LogOrder,
/** Follow only the first parent of merges (`--first-parent`). */
firstParent?: boolean,
/** Leave out merge commits (`--no-merges`); their children link to their parents. */
noMerges?: boolean,
/**
* Committer time bounds (epoch ms; `since` inclusive, `until` exclusive). Parents
* outside the range are rewritten to their nearest shown ancestors (or dropped when
* older), so the graph stays connected.
*/
sinceMs?: number | null,
untilMs?: number | null,
};
export type LspRefreshKind = "inlayHints" | "semanticTokens" | "codeLens";
/**
* Main toolbar entries per side, in order: title widget ids (`project`,
* `vcs.branch`…) or `action:<id>`. Widgets in neither list nor `hidden`
* (registered later) keep their default place.
*/
export type MainToolbarSettings = {
left?: string[],
right?: string[],
/** Widgets the user removed. */
hidden?: string[],
};
export type Markup = {
/** `markdown` or `plaintext`. */
kind: string,
@@ -2864,6 +3231,8 @@ export type MenuConfig = {
labels: { [key in string]: string },
/** Action id → display shortcut from the JS keymap, e.g. `⇧⌘O`. */
shortcuts: { [key in string]: string },
/** [`Entry::Dynamic`] slot → its items, already localised. */
dynamic?: { [key in string]: DynamicItem[] },
};
/**
@@ -2876,6 +3245,16 @@ export type MenuNode = { kind: "submenu"; id: string; label: string; children: M
/** A disabled placeholder such as "No Recent Projects". */
{ kind: "placeholder"; id: string; label: string } | { kind: "separator" };
/** Merge dialog › Modify options (shared by the Pull dialog). */
export type MergeOptions = {
noFf?: boolean,
ffOnly?: boolean,
squash?: boolean,
noCommit?: boolean,
/** `-m`: message of the merge commit (Merge dialog only). */
message?: string | null,
};
/**
* Who stands on each side of the conflicts of the operation in progress (merge editor
* titles, as IDEA labels them).
@@ -2996,6 +3375,8 @@ export type OutlineItem = {
/** The identifier itself; where navigation lands. */
selection_range: Range,
children: OutlineItem[],
/** Unknown (`None`) for language-server symbols and languages without the notion. */
visibility?: Visibility | null,
};
export type OutputChunk = { kind: "stdout"; text: string } | { kind: "stderr"; text: string } |
@@ -3317,6 +3698,10 @@ export type ProjectStructureSettings = {
excluded?: string[],
/** Auto-detected roots (from `pom.xml`, `build.gradle`, `Cargo.toml`, …) the user unmarked. */
unmarked?: string[],
/** Content roots besides the project folder (absolute paths), shown and searched with it. */
contentRoots?: string[],
/** "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth. */
excludeFiles?: string,
};
export type PruneTarget = "containers" |
@@ -3410,6 +3795,14 @@ export type RebaseEntry = {
message?: string | null,
};
/** Rebase dialog options. */
export type RebaseOptions = {
/** `--onto <newbase>`: replay `upstream..HEAD` onto this revision instead. */
onto?: string | null,
rebaseMerges?: boolean,
keepEmpty?: boolean,
};
export type RecentProject = {
path: IpcPath,
name: string,
@@ -3418,6 +3811,17 @@ export type RecentProject = {
branch: string | null,
};
export type Remote = {
name: string,
/** Fetch URL. */
url: string,
};
/** Manage Remotes edits. */
export type RemoteEdit = { op: "add"; name: string; url: string } | { op: "remove"; name: string } |
/** Renames `name` to `new_name` (when different) and sets its URL. */
{ op: "edit"; name: string; new_name: string; url: string };
export type RemoteOp = { op: "fetch" } | { op: "pull"; rebase: boolean } | { op: "push"; force_with_lease: boolean; set_upstream: boolean;
/** Push dialog › Push tags. */
tags?: PushTags | null } |
@@ -3429,7 +3833,9 @@ tags?: PushTags | null } |
*/
{ op: "pushBranch"; branch: string; revision: string | null; force_with_lease: boolean; target?: string | null } |
/** Fast-forwards local `branch` (not checked out) to its upstream. */
{ op: "updateBranch"; branch: string };
{ op: "updateBranch"; branch: string } |
/** Pull dialog: `branches` of `remote` (none = the configured merge branch). */
{ op: "pullFrom"; remote: string; branches: string[]; rebase: boolean; options: MergeOptions };
export type ReplaceEvent =
/** A file was changed (`replacements` > 0) or had nothing left to replace. */
@@ -3492,7 +3898,7 @@ export type RunConfig_Deserialize = {
debugAdapter: string | null,
/** "Allow multiple instances": Run on a running config starts another one instead of restarting it. */
allowParallel?: boolean,
};
} & RunExtras_Deserialize;
export type RunConfig_Serialize = {
/** Stable id, e.g. `npm:dev`, `cargo:run:app`, or a uuid for user-defined. */
@@ -3512,6 +3918,78 @@ export type RunConfig_Serialize = {
debugAdapter: string | null,
/** "Allow multiple instances": Run on a running config starts another one instead of restarting it. */
allowParallel?: boolean,
} & RunExtras_Serialize;
export type RunExtras = RunExtras_Serialize | RunExtras_Deserialize;
export type RunExtras_Deserialize = {
/** Values of the configuration type's own form fields (the command line derives from them). */
params?: { [key in string]: string },
/** Folder in the Run/Debug Configurations tree. */
folder?: string | null,
/** Stored in the workspace (`.openidea/run.local.toml`, not shared) instead of `run.toml`. */
local?: boolean,
/** Off: the process gets only the configured variables. */
includeSystemEnv?: boolean,
/** `.env` files (relative to the project root) loaded before `env`, in order. */
envFiles?: string[],
/** "Redirect input from": the process reads this file as stdin. */
stdinFile?: string | null,
/** "Save console output to file" (overwritten on each run). */
outputFile?: string | null,
beforeLaunch?: BeforeLaunchTask[],
/** Compound configurations: ids of the configurations started together. */
compound?: string[],
showOnStdout?: boolean,
showOnStderr?: boolean,
/** Overrides Settings › Run › "Show the Run tool window when a process starts". */
openToolWindow?: boolean | null,
focusToolWindow?: boolean,
/**
* "Emulate terminal in output console": the process runs on a pseudo-terminal
* (colours, line editing, full-screen programs); the console renders it as a terminal.
*/
emulateTerminal?: boolean,
/**
* Shell command line run on Stop before the process is signalled (e.g. `docker stop <name>`),
* with the run's working directory and environment.
*/
stopCommand?: string | null,
};
export type RunExtras_Serialize = {
/** Values of the configuration type's own form fields (the command line derives from them). */
params?: { [key in string]: string },
/** Folder in the Run/Debug Configurations tree. */
folder?: string | null,
/** Stored in the workspace (`.openidea/run.local.toml`, not shared) instead of `run.toml`. */
local?: boolean,
/** Off: the process gets only the configured variables. */
includeSystemEnv?: boolean,
/** `.env` files (relative to the project root) loaded before `env`, in order. */
envFiles?: string[],
/** "Redirect input from": the process reads this file as stdin. */
stdinFile?: string | null,
/** "Save console output to file" (overwritten on each run). */
outputFile?: string | null,
beforeLaunch?: BeforeLaunchTask[],
/** Compound configurations: ids of the configurations started together. */
compound?: string[],
showOnStdout?: boolean,
showOnStderr?: boolean,
/** Overrides Settings › Run › "Show the Run tool window when a process starts". */
openToolWindow?: boolean | null,
focusToolWindow?: boolean,
/**
* "Emulate terminal in output console": the process runs on a pseudo-terminal
* (colours, line editing, full-screen programs); the console renders it as a terminal.
*/
emulateTerminal?: boolean,
/**
* Shell command line run on Stop before the process is signalled (e.g. `docker stop <name>`),
* with the run's working directory and environment.
*/
stopCommand?: string | null,
};
/** A process started from a run configuration. */
@@ -3523,6 +4001,8 @@ export type RunInfo = {
name: string,
pid: number | null,
running: boolean,
/** Runs on a pseudo-terminal: output is raw terminal data, input goes in keystroke by keystroke. */
pty: boolean,
};
/** Run/Debug configuration behaviour. */
@@ -3615,7 +4095,12 @@ export type SearchScope = { kind: "project" } | { kind: "directory"; path: IpcPa
* IDEA's named scopes over the project's roots ("Project Production Files" …).
* Walks the project like `Project`; the caller passes the matching [`PathFilter`].
*/
{ kind: "fileSet"; set: ProjectFileSet };
{ kind: "fileSet"; set: ProjectFileSet } |
/**
* A custom scope: the project's files matching a [`ScopePattern`]
* (`file:src//*.ts&&!file:*test*`).
*/
{ kind: "pattern"; pattern: string };
export type SearchSummary = {
files: number,
@@ -3671,6 +4156,22 @@ export type SessionInfo = {
export type Settings = Settings_Serialize | Settings_Deserialize;
export type SettingsImport = SettingsImport_Serialize | SettingsImport_Deserialize;
export type SettingsImport_Deserialize = {
/** Effective settings of the calling window after the import. */
settings: Settings_Deserialize,
/** IDE state the frontend keeps (written back to its storage). */
storage: { [key in string]: string },
};
export type SettingsImport_Serialize = {
/** Effective settings of the calling window after the import. */
settings: Settings_Serialize,
/** IDE state the frontend keeps (written back to its storage). */
storage: { [key in string]: string },
};
export type SettingsScope = "global" | "project";
export type Settings_Deserialize = {
@@ -3709,6 +4210,10 @@ export type Settings_Deserialize = {
* command the language definition launches (`elixir-ls`, `codelldb`).
*/
tools?: { [key in string]: ToolSettings },
/** Editor › Inspections profiles. */
inspections?: InspectionSettings,
/** Editor › Natural Languages › Spelling. */
spelling?: SpellingSettings,
};
export type Settings_Serialize = {
@@ -3747,6 +4252,10 @@ export type Settings_Serialize = {
* command the language definition launches (`elixir-ls`, `codelldb`).
*/
tools: { [key in string]: ToolSettings },
/** Editor › Inspections profiles. */
inspections: InspectionSettings,
/** Editor › Natural Languages › Spelling. */
spelling: SpellingSettings,
};
export type Severity = "error" | "warning" | "info" | "hint";
@@ -3804,6 +4313,16 @@ export type SourceFolder = {
detectedFrom: string | null,
};
/** Editor › Natural Languages › Spelling. */
export type SpellingSettings = {
/** Application-level dictionary: accepted words (IDE settings). */
ideWords?: string[],
/** Project-level dictionary: accepted words (project settings). */
projectWords?: string[],
/** Custom dictionaries: plain word lists or Hunspell `.dic` files. */
customDictionaries?: string[],
};
export type SqlStatement = {
/** UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded). */
from: number,
@@ -4188,6 +4707,13 @@ export type TypeScriptSettings = {
version?: string,
};
/** A misspelt word. */
export type Typo = {
/** UTF-16 range of the word. */
range: Range,
word: string,
};
/** What [`FileMoves::undo`] did. */
export type UndoOutcome = {
/** Moves reverted (each `from` is the original location again). */
@@ -4262,6 +4788,11 @@ export type VirtualDocument = {
decompiled: boolean,
};
/** Declared (or conventional) visibility of an outline symbol. */
export type Visibility = "public" | "protected" |
/** Package / module / assembly scope (Java default, `internal`, `pub(crate)`, Go unexported). */
"package" | "private";
/**
* A web reference no language server resolves and where it points: `class="card"` →
* `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
@@ -4301,6 +4832,18 @@ export type WorkspaceSymbol = {
location: Location,
};
export type Worktree = {
path: IpcPath,
/** Checked-out commit; `None` for a bare main repository. */
head: string | null,
/** Short branch name; `None` when HEAD is detached. */
branch: string | null,
/** The main working tree (cannot be removed). */
main: boolean,
locked: boolean,
prunable: boolean,
};
/* Tauri Specta runtime */
async function typedError<T, E>(result: Promise<T>): Promise<{ status: "ok"; data: T } | { status: "error"; error: E }> {
try {
+22
Переглянути файл
@@ -391,6 +391,28 @@ export function _provideOpenEditorPaths(provider: () => string[]) {
openPathsProvider = provider;
}
// --- library sources -----------------------------------------------------------------
const libraryRoots = new Map<string, readonly string[]>();
/**
* Folders of library / SDK sources ("External Libraries") contributed by `owner`;
* files below them open read-only, like IDEA's library sources. Replaces the
* owner's previous roots; returns a disposer.
*/
export function setLibraryRoots(owner: string, roots: readonly string[]): () => void {
libraryRoots.set(owner, roots);
return () => {
if (libraryRoots.get(owner) === roots) libraryRoots.delete(owner);
};
}
export function isLibraryPath(path: string): boolean {
for (const roots of libraryRoots.values())
for (const r of roots) if (path === r || path.startsWith(r.endsWith("/") ? r : `${r}/`)) return true;
return false;
}
// --- documents shown outside editor tabs ---------------------------------------
/**
+29 -1
Переглянути файл
@@ -36,7 +36,9 @@ import { cyclicExpandWord } from "./wordCompletion";
import { qualifiedReference } from "./copyReference";
import { symbolChain } from "./view/outline";
import { recordClipboard } from "./clipboardHistory";
import { useOutline } from "./model/document";
import { useDocMeta, useOutline } from "./model/document";
import { fontZoom, setFontZoom } from "./cm/prefs";
import { useEditorRegistry } from "@/editor/api";
import { useProject } from "@/platform/project";
import { toggleReadOnly } from "./fileAttributes";
import { openNavBarPopup } from "./view/NavBarPopup";
@@ -410,6 +412,32 @@ export function registerEditorActions(): () => void {
return ctrl.reloadFromDisk(paths.length ? paths : active ? [active] : []);
},
},
{
// IDEA's Synchronize: open documents without unsaved changes, the project tree and VCS status.
id: "file.reloadAllFromDisk",
title: "Reload All from Disk",
category: "File",
icon: "refresh",
when: "projectOpen",
keywords: ["synchronize", "refresh"],
run: async () => {
const meta = useDocMeta.getState();
await ctrl.reloadFromDisk(ctrl.openPaths().filter((p) => !meta[p]?.dirty));
void executeAction("project.refresh");
void executeAction("vcs.refresh");
},
},
{
id: "editor.resetFontSize",
title: "Reset Font Size in All Editors",
category: "View",
keywords: ["zoom", "font size", "mouse wheel"],
when: "projectOpen",
run: () => {
for (const ed of useEditorRegistry.getState().editors)
if (ed.view.state.field(fontZoom, false)) ed.view.dispatch({ effects: setFontZoom.of(0) });
},
},
{
id: "file.toggleReadOnly",
title: "Toggle Read-only Attribute",
+3
Переглянути файл
@@ -52,6 +52,8 @@ export interface DocBackend {
onDirty(handler: (e: { doc: DocId; dirty: boolean }) => void): () => void;
/** Files changed on disk (used to refresh nothing but closed-file state). */
onFsChanged?(handler: (e: { removed: string[] }) => void): () => void;
/** An open document's language changed (Settings › File Types, Override File Type). */
onLanguage?(handler: (e: { doc: DocId; language: string }) => void): () => void;
}
export const ipcBackend: DocBackend = {
@@ -71,6 +73,7 @@ export const ipcBackend: DocBackend = {
onPatched: (h) => onIdeEvent("docPatched", (e) => h(e)),
onDirty: (h) => onIdeEvent("docDirty", (e) => h(e)),
onFsChanged: (h) => onIdeEvent("fsChanged", (e) => h(e)),
onLanguage: (h) => onIdeEvent("docLanguage", (e) => h(e)),
};
// ---------------------------------------------------------------------------
+1
Переглянути файл
@@ -55,6 +55,7 @@ export function indentColumns(text: string, tabSize: number): number {
const ICONS: Record<string, string> = {
run: "M4 3l9 5-9 5z",
user: "M8 2a3 3 0 110 6 3 3 0 010-6zM2.5 14c0-3 2.5-5 5.5-5s5.5 2 5.5 5z",
debug: "M8 2a3 3 0 013 3v1h1v2h-1v1h1v2h-1a3 3 0 01-6 0H4V9h1V8H4V6h1V5a3 3 0 013-3z",
};
+73
Переглянути файл
@@ -2,8 +2,14 @@ import { afterEach, describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { setSearchQuery } from "@codemirror/search";
import { StreamLanguage } from "@codemirror/language";
import { literalKindAt } from "./literals";
import {
countMatches,
excludeCurrent,
findExcluded,
isExcluded,
toggleFindExcluded,
findExtension,
findNext,
findPrevious,
@@ -84,3 +90,70 @@ describe("find in selection", () => {
expect(mapScope({ from: 2, to: 5 }, s.changes({ from: 2, to: 5 }))).toBeNull();
});
});
describe("search context, exclude and preserve case", () => {
const lang = StreamLanguage.define<null>({
token(stream) {
if (stream.match("//")) {
stream.skipToEnd();
return "comment";
}
if (stream.match(/^"[^"]*"/)) return "string";
stream.next();
return null;
},
});
const code = 'foo("foo") // foo\nfoo';
const base = { search: "", caseSensitive: false, wholeWord: false, regexp: false, replace: "" };
function makeCode(o: Partial<FindOptions>) {
const view = new EditorView({
state: EditorState.create({ doc: code, extensions: [lang, findExtension()] }),
parent: document.body,
});
views.push(view);
view.dispatch({ effects: setSearchQuery.of(makeQuery({ ...base, search: "foo", ...o })) });
return view;
}
const starts = (v: EditorView) => countMatches(v.state).starts;
it("filters matches by comment / string literal", () => {
expect(starts(makeCode({}))).toEqual([0, 5, 14, 18]);
expect(starts(makeCode({ context: "inComments" }))).toEqual([14]);
expect(starts(makeCode({ context: "inStringLiterals" }))).toEqual([5]);
expect(starts(makeCode({ context: "exceptComments" }))).toEqual([0, 5, 18]);
expect(starts(makeCode({ context: "exceptCommentsAndStringLiterals" }))).toEqual([0, 18]);
expect(literalKindAt(makeCode({}).state, 14)).toBe("comment");
});
it("excluded matches are skipped by next and replace all, until the query changes", () => {
const v = makeCode({ replace: "bar" });
findNext(v);
expect(sel(v)).toEqual([0, 3]);
excludeCurrent(v);
expect(sel(v)).toEqual([5, 8]);
expect(isExcluded(v.state, 0, 3)).toBe(true);
expect(countMatches(v.state).total).toBe(3);
replaceAll(v, false);
expect(v.state.doc.toString()).toBe('foo("bar") // bar\nbar');
v.dispatch({ effects: setSearchQuery.of(makeQuery({ ...base, search: "o" })) });
expect(v.state.field(findExcluded)).toEqual([]);
});
it("excluding an excluded match includes it again", () => {
const v = makeCode({});
v.dispatch({ effects: toggleFindExcluded.of({ from: 0, to: 3 }), selection: { anchor: 0, head: 3 } });
excludeCurrent(v);
expect(isExcluded(v.state, 0, 3)).toBe(false);
});
it("preserve case keeps each occurrence's case", () => {
const v = make("foo Foo FOO", { search: "foo", replace: "bar" });
replaceAll(v, true);
expect(v.state.doc.toString()).toBe("bar Bar BAR");
const w = make("Foo foo", { search: "foo", replace: "bar" });
findNext(w);
replaceNext(w, true);
expect(w.state.doc.toString()).toBe("Bar foo");
expect(sel(w)).toEqual([4, 7]);
});
});
+91 -10
Переглянути файл
@@ -3,7 +3,9 @@
* @codemirror/search's query + commands; the bar itself is a React component
* (view/FindBar.tsx). Highlighting is ours because CodeMirror only highlights
* while its own panel is open. Match positions found by the bar's counter are
* kept in `findMarks` for the overview lane.
* kept in `findMarks` for the overview lane. The search context (comments /
* string literals) and excluded matches filter matches through the query's
* `test`, so CodeMirror's own commands honour them too.
*/
import { EditorSelection, Prec, StateEffect, StateField, type ChangeDesc, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
@@ -18,6 +20,8 @@ import {
setSearchQuery,
} from "@codemirror/search";
import { scrollbarMarks, type ScrollbarMark } from "@/editor/api";
import { contextAccepts, preserveCase, type SearchContext } from "@/platform/search";
import { literalKindAt, literalsExtension } from "./literals";
export const setFindOpen = StateEffect.define<boolean>();
export const setFindMarks = StateEffect.define<number[]>();
@@ -51,6 +55,46 @@ export const findScope = StateField.define<FindScope | null>({
provide: (f) => Prec.lowest(EditorView.decorations.from(f, (s) => (s ? Decoration.set([scopeDeco.range(s.from, s.to)]) : Decoration.none))),
});
/** IDEA's Exclude: matches skipped by Next / Previous and Replace All until the query changes. */
export const toggleFindExcluded = StateEffect.define<FindScope>();
export const findExcluded = StateField.define<readonly FindScope[]>({
create: () => [],
update(v, tr) {
for (const e of tr.effects) {
if (e.is(setSearchQuery) || (e.is(setFindOpen) && !e.value)) v = [];
else if (e.is(toggleFindExcluded)) {
const { from, to } = e.value;
v = v.some((x) => x.from === from && x.to === to)
? v.filter((x) => x.from !== from || x.to !== to)
: [...v, { from, to }];
}
}
if (!tr.docChanged || !v.length) return v;
return v
.map((x) => ({ from: tr.changes.mapPos(x.from, 1), to: tr.changes.mapPos(x.to, -1) }))
.filter((x) => x.to > x.from);
},
});
export function isExcluded(state: EditorState, from: number, to: number): boolean {
return (state.field(findExcluded, false) ?? []).some((x) => x.from === from && x.to === to);
}
type MatchTest = (match: string, state: EditorState, from: number, to: number) => boolean;
const tests = new Map<SearchContext, MatchTest>();
/** One stable test per context, so equal options give equal queries (`SearchQuery.eq`). */
function testFor(context: SearchContext): MatchTest {
let test = tests.get(context);
if (!test) {
test = (_match, state, from, to) =>
!isExcluded(state, from, to) && (context === "any" || contextAccepts(context, literalKindAt(state, from)));
tests.set(context, test);
}
return test;
}
function scopeOf(state: EditorState): FindScope {
return state.field(findScope, false) ?? { from: 0, to: state.doc.length };
}
@@ -82,6 +126,7 @@ export const findMarks = StateField.define<number[]>({
const matchDeco = Decoration.mark({ class: "cm-openidea-findMatch" });
const currentDeco = Decoration.mark({ class: "cm-openidea-findMatch cm-openidea-findMatch-current" });
const excludedDeco = Decoration.mark({ class: "cm-openidea-findExcluded" });
const MAX_VISIBLE = 2000;
function buildHighlights(view: EditorView): DecorationSet {
@@ -104,6 +149,8 @@ function buildHighlights(view: EditorView): DecorationSet {
ranges.push((m.from === main.from && m.to === main.to ? currentDeco : matchDeco).range(m.from, m.to));
}
}
for (const x of state.field(findExcluded, false) ?? [])
if (x.from >= scope.from && x.to <= scope.to) ranges.push(excludedDeco.range(x.from, x.to));
return Decoration.set(ranges, true);
}
@@ -120,7 +167,8 @@ const findHighlighter = ViewPlugin.fromClass(
u.selectionSet ||
u.startState.field(findOpen, false) !== u.state.field(findOpen, false) ||
getSearchQuery(u.startState) !== getSearchQuery(u.state) ||
u.startState.field(findScope, false) !== u.state.field(findScope, false)
u.startState.field(findScope, false) !== u.state.field(findScope, false) ||
u.startState.field(findExcluded, false) !== u.state.field(findExcluded, false)
)
this.decorations = buildHighlights(u.view);
}
@@ -136,7 +184,9 @@ export function findExtension(): Extension {
}),
findOpen,
findScope,
findExcluded,
findMarks,
literalsExtension,
findHighlighter,
];
}
@@ -147,6 +197,10 @@ export interface FindOptions {
wholeWord: boolean;
regexp: boolean;
replace: string;
/** IDEA's search scope filter: anywhere, only / except comments and string literals. */
context?: SearchContext;
/** Replace keeps the case of each occurrence. */
preserveCase?: boolean;
}
export function makeQuery(o: FindOptions) {
@@ -158,6 +212,7 @@ export function makeQuery(o: FindOptions) {
wholeWord: o.wholeWord,
regexp: o.regexp,
replace: o.replace,
test: testFor(o.context ?? "any"),
});
}
@@ -251,30 +306,56 @@ export function findPrevious(view: EditorView): boolean {
return view.state.field(findScope, false) ? step(view, false) : cmFindPrevious(view);
}
/** The text replacing `m`, keeping its case when `preserve` is set. */
function insertFor(state: EditorState, query: SearchQuery, m: Match, preserve: boolean): string {
const insert = replacementFor(query, m);
return preserve ? preserveCase(state.sliceDoc(m.from, m.to), insert) : insert;
}
/** Replaces the selected match (if it is one) and selects the next match. */
export function replaceNext(view: EditorView): boolean {
export function replaceNext(view: EditorView, preserve = false): boolean {
const { state } = view;
if (!state.field(findScope, false)) return cmReplaceNext(view);
if (!state.field(findScope, false) && !preserve) return cmReplaceNext(view);
if (state.readOnly) return false;
const query = getSearchQuery(state);
const ms = matchesIn(state);
const { from, to } = state.selection.main;
const cur = ms.find((m) => m.from === from && m.to === to);
if (!cur) return step(view, true);
const insert = replacementFor(query, cur);
view.dispatch({ changes: { from: cur.from, to: cur.to, insert }, selection: EditorSelection.cursor(cur.from + insert.length), userEvent: "input.replace" });
const next = matchesIn(view.state).find((m) => m.from >= cur.from + insert.length);
const insert = insertFor(state, query, cur, preserve);
view.dispatch({
changes: { from: cur.from, to: cur.to, insert },
selection: EditorSelection.cursor(cur.from + insert.length),
userEvent: "input.replace",
});
const after = matchesIn(view.state);
const next = after.find((m) => m.from >= cur.from + insert.length) ?? after[0];
if (next) selectMatch(view, next);
return true;
}
export function replaceAll(view: EditorView): boolean {
export function replaceAll(view: EditorView, preserve = false): boolean {
const { state } = view;
if (!state.field(findScope, false)) return cmReplaceAll(view);
if (!state.field(findScope, false) && !preserve) return cmReplaceAll(view);
if (state.readOnly) return false;
const query = getSearchQuery(state);
const changes = matchesIn(state).map((m) => ({ from: m.from, to: m.to, insert: replacementFor(query, m) }));
const changes = matchesIn(state).map((m) => ({
from: m.from,
to: m.to,
insert: insertFor(state, query, m, preserve),
}));
if (!changes.length) return false;
view.dispatch({ changes, userEvent: "input.replace.all" });
return true;
}
/** IDEA's Exclude: skips the selected match (or includes it again) and selects the next one. */
export function excludeCurrent(view: EditorView): boolean {
const { from, to } = view.state.selection.main;
const excluded = isExcluded(view.state, from, to);
if (!excluded && (from === to || !matchesIn(view.state).some((m) => m.from === from && m.to === to)))
return step(view, true);
view.dispatch({ effects: toggleFindExcluded.of({ from, to }) });
if (!excluded) step(view, true);
return true;
}
+95
Переглянути файл
@@ -0,0 +1,95 @@
/**
* Comments and string literals of a document, for the find bar's search
* context ("In Comments", "Except String Literals" …): from the Lezer syntax
* tree, or from the backend's tree-sitter highlights for languages without a
* CodeMirror mode.
*/
import { StateField, type EditorState } from "@codemirror/state";
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
import type { SyntaxHighlightKind } from "@/platform/ipc";
import type { LiteralKind } from "@/platform/search";
import { setSyntaxHighlights } from "./syntaxHighlight";
/** Sorted, non-overlapping `[from, to)` ranges. */
export interface LiteralRange {
from: number;
to: number;
kind: LiteralKind;
}
const KINDS: Partial<Record<SyntaxHighlightKind, LiteralKind>> = {
comment: "comment",
docComment: "comment",
string: "string",
stringSpecial: "string",
escape: "string",
};
/** Literal ranges of the tree-sitter highlights, mapped through edits until the next refresh. */
const treeSitterLiterals = StateField.define<readonly LiteralRange[]>({
create: () => [],
update(v, tr) {
for (const e of tr.effects) {
if (!e.is(setSyntaxHighlights)) continue;
const { kinds, data } = e.value;
const out: LiteralRange[] = [];
for (let i = 0; i + 2 < data.length; i += 3) {
const kind = KINDS[kinds[data[i + 2]!]!];
if (kind) push(out, data[i]!, data[i + 1]!, kind);
}
return out;
}
if (!tr.docChanged || !v.length) return v;
return v
.map((r) => ({ from: tr.changes.mapPos(r.from, 1), to: tr.changes.mapPos(r.to, -1), kind: r.kind }))
.filter((r) => r.to > r.from);
},
});
export const literalsExtension = treeSitterLiterals;
const highlighter = tagHighlighter([
{ tag: t.comment, class: "comment" },
{ tag: t.string, class: "string" },
]);
function push(out: LiteralRange[], from: number, to: number, kind: LiteralKind) {
const last = out[out.length - 1];
if (last && last.kind === kind && last.to >= from) last.to = Math.max(last.to, to);
else if (to > from) out.push({ from, to, kind });
}
const cache = new WeakMap<object, readonly LiteralRange[]>();
/** How long counting may wait for the parser to reach the end of the document. */
const PARSE_BUDGET_MS = 50;
/** Comments and string literals of `state` (cached per syntax tree). */
export function literalRanges(state: EditorState): readonly LiteralRange[] {
const tree = ensureSyntaxTree(state, state.doc.length, PARSE_BUDGET_MS) ?? syntaxTree(state);
if (tree.length === 0) return state.field(treeSitterLiterals, false) ?? [];
const hit = cache.get(tree);
if (hit) return hit;
const out: LiteralRange[] = [];
highlightTree(tree, highlighter, (from, to, cls) => {
const kind: LiteralKind | null = cls.includes("comment") ? "comment" : cls.includes("string") ? "string" : null;
if (kind) push(out, from, to, kind);
});
cache.set(tree, out);
return out;
}
/** What `pos` lies in: a comment, a string literal or plain code (`null`). */
export function literalKindAt(state: EditorState, pos: number): LiteralKind | null {
const ranges = literalRanges(state);
let lo = 0;
let hi = ranges.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const r = ranges[mid]!;
if (pos < r.from) hi = mid - 1;
else if (pos >= r.to) lo = mid + 1;
else return r.kind;
}
return null;
}
+4
Переглянути файл
@@ -176,6 +176,10 @@ export const openideaTheme = EditorView.theme({
backgroundColor: "var(--editor-search)",
borderRadius: "2px",
},
".cm-openidea-findExcluded": {
textDecoration: "line-through",
textDecorationColor: "var(--fg-muted)",
},
".cm-openidea-findScope": {
backgroundColor: "var(--editor-selection-inactive)",
},
+8 -1
Переглянути файл
@@ -7,7 +7,7 @@ import { EditorSelection } from "@codemirror/state";
import { create } from "zustand";
import { setContext } from "@/platform/context";
import { shortcutLabel } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { basename, useProject } from "@/platform/project";
import { editorPrefs } from "@/platform/editorSettings";
import {
_emitEditorEvent,
@@ -50,6 +50,7 @@ import {
removePane,
reopenClosed,
setActivePane,
sortTabs,
splitPane,
tabOptions,
togglePin,
@@ -130,6 +131,11 @@ export function openFilePaths(s: TabsState): string[] {
return out;
}
/** Editor Tabs › Sort tabs alphabetically: by file name. */
function tabSortKey(input: EditorInput): string {
return basename(input.path ?? input.key);
}
function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: string } } {
return !!tab && isTextInput(tab.input);
}
@@ -776,6 +782,7 @@ export class EditorController implements SlotHost {
});
s = r.state;
tabId = r.tabId;
if (tabs.sortAlphabetically) s = sortTabs(s, paneId, (t) => tabSortKey(t.input));
s = enforceTabLimit(
s,
paneId,
+9 -1
Переглянути файл
@@ -6,15 +6,18 @@
import {
provideEditorArea,
provideEditorService,
registerEditorBanner,
registerOverlay,
registerStatusItem,
} from "@/workbench/contributions";
import { _provideCodePreview, _provideDocumentAttacher } from "@/editor/api";
import { _provideCodePreview, _provideDocumentAttacher, editorsFor } from "@/editor/api";
import { formatDocument } from "@/editor/lsp/format";
import { editorController } from "./controller";
import { attachDocument } from "./model/attachment";
import { previewExtensions } from "./cm/preview";
import { registerEditorActions } from "./actions";
import { installColorScheme } from "./colorSchemeRuntime";
import { documentBanner } from "./view/Banner";
import { EditorAreaView } from "./view/EditorArea";
import { GotoLineDialog } from "./view/GotoLineDialog";
import { NavBarPopup } from "./view/NavBarPopup";
@@ -41,6 +44,10 @@ export function activate(): void | (() => void) {
openPaths: () => ctrl.openPaths(),
closePath: (path) => ctrl.closePath(path),
closeInput: (key) => ctrl.closeInput(key),
reformat: async (path) => {
const handle = editorsFor(path)[0];
return !!handle && (await formatDocument(handle.view, { explicit: false, selectionOnly: false })).applied;
},
});
_provideDocumentAttacher((path) => attachDocument(ctrl.manager, path));
_provideCodePreview((path) => previewExtensions(path, ctrl.manager.settings()?.editor));
@@ -48,6 +55,7 @@ export function activate(): void | (() => void) {
installColorScheme(),
provideEditorArea(EditorAreaView),
registerEditorActions(),
registerEditorBanner(documentBanner),
registerOverlay({ id: "editor.gotoLine", component: GotoLineDialog }),
registerOverlay({ id: "editor.navigationBar", component: NavBarPopup }),
installClipboardTracking(),
+29 -4
Переглянути файл
@@ -11,6 +11,7 @@ import type { DocId, DocumentInfo, EditorSettings, FileSettings, LineEnding, Out
import {
_emitEditorEvent,
_saveParticipants,
isLibraryPath,
mirroredChange,
remoteChange,
useEditorRegistry,
@@ -44,7 +45,7 @@ import {
import { setMethodLines, softWrapActive } from "../cm/prefs";
import type { EditorSlot } from "./slot";
export type Banner = "binary" | "tooLarge" | "readOnly" | null;
export type Banner = "binary" | "tooLarge" | "readOnly" | "library" | null;
/** A view attached to a document outside the tab system (see `attachment.ts`). */
export interface DocAttachmentTarget {
@@ -73,7 +74,9 @@ export const useOutline = create<Record<string, OutlineItem[]>>(() => ({}));
const REFRESH_DELAY = 450;
function bannerFor(info: DocumentInfo): Banner {
// Virtual documents (library classes) get their own notice from the editor area.
if (!info.readOnly || info.uri) return null;
if (info.uri) return null;
if (isLibraryPath(info.path)) return "library";
if (!info.readOnly) return null;
if (!info.text.length) return "binary";
// The backend makes files above its size limit read-only and flags them `large`.
if (info.large) return "tooLarge";
@@ -181,6 +184,8 @@ export class DocModel {
editorconfig: EditorConfigProps | null = null;
desc: LanguageDescription | null;
language: LanguageSupport | null;
/** Too large for language support (no CodeMirror language, backend highlighting only). */
private large: boolean;
lastFocused: EditorSlot | null = null;
/** Bumped by every change (local or remote). */
changeCounter = 0;
@@ -200,6 +205,7 @@ export class DocModel {
this.editorconfig = info.editorconfig ?? null;
// `large`: the backend skips LSP and syntax trees for it; heavy UI features follow.
const large = !!info.large || info.text.length > HEAVY_LIMIT;
this.large = large;
this.desc = large ? null : findLanguageDescription(this.info?.uiMode ?? null, info.path);
this.language = loadedSupport(this.desc);
this.meta = {
@@ -209,7 +215,8 @@ export class DocModel {
uiMode: this.info?.uiMode ?? this.desc?.name ?? null,
encoding: info.encoding,
lineEnding: info.lineEnding,
readOnly: info.readOnly,
// Library sources (External Libraries) open read-only, like IDEA's.
readOnly: info.readOnly || (!this.virtual && isLibraryPath(info.path)),
dirty: info.dirty,
banner: bannerFor(info),
indent: { tabSize: 4, insertSpaces: true },
@@ -322,6 +329,23 @@ export class DocModel {
this.scheduleRefresh();
}
/** The backend detected another language (Settings › File Types, Override File Type). */
setLanguage(language: string) {
if (this.closed || language === this.meta.language) return;
this.info = languageInfo(language);
this.desc = this.large ? null : findLanguageDescription(this.info?.uiMode ?? null, this.path);
this.language = loadedSupport(this.desc);
this.highlights = null;
this.setMeta({ language, uiMode: this.info?.uiMode ?? this.desc?.name ?? null });
for (const s of this.slots) {
s.reconfigure([compartments.language.reconfigure(this.language ?? [])]);
s.syncContributions(true);
}
this.applySettings();
if (this.desc && !this.language) void this.loadLanguage();
this.scheduleRefresh(0);
}
async loadLanguage() {
if (!this.desc) return;
const support = await loadSupport(this.desc);
@@ -397,7 +421,7 @@ export class DocModel {
setReadOnly(readOnly: boolean) {
if (readOnly === this.meta.readOnly) return;
const banner = this.meta.banner;
this.setMeta({ readOnly, banner: readOnly ? (banner ?? (this.virtual ? null : "readOnly")) : banner === "readOnly" ? null : banner });
this.setMeta({ readOnly, banner: readOnly ? (banner ?? (this.virtual ? null : "readOnly")) : banner === "readOnly" || banner === "library" ? null : banner });
for (const s of this.slots) {
s.reconfigure([
compartments.readOnly.reconfigure(readOnlyExtension(readOnly)),
@@ -570,6 +594,7 @@ export class DocumentManager {
start() {
this.disposers.push(
this.backend.onPatched((e) => this.byId.get(e.doc)?.sync.remote(e)),
this.backend.onLanguage?.((e) => this.byId.get(e.doc)?.setLanguage(e.language)) ?? (() => {}),
this.backend.onDirty((e) => {
const m = this.byId.get(e.doc);
if (m && m.getMeta().dirty !== e.dirty) {
+10
Переглянути файл
@@ -15,6 +15,7 @@ import {
paneIds,
promoteTab,
reopenClosed,
sortTabs,
splitPane,
tabOptions,
togglePin,
@@ -181,6 +182,15 @@ describe("tab model", () => {
s = cycleTab(s, -1);
expect(active(s)).toBe("c");
});
it("sorts tabs alphabetically, pinned ones first", () => {
let s = initialTabsState();
for (const p of ["c", "B", "a", "d"]) s = open(s, p);
s = togglePin(s, s.activePane, s.panes[s.activePane]!.tabs.find((t) => t.input.path === "d")!.id);
s = sortTabs(s, s.activePane, (t) => t.input.path!);
expect(labels(s)).toEqual(["d^", "a", "B", "c"]);
expect(sortTabs(s, s.activePane, (t) => t.input.path!)).toBe(s);
});
});
describe("navigation history", () => {
+9
Переглянути файл
@@ -320,6 +320,15 @@ function rank(pane: Pane, tab: Tab): number {
return i < 0 ? Number.MAX_SAFE_INTEGER : i;
}
/** Editor Tabs › Sort tabs alphabetically: orders the pinned and the other tabs of a pane by `title`. */
export function sortTabs(s: TabsState, paneId: string, title: (t: Tab) => string): TabsState {
const pane = paneOf(s, paneId);
const byTitle = (a: Tab, b: Tab) =>
title(a).localeCompare(title(b), undefined, { sensitivity: "base" }) || title(a).localeCompare(title(b));
const tabs = [...pane.tabs.filter((t) => t.pinned).sort(byTitle), ...pane.tabs.filter((t) => !t.pinned).sort(byTitle)];
return tabs.every((t, i) => t === pane.tabs[i]) ? s : withPane(s, { ...pane, tabs });
}
export function cycleTab(s: TabsState, delta: number, paneId = s.activePane): TabsState {
const pane = paneOf(s, paneId);
if (pane.tabs.length < 2) return s;
+47 -11
Переглянути файл
@@ -1,4 +1,6 @@
import { Icon } from "@/ui";
import { useMemo } from "react";
import { Icon, T } from "@/ui";
import { useEditorBanners, type EditorBannerContribution } from "@/workbench/contributions";
import { useDocMeta } from "../model/document";
import styles from "./EditorArea.module.css";
@@ -6,19 +8,18 @@ const TEXT = {
binary: "This file appears to be binary and is shown read-only.",
tooLarge: "The file is too large: editing and code insight are disabled.",
readOnly: "The file is read-only.",
library: "Library source (read-only).",
} as const;
/** Notice above read-only / binary / too-large documents. */
export function Banner({ path }: { path: string }) {
const banner = useDocMeta((s) => s[path]?.banner ?? null);
if (!banner) return null;
return (
<div className={styles.banner} role="status">
<Icon name={banner === "readOnly" ? "lock" : "info"} size={14} className={styles.bannerIcon} />
<span>{TEXT[banner]}</span>
</div>
);
}
export const documentBanner: EditorBannerContribution = {
id: "editor.document",
order: -10,
useBanner: (path) => {
const banner = useDocMeta((s) => s[path]?.banner ?? null);
return banner ? { text: TEXT[banner], icon: banner === "readOnly" || banner === "library" ? "lock" : "info" } : null;
},
};
/**
* Notice above a library class opened from a language server (IDEA's
@@ -39,3 +40,38 @@ export function VirtualBanner({ decompiled, description }: { decompiled: boolean
</div>
);
}
function ContributedBanner({ banner, path }: { banner: EditorBannerContribution; path: string }) {
const b = banner.useBanner(path);
if (!b) return null;
return (
<div className={`${styles.banner} ${b.level === "warning" ? styles.libraryBanner : ""}`} role="status">
<Icon name={b.icon ?? (b.level === "warning" ? "warning" : "info")} size={14} className={styles.bannerIcon} />
<span className={styles.bannerText}>
<T>{b.text}</T>
</span>
<span className={styles.bannerSpacer} />
{b.actions?.map((a) => (
<button key={a.label} type="button" className={styles.bannerLink} onClick={a.run}>
<T>{a.label}</T>
</button>
))}
</div>
);
}
/** Banners features contribute for `path` (`registerEditorBanner`), in order. */
export function EditorBanners({ path }: { path: string }) {
const map = useEditorBanners((s) => s.banners);
const banners = useMemo(
() => [...map.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id)),
[map],
);
return (
<>
{banners.map((b) => (
<ContributedBanner key={b.id} banner={b} path={path} />
))}
</>
);
}
+52 -11
Переглянути файл
@@ -217,19 +217,45 @@
.tabsHidden {
display: none;
}
.tabsMultiRow {
height: auto;
min-height: var(--h-header);
.tabRows {
flex: none;
display: flex;
flex-direction: column;
min-width: 0;
}
.tabsMultiRow > [role="tablist"] {
flex: 1;
flex-wrap: wrap;
overflow: visible;
row-gap: 2px;
padding: 3px 0;
.tabsBottom > .tabs,
.tabsBottom > .tabRows {
order: 1;
}
.tabsMultiRow > [role="tablist"] > * {
height: calc(var(--h-header) - 6px);
.tabsBottom .tabs {
border-bottom: none;
border-top: 1px solid var(--border-editor-tabs, var(--border));
}
/* Tabs on the left / right of the editor (a column). */
.tabsVertical {
flex-direction: row;
}
.tabsVertical > .tabs,
.tabsVertical > .tabRows {
width: max-content;
min-width: 140px;
max-width: 260px;
border-bottom: none;
border-right: 1px solid var(--border-editor-tabs, var(--border));
}
.tabsVertical .tabRows > .tabs {
min-height: 0;
border-bottom: none;
}
.tabsVertical .tabRows > .tabs + .tabs {
flex: 1 1 auto;
border-top: 1px solid var(--border-editor-tabs, var(--border));
}
.tabsRight > .tabs,
.tabsRight > .tabRows {
order: 1;
border-right: none;
border-left: 1px solid var(--border-editor-tabs, var(--border));
}
/* Breadcrumbs inside the editor (top or bottom of the text) */
@@ -251,3 +277,18 @@
.codeCrumbsBottom {
border-top: 1px solid var(--border-editor-tabs, var(--border));
}
/* Contributed editor banners (registerEditorBanner): text, then right-aligned links. */
.bannerLink {
flex: none;
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.bannerLink:hover {
color: var(--link-hover);
text-decoration: underline;
}
+91 -43
Переглянути файл
@@ -42,7 +42,7 @@ import type { LayoutNode, SplitEdge, Tab } from "../model/tabs";
import { disambiguateTitles } from "../model/tabTitles";
import { TextEditorHost } from "./TextEditorHost";
import { FindBar } from "./FindBar";
import { Banner, VirtualBanner } from "./Banner";
import { EditorBanners, VirtualBanner } from "./Banner";
import { CodeBreadcrumbs } from "./StatusItems";
import styles from "./EditorArea.module.css";
@@ -147,12 +147,13 @@ function PaneView({ paneId }: { paneId: string }) {
const paths = tabs.map((t) => t.input.path ?? "");
const dirty = useDocMeta(useShallow((s) => paths.map((p) => !!s[p]?.dirty)));
const colors = useFileDecorations(useShallow(() => paths.map((p) => (p ? (getFileDecoration(p)?.color ?? "") : ""))));
const tints = useFileDecorations(useShallow(() => paths.map((p) => (p ? (getFileDecoration(p)?.tint ?? "") : ""))));
const hints = useTitleHints();
const [dropZone, setDropZone] = useState<DropZone | null>(null);
const tabPrefs = useEditorPrefs().tabs;
const distractionFree = useDistractionFree((s) => s.active);
const { showExtension, markModified, showCloseButton } = tabPrefs;
const { showExtension, markModified, showCloseButton, showFileIcon, showDirectoryForNonUnique, showFullPathOnHover } = tabPrefs;
const items: TabItem[] = useMemo(() => {
const names = tabs.map((t) => tabTitle(t.input, kinds));
@@ -160,8 +161,9 @@ function PaneView({ paneId }: { paneId: string }) {
const full = names[i]!;
const dot = full.lastIndexOf(".");
const name = t.input.kind === "file" && !showExtension && dot > 0 ? full.slice(0, dot) : full;
const dupe =
t.input.kind === "file" && t.input.path
const dupe = !showDirectoryForNonUnique
? null
: t.input.kind === "file" && t.input.path
? (hints.get(t.input.path) ?? null)
: names.filter((n) => n === name).length > 1 && t.input.path
? basename(dirname(t.input.path))
@@ -175,28 +177,47 @@ function PaneView({ paneId }: { paneId: string }) {
</>
),
text: dupe ? `${name} — ${dupe}` : name,
icon: tabIcon(t.input, kinds),
icon: showFileIcon ? tabIcon(t.input, kinds) : undefined,
italic: t.preview,
modified: dirty[i],
closable: showCloseButton,
color: colors[i] || undefined,
tooltip: virtualTooltip(t.input) ?? t.input.path ?? name,
tint: tints[i] || undefined,
tooltip: virtualTooltip(t.input) ?? (showFullPathOnHover ? (t.input.path ?? name) : undefined),
trailing: t.pinned ? <Icon name="pin" size={11} className={styles.pin} label="Pinned" /> : undefined,
};
});
}, [tabs, kinds, dirty, colors, hints, showExtension, markModified, showCloseButton]);
}, [
tabs,
kinds,
dirty,
colors,
tints,
hints,
showExtension,
markModified,
showCloseButton,
showFileIcon,
showDirectoryForNonUnique,
showFullPathOnHover,
]);
const dnd: TabsDnd = useMemo(
() => ({
const pinnedCount = tabs.filter((t) => t.pinned).length;
// Settings › Editor Tabs › Tab placement Left / Right: a vertical, scrolling column.
const vertical = tabPrefs.placement === "left" || tabPrefs.placement === "right";
const pinnedRow =
tabPrefs.pinnedSeparateRow && (vertical || !tabPrefs.multipleRows) && pinnedCount > 0 && pinnedCount < tabs.length;
const dnd = useMemo(() => {
const strip = (offset: number): TabsDnd => ({
type: EDITOR_TAB_MIME,
payload: (tabId) => JSON.stringify({ paneId, tabId }),
onDrop: (raw, index) => {
const p = parseTabPayload(raw);
if (p) ctrl.moveTab(p.paneId, p.tabId, paneId, index);
if (p) ctrl.moveTab(p.paneId, p.tabId, paneId, index + offset);
},
}),
[ctrl, paneId],
);
});
return { all: strip(0), rest: strip(pinnedCount) };
}, [ctrl, paneId, pinnedCount]);
if (!pane) return null;
const active = pane.tabs.find((t) => t.id === pane.active) ?? null;
@@ -236,42 +257,65 @@ function PaneView({ paneId }: { paneId: string }) {
},
};
const hidden = tabPrefs.placement === "none" || distractionFree;
const strip = (stripItems: TabItem[], stripDnd: TabsDnd, trailing: ReactNode) => (
<Tabs
className={clsx(styles.tabs, !pinnedRow && hidden && styles.tabsHidden)}
variant="editor"
orientation={vertical ? "vertical" : "horizontal"}
aria-label="Editor tabs"
items={stripItems}
activeId={pane.active}
panelIdPrefix={`editor-${paneId}`}
dnd={stripDnd}
layout={tabPrefs.multipleRows ? "wrap" : tabPrefs.oneRowOverflow === "squeeze" ? "squeeze" : "scroll"}
closePosition={tabPrefs.closeButtonPosition === "left" ? "left" : "right"}
onSelect={(id) => ctrl.activate(paneId, id)}
onClose={(id) => ctrl.closeTab(paneId, id)}
onCloseOthers={(id) => ctrl.closeOthers(paneId, id)}
onDoubleClick={(id) => {
// IDEA: a preview tab becomes a regular one; any other tab maximizes the editor.
if (pane.tabs.find((t) => t.id === id)?.preview) ctrl.keepTab(paneId, id);
else void executeAction("toolwindow.hideAll");
}}
onContextMenu={(id, e) => {
const tab = pane.tabs.find((t) => t.id === id);
if (!tab) return;
e.preventDefault();
// Contributed groups (Run '…') may read a background tab's file first (bounded wait).
const at = { x: e.clientX, y: e.clientY };
const path = tab.input.kind === "file" ? tab.input.path : undefined;
if (!path) return showContextMenu(at, ctrl.tabMenu(paneId, tab));
void prepareMenuGroups("editorTab", { paths: [path] }).then(() => showContextMenu(at, ctrl.tabMenu(paneId, tab)));
}}
trailing={trailing}
/>
);
const actions = active ? <EditorTabActions paneId={paneId} tab={active} /> : undefined;
return (
<section
className={clsx(styles.pane, multiPane && !isActivePane && styles.inactivePane)}
className={clsx(
styles.pane,
multiPane && !isActivePane && styles.inactivePane,
tabPrefs.placement === "bottom" && styles.tabsBottom,
vertical && styles.tabsVertical,
tabPrefs.placement === "right" && styles.tabsRight,
)}
data-pane-id={paneId}
aria-label="Editor"
onFocus={onFocus}
onBlur={onBlur}
>
<Tabs
className={clsx(styles.tabs, (tabPrefs.placement === "none" || distractionFree) && styles.tabsHidden, tabPrefs.multipleRows && styles.tabsMultiRow)}
variant="editor"
aria-label="Editor tabs"
items={items}
activeId={pane.active}
panelIdPrefix={`editor-${paneId}`}
dnd={dnd}
onSelect={(id) => ctrl.activate(paneId, id)}
onClose={(id) => ctrl.closeTab(paneId, id)}
onCloseOthers={(id) => ctrl.closeOthers(paneId, id)}
onDoubleClick={(id) => {
// IDEA: a preview tab becomes a regular one; any other tab maximizes the editor.
if (pane.tabs.find((t) => t.id === id)?.preview) ctrl.keepTab(paneId, id);
else void executeAction("toolwindow.hideAll");
}}
onContextMenu={(id, e) => {
const tab = pane.tabs.find((t) => t.id === id);
if (!tab) return;
e.preventDefault();
// Contributed groups (Run '…') may read a background tab's file first (bounded wait).
const at = { x: e.clientX, y: e.clientY };
const path = tab.input.kind === "file" ? tab.input.path : undefined;
if (!path) return showContextMenu(at, ctrl.tabMenu(paneId, tab));
void prepareMenuGroups("editorTab", { paths: [path] }).then(() => showContextMenu(at, ctrl.tabMenu(paneId, tab)));
}}
trailing={active ? <EditorTabActions paneId={paneId} tab={active} /> : undefined}
/>
{pinnedRow ? (
// Settings › Editor Tabs › Show pinned tabs in a separate row.
<div className={clsx(styles.tabRows, hidden && styles.tabsHidden)}>
{strip(items.slice(0, pinnedCount), dnd.all, vertical ? actions : undefined)}
{strip(items.slice(pinnedCount), dnd.rest, vertical ? undefined : actions)}
</div>
) : (
strip(items, dnd.all, actions)
)}
<div
className={styles.content}
role="tabpanel"
@@ -303,7 +347,11 @@ function PaneContent({ paneId, tab }: { paneId: string; tab: Tab }) {
const host = <TextEditorHost key={tab.id} tabId={tab.id} />;
return (
<>
{virtual ? <VirtualBanner decompiled={virtual.decompiled} description={virtual.description} /> : <Banner path={tab.input.path} />}
{virtual ? (
<VirtualBanner decompiled={virtual.decompiled} description={virtual.description} />
) : (
<EditorBanners path={tab.input.path} />
)}
<FindBar paneId={paneId} />
{crumbs && <CodeBreadcrumbs path={tab.input.path!} tabId={tab.id} position="top" />}
{wrapper ? (
+186 -69
Переглянути файл
@@ -1,20 +1,26 @@
/**
* IDEA-style Find / Replace bar at the top of the editor (⌘F / ⌘R): search
* field with Match Case / Words / Regex toggles (⌥C / ⌥W / ⌥X), In Selection
* (on by default when Replace opens over a multi-line selection), n/N counter,
* ↑/↓ (⇧↵ / ↵), Replace / Replace All, Esc closes.
* field with Match Case / Words / Regex toggles (⌥C / ⌥W / ⌥X), New Line
* (⇧⌘↵, multi-line queries), recent searches (magnifier ▾ / ⌥↓), the filter
* menu (Search In Selection ⌥E — on by default when Replace opens over a
* multi-line selection — and the search scope: anywhere, in / except comments
* and string literals), n/N counter, ↑/↓ (⇧↵ / ↵), Replace / Replace All /
* Exclude with Preserve Case, Esc closes.
*/
import { useEffect, useLayoutEffect, useRef, type KeyboardEvent } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
import { create } from "zustand";
import clsx from "clsx";
import type { EditorView } from "@codemirror/view";
import { Button, IconButton, SearchField, T } from "@/ui";
import { Button, IconButton, Menu, SearchField, T, type MenuItem } from "@/ui";
import { SEARCH_CONTEXTS, rememberSearch, searchHistory } from "@/platform/search";
import { editorController, useFindBars, useTabs } from "../controller";
import {
countMatches,
excludeCurrent,
findNext,
findPrevious,
findScope,
isExcluded,
regexValid,
replaceAll,
replaceNext,
@@ -29,14 +35,23 @@ import styles from "./EditorArea.module.css";
import { hasPrimaryModifier } from "@/platform/keymap";
/** The query is shared by all panes and survives closing the bar, like IDEA. */
export const useFindOptions = create<FindOptions>(() => ({
export const useFindOptions = create<Required<FindOptions>>(() => ({
search: "",
caseSensitive: false,
wholeWord: false,
regexp: false,
replace: "",
context: "any",
preserveCase: false,
}));
const useFindCounts = create<Record<string, { total: number; current: number; capped: boolean } | null>>(() => ({}));
interface Counts {
total: number;
current: number;
capped: boolean;
/** The selection is an excluded match (Exclude reads "Include"). */
excluded: boolean;
}
const useFindCounts = create<Record<string, Counts | null>>(() => ({}));
/** Whether the bar of a pane is limited to a selection ("In Selection"). */
const useFindScoped = create<Record<string, boolean>>(() => ({}));
/** The selection the bar was opened over, for toggling "In Selection" on later. */
@@ -124,11 +139,18 @@ function recount(paneId: string) {
if (!view) return;
const opts = useFindOptions.getState();
const r = countMatches(view.state);
useFindCounts.setState({ [paneId]: opts.search ? r : null });
const { from, to } = view.state.selection.main;
useFindCounts.setState({ [paneId]: opts.search ? { ...r, excluded: isExcluded(view.state, from, to) } : null });
useFindScoped.setState({ [paneId]: !!view.state.field(findScope, false) });
view.dispatch({ effects: setFindMarks.of(opts.search ? r.starts : []) });
}
function rememberQuery(replace: boolean) {
const o = useFindOptions.getState();
rememberSearch("find", o.search);
if (replace) rememberSearch("replace", o.replace);
}
export function FindBar({ paneId }: { paneId: string }) {
const bar = useFindBars((s) => s[paneId]);
if (!bar) return null;
@@ -142,6 +164,11 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
const scoped = useFindScoped((s) => !!s[paneId]);
const inputRef = useRef<HTMLInputElement>(null);
const replaceRef = useRef<HTMLInputElement>(null);
const findBoxRef = useRef<HTMLDivElement>(null);
const replaceBoxRef = useRef<HTMLDivElement>(null);
const filterRef = useRef<HTMLButtonElement>(null);
const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null);
const [filterMenu, setFilterMenu] = useState(false);
const setOpts = (patch: Partial<FindOptions>) => useFindOptions.setState(patch);
// Opening (or ⌘F again): focus + select the field.
@@ -191,6 +218,7 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
);
const close = () => {
rememberQuery(replace);
closeFindBar(paneId);
activeView(paneId)?.focus();
};
@@ -199,21 +227,38 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
if (view) cmd(view);
recount(paneId);
};
const toggle = (key: "caseSensitive" | "wholeWord" | "regexp") =>
const doReplace = () => {
rememberQuery(true);
run((v) => replaceNext(v, opts.preserveCase));
};
const doReplaceAll = () => {
rememberQuery(true);
run((v) => replaceAll(v, opts.preserveCase));
};
const toggle = (key: "caseSensitive" | "wholeWord" | "regexp" | "preserveCase") =>
useFindOptions.setState((o) => ({ [key]: !o[key] }));
const onKeyDown = (e: KeyboardEvent, field: "find" | "replace") => {
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
// Inside a multi-line query the arrows move the caret between its lines.
const multiLine = target.value.includes("\n");
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
close();
} else if (e.key === "Enter" || e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "F3") {
} else if (e.key === "ArrowDown" && e.altKey && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setHistoryMenu(field);
} else if (e.key === "Enter" || e.key === "F3" || (!multiLine && (e.key === "ArrowDown" || e.key === "ArrowUp"))) {
e.preventDefault();
const back = e.key === "ArrowUp" || (e.shiftKey && e.key !== "ArrowDown");
if (field === "replace" && e.key === "Enter" && !e.shiftKey) {
if (hasPrimaryModifier(e)) run(replaceAll);
else run(replaceNext);
} else run(back ? findPrevious : findNext);
if (hasPrimaryModifier(e)) doReplaceAll();
else doReplace();
} else {
if (e.key === "Enter") rememberQuery(false);
run(back ? findPrevious : findNext);
}
} else if (e.altKey && !e.metaKey && !e.ctrlKey) {
const map: Record<string, "caseSensitive" | "wholeWord" | "regexp"> = {
KeyC: "caseSensitive",
@@ -227,6 +272,15 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
} else if (e.code === "KeyE") {
e.preventDefault();
toggleScope(paneId);
} else if (replace && e.code === "KeyP") {
e.preventDefault();
doReplace();
} else if (replace && e.code === "KeyA") {
e.preventDefault();
doReplaceAll();
} else if (replace && e.code === "KeyL") {
e.preventDefault();
run(excludeCurrent);
}
} else if (e.key === "Tab" && !e.shiftKey && field === "find" && replace) {
e.preventDefault();
@@ -244,52 +298,72 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
? "0 results"
: `${count.current || "?"}/${count.total}${count.capped ? "+" : ""}`;
const historyItems = (kind: "find" | "replace"): MenuItem[] => {
const h = searchHistory(kind);
return h.length
? [
{ kind: "header", label: kind === "find" ? "Recent Searches" : "Recent Replacements" },
...h.map((q) => ({
label: q.replace(/\n/g, "⏎"),
verbatim: true,
run: () => setOpts(kind === "find" ? { search: q } : { replace: q }),
})),
]
: [{ label: kind === "find" ? "No recent searches" : "No recent replacements", disabled: true }];
};
const filterItems: MenuItem[] = [
{ label: "Search In Selection", shortcut: "⌥E", checked: scoped || undefined, run: () => toggleScope(paneId) },
{ kind: "separator" },
{ kind: "header", label: "Search Scope" },
...SEARCH_CONTEXTS.map((c) => ({
label: c.label,
checked: c.value === opts.context ? true : undefined,
run: () => setOpts({ context: c.value }),
})),
];
const canReplace = !!opts.search && !invalid;
return (
<div className={styles.find} role="search" aria-label={replace ? "Replace in file" : "Find in file"}>
<SearchField
ref={inputRef}
className={styles.findField}
value={opts.search}
onChange={(search) => setOpts({ search })}
placeholder="Find"
aria-label="Find"
invalid={invalid || none}
onKeyDown={(e) => onKeyDown(e, "find")}
toggles={[
{
id: "case",
icon: "case",
title: "Match Case",
shortcut: "⌥C",
pressed: opts.caseSensitive,
onToggle: () => toggle("caseSensitive"),
},
{
id: "word",
icon: "word",
title: "Words",
shortcut: "⌥W",
pressed: opts.wholeWord,
onToggle: () => toggle("wholeWord"),
},
{
id: "regex",
icon: "regex",
title: "Regex",
shortcut: "⌥X",
pressed: opts.regexp,
onToggle: () => toggle("regexp"),
},
{
id: "scope",
icon: "filter",
title: "In Selection",
shortcut: "⌥E",
pressed: scoped,
onToggle: () => toggleScope(paneId),
},
]}
/>
<div ref={findBoxRef} className={styles.findField}>
<SearchField
ref={inputRef}
value={opts.search}
onChange={(search) => setOpts({ search })}
placeholder="Find"
aria-label="Find"
invalid={invalid || none}
multiline
onHistory={() => setHistoryMenu("find")}
onKeyDown={(e) => onKeyDown(e, "find")}
toggles={[
{
id: "case",
icon: "case",
title: "Match Case",
shortcut: "⌥C",
pressed: opts.caseSensitive,
onToggle: () => toggle("caseSensitive"),
},
{
id: "word",
icon: "word",
title: "Words",
shortcut: "⌥W",
pressed: opts.wholeWord,
onToggle: () => toggle("wholeWord"),
},
{
id: "regex",
icon: "regex",
title: "Regex",
shortcut: "⌥X",
pressed: opts.regexp,
onToggle: () => toggle("regexp"),
},
]}
/>
</div>
<div className={styles.findButtons}>
<span className={clsx(styles.findCount, (none || invalid) && styles.findCountNone)} aria-live="polite">
{counter}
@@ -302,6 +376,14 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
onClick={() => run(findPrevious)}
/>
<IconButton icon="arrow-down" title="Next Occurrence" shortcut="↵" size="small" onClick={() => run(findNext)} />
<IconButton
ref={filterRef}
icon="filter"
title="Filter Search Results"
size="small"
pressed={scoped || opts.context !== "any"}
onClick={() => setFilterMenu(true)}
/>
<IconButton
icon="replace"
title={replace ? "Hide Replace" : "Show Replace"}
@@ -313,26 +395,61 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
<IconButton icon="close" title="Close" shortcut="⎋" size="small" className={styles.findClose} onClick={close} />
{replace && (
<>
<SearchField
ref={replaceRef}
className={styles.findField}
value={opts.replace}
onChange={(value) => setOpts({ replace: value })}
placeholder="Replace"
aria-label="Replace"
leadingIcon="replace"
onKeyDown={(e) => onKeyDown(e, "replace")}
/>
<div ref={replaceBoxRef} className={styles.findField}>
<SearchField
ref={replaceRef}
value={opts.replace}
onChange={(value) => setOpts({ replace: value })}
placeholder="Replace"
aria-label="Replace"
multiline
onHistory={() => setHistoryMenu("replace")}
onKeyDown={(e) => onKeyDown(e, "replace")}
toggles={[
{
id: "preserveCase",
icon: "preserve-case",
title: "Preserve Case",
pressed: opts.preserveCase,
onToggle: () => toggle("preserveCase"),
},
]}
/>
</div>
<div className={styles.replaceButtons}>
<Button size="small" onClick={() => run(replaceNext)} disabled={!opts.search || invalid}>
<Button size="small" title="Replace (⌥P)" onClick={doReplace} disabled={!canReplace}>
<T>Replace</T>
</Button>
<Button size="small" onClick={() => run(replaceAll)} disabled={!opts.search || invalid}>
<Button size="small" title="Replace All (⌥A)" onClick={doReplaceAll} disabled={!canReplace}>
<T>Replace All</T>
</Button>
<Button
size="small"
title={count?.excluded ? "Include (⌥L)" : "Exclude (⌥L)"}
onClick={() => run(excludeCurrent)}
disabled={!canReplace || (!count?.total && !count?.excluded)}
>
{count?.excluded ? <T>Include</T> : <T>Exclude</T>}
</Button>
</div>
</>
)}
<Menu
open={historyMenu !== null}
anchor={historyMenu === "replace" ? replaceBoxRef : findBoxRef}
items={historyMenu ? historyItems(historyMenu) : []}
placement="bottom-start"
aria-label="Recent searches"
onClose={() => setHistoryMenu(null)}
/>
<Menu
open={filterMenu}
anchor={filterRef}
items={filterItems}
placement="bottom-end"
aria-label="Filter search results"
onClose={() => setFilterMenu(false)}
/>
</div>
);
}
+21
Переглянути файл
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { registerEditorBanner } from "@/workbench/contributions";
import { useDocMeta, type DocMeta } from "../model/document";
import { documentBanner, EditorBanners } from "./Banner";
describe("editor banners", () => {
it("shows the document notice through the banner extension point, before contributed ones", () => {
const off = [
registerEditorBanner(documentBanner),
registerEditorBanner({ id: "test.other", useBanner: () => ({ text: "Other notice" }) }),
];
act(() => useDocMeta.setState({ "/p/a.bin": { banner: "binary" } as DocMeta }));
render(<EditorBanners path="/p/a.bin" />);
const notices = screen.getAllByRole("status").map((n) => n.textContent);
expect(notices).toEqual(["This file appears to be binary and is shown read-only.", "Other notice"]);
act(() => useDocMeta.setState({ "/p/a.bin": { banner: null } as DocMeta }));
expect(screen.getAllByRole("status").map((n) => n.textContent)).toEqual(["Other notice"]);
off.forEach((f) => f());
});
});
+79 -8
Переглянути файл
@@ -15,11 +15,38 @@ import { workflowSettings, type ActionsOnSaveValid } from "@/platform/workflowSe
import { startRename } from "./rename";
import { formatDocument, reformatDialog } from "./format";
import { showHint } from "./hint";
import { refactorThis, runRefactoring, safeDelete } from "@/editor/refactor";
import { refactorThis, runRefactoring, safeDelete, showNothingHere } from "@/editor/refactor";
import { evaluateWhen } from "@/platform/context";
/** Editor actions apply to the active text editor unless a tool window or popup owns the keyboard. */
export const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
/**
* Languages whose servers offer Generate (constructors, accessors, overrides…).
* Elsewhere the Generate shortcut (⌘N / Alt+Insert) yields to the New popup, as in IDEA.
*/
const GENERATE_LANGUAGES = [
"java",
"kotlin",
"scala",
"groovy",
"rust",
"go",
"typescript",
"typescriptreact",
"javascript",
"javascriptreact",
"c",
"cpp",
"objective-c",
"objective-cpp",
"csharp",
"swift",
"dart",
"php",
];
export const GENERATE_KEY_WHEN = GENERATE_LANGUAGES.map((id) => `editorLangId == ${id}`).join(" || ");
function withEditor(run: (view: EditorView) => unknown): Action["run"] {
return () => {
const ed = activeEditor();
@@ -59,6 +86,19 @@ const optimizeImports = (v: EditorView) =>
label: "Optimize Imports",
});
interface CleanupOptions {
fixAll?: boolean;
optimizeImports?: boolean;
reformat?: boolean;
}
/** Code Cleanup of one document: the server's fix-all, organize imports, then reformat. */
async function cleanup(view: EditorView, o: CleanupOptions) {
if (o.fixAll !== false) await applyKindSilently(view, "source.fixAll");
if (o.optimizeImports !== false) await applyKindSilently(view, "source.organizeImports");
if (o.reformat !== false) await formatDocument(view, { explicit: false });
}
/** Code action titles of Implement Methods (⌃I) and Override Methods (⌃O). */
export const IMPLEMENT_TITLE = /\bimplement\b|\b(add|generate) (missing|unimplemented|all missing)\b|missing (members|methods|trait items)/i;
export const OVERRIDE_TITLE = /\boverride\b/i;
@@ -108,6 +148,9 @@ export function registerLspActions(): () => void {
keywords: ["javadoc", "docs", "hover"],
}),
editorAction("code.quickDefinition", "Quick Definition", "Code", quickDefinition),
editorAction("code.quickTypeDefinition", "Quick Type Definition", "View", (v) => quickDefinition(v, "typeDefinition"), {
keywords: ["type declaration", "preview"],
}),
editorAction("code.showIntentions", "Show Context Actions", "Code", showIntentions, {
keywords: ["intention", "quick fix", "lightbulb"],
}),
@@ -120,6 +163,18 @@ export function registerLspActions(): () => void {
editorAction("code.optimizeImports", "Optimize Imports", "Code", optimizeImports, { keywords: ["organize imports"] }),
// Project view / editor tab variants: `{ path }` of a file, opened first.
{ id: "code.reformatFile", title: "Reformat File", category: "Code", hidden: true, when: "projectOpen", run: inFile(reformat) },
{
id: "code.cleanupFile",
title: "Code Cleanup in File",
description: "Code Cleanup on `{ path, fixAll?, optimizeImports?, reformat? }` (all steps by default), opened first.",
category: "Code",
hidden: true,
when: "projectOpen",
run: (c) => {
const o = (c.arg ?? {}) as CleanupOptions;
return inFile((v) => cleanup(v, o))(c);
},
},
{
id: "code.optimizeImportsFile",
title: "Optimize Imports in File",
@@ -136,7 +191,18 @@ export function registerLspActions(): () => void {
editorAction("navigate.nextError", "Next Highlighted Error", "Navigate", (v) => gotoDiagnostic(v, 1)),
editorAction("navigate.prevError", "Previous Highlighted Error", "Navigate", (v) => gotoDiagnostic(v, -1)),
editorAction("find.highlightUsages", "Highlight Usages in File", "Find", highlightUsages),
editorAction("refactor.thisMenu", "Refactor This…", "Refactor", (v) => refactorThis(v, startRename)),
{
// Also outside an editor, where nothing applies: IDEA's "Nothing here" popup.
id: "refactor.thisMenu",
title: "Refactor This…",
category: "Refactor",
when: "projectOpen",
run: ({ ctx }) => {
const ed = activeEditor();
if (ed && evaluateWhen(EDITOR_WHEN, ctx)) return refactorThis(ed.view, startRename);
showNothingHere();
},
},
editorAction("refactor.rename", "Rename…", "Refactor", startRename),
editorAction("refactor.extractVariable", "Introduce Variable…", "Refactor", (v) => runRefactoring(v, "extractVariable")),
editorAction("refactor.extractMethod", "Extract Method…", "Refactor", (v) => runRefactoring(v, "extractMethod")),
@@ -146,12 +212,17 @@ export function registerLspActions(): () => void {
editorAction("refactor.move", "Move…", "Refactor", (v) => runRefactoring(v, "move")),
editorAction("refactor.changeSignature", "Change Signature…", "Refactor", (v) => runRefactoring(v, "changeSignature")),
editorAction("refactor.safeDelete", "Safe Delete…", "Refactor", safeDelete),
editorAction("code.generate", "Generate…", "Code", (v) =>
runKindAction(v, "", {
title: /^(generate|implement|override|add (missing|derive|impl|getter|setter|constructor)|create (constructor|getter|setter))/i,
none: "Nothing to generate here",
label: "Generate",
}),
editorAction(
"code.generate",
"Generate…",
"Code",
(v) =>
runKindAction(v, "", {
title: /^(generate|implement|override|add (missing|derive|impl|getter|setter|constructor)|create (constructor|getter|setter))/i,
none: "Nothing to generate here",
label: "Generate",
}),
{ keyWhen: GENERATE_KEY_WHEN },
),
editorAction("refactor.inline", "Inline…", "Refactor", (v) => runRefactoring(v, "inline")),
// Implement / Override Methods: the server's code actions for them (jdtls "Override/Implement
+41
Переглянути файл
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import type { BlameLine } from "@/platform/ipc";
import { authorLabel, authorSummary, authorTooltip } from "./authorLens";
const line = (author: string, timeMs: number | null, commit = "a".repeat(40)): BlameLine => ({
commit,
author,
timeMs,
summary: "",
});
const UNCOMMITTED = "0".repeat(40);
describe("code vision author", () => {
const blame = [
line("Ann", 100),
line("Thomas", 300),
line("Bob", 200),
line("Ann", 50),
line("Not Committed Yet", null, UNCOMMITTED),
line("Carol", 900),
];
it("names the latest author of the range and counts the others", () => {
const s = authorSummary(blame, 0, 4)!;
expect(s.authors).toEqual(["Thomas", "Bob", "Ann"]);
expect(authorLabel(s)).toBe("Thomas +2");
expect(authorTooltip(s)).toBe("Last changed by Thomas; also Bob, Ann");
});
it("shows a single author without a count", () => {
const s = authorSummary(blame, 3, 3)!;
expect(authorLabel(s)).toBe("Ann");
expect(authorTooltip(s)).toBe("Last changed by Ann");
});
it("ignores uncommitted lines and lines past the blame", () => {
expect(authorSummary(blame, 4, 4)).toBeNull();
expect(authorLabel(authorSummary(blame, 4, 99)!)).toBe("Carol");
expect(authorSummary(blame, 10, 12)).toBeNull();
});
});
+86
Переглянути файл
@@ -0,0 +1,86 @@
/**
* Code vision "author" hint beside the usages lens (IDEA's "Thomas +2"): the
* author of the latest commit touching a declaration's lines, plus how many
* other people changed them. Blame comes from `vcs_blame`, cached per file
* and document text; a VCS change (commit, checkout, …) drops the cache.
*/
import type { Text } from "@codemirror/state";
import { call, commands, isTauri, onIdeEvent, type BlameLine } from "@/platform/ipc";
export interface AuthorSummary {
author: string;
/** Other distinct authors of the range. */
others: number;
/** Every distinct author, latest change first. */
authors: string[];
}
const isUncommitted = (l: BlameLine) => /^0+$/.test(l.commit);
/** Authors of 0-based lines `from..=to`; null when none of them is committed. */
export function authorSummary(blame: readonly BlameLine[], from: number, to: number): AuthorSummary | null {
const latest = new Map<string, number>();
for (let i = Math.max(0, from); i <= to && i < blame.length; i++) {
const l = blame[i]!;
if (isUncommitted(l) || !l.author) continue;
const t = l.timeMs ?? 0;
if ((latest.get(l.author) ?? -Infinity) < t) latest.set(l.author, t);
}
if (!latest.size) return null;
const authors = [...latest.entries()].sort((a, b) => b[1] - a[1]).map(([a]) => a);
return { author: authors[0]!, others: authors.length - 1, authors };
}
export function authorLabel(s: AuthorSummary): string {
return s.others ? `${s.author} +${s.others}` : s.author;
}
export function authorTooltip(s: AuthorSummary): string {
return s.others ? `Last changed by ${s.author}; also ${s.authors.slice(1).join(", ")}` : `Last changed by ${s.author}`;
}
interface Entry {
text: Text | null;
lines: Promise<BlameLine[] | null>;
}
const cache = new Map<string, Entry>();
const listeners = new Set<() => void>();
let offVcs: (() => void) | null = null;
/** Drops cached blame and tells editors to fetch it again (VCS changes). */
export function clearBlameCache() {
cache.clear();
listeners.forEach((l) => l());
}
function watchVcs() {
if (!offVcs && isTauri()) offVcs = onIdeEvent("vcsChanged", clearBlameCache);
}
/** Called after a VCS change made cached blame stale; returns the unsubscribe function. */
export function onBlameInvalidated(listener: () => void): () => void {
watchVcs();
listeners.add(listener);
return () => listeners.delete(listener);
}
/**
* Blame of `path` for the document `text`; null when the file isn't under
* version control (remembered until the next VCS change).
*/
export function blameFor(path: string, text: Text): Promise<BlameLine[] | null> {
watchVcs();
const hit = cache.get(path);
if (hit && (hit.text === text || hit.text === null)) return hit.lines;
const entry: Entry = { text, lines: Promise.resolve(null) };
entry.lines = call(commands.vcsBlame(path)).then(
(lines) => lines,
() => {
entry.text = null;
return null;
},
);
cache.set(path, entry);
return entry.lines;
}
+34 -10
Переглянути файл
@@ -2,7 +2,8 @@
* "N usages" code lenses above declarations (IDEA's usages inlay). Cheap by
* construction: only declarations in (and near) the viewport are counted, one
* debounced pass after edits / scrolling, results cached per document text
* and symbol, and at most a few reference requests in flight.
* and symbol, and at most a few reference requests in flight. Beside each
* count, the declaration's last author from VCS blame (`authorLens`).
*/
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
@@ -14,6 +15,7 @@ import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
import { posToOffset } from "./positions";
import { onLspRefresh, serverBackoff } from "./availability";
import { editorPrefs } from "@/platform/editorSettings";
import { authorLabel, authorSummary, authorTooltip, blameFor, onBlameInvalidated } from "./authorLens";
const LENS_KINDS = new Set<SymbolKind>([
"class",
@@ -38,6 +40,9 @@ export interface LensTarget {
/** 0-based line / UTF-16 col of the identifier. */
line: number;
col: number;
/** 0-based lines of the whole declaration. */
startLine: number;
endLine: number;
}
/** Servers name callbacks and anonymous functions (`<function>`, `map() callback`); they have no usages. */
@@ -48,7 +53,13 @@ export function lensTargets(items: readonly OutlineItem[]): LensTarget[] {
const out: LensTarget[] = [];
const walk = (list: readonly OutlineItem[]) => {
for (const it of list) {
if (LENS_KINDS.has(it.kind) && !anonymous(it.name)) out.push({ name: it.name, line: it.selection_range.start.line, col: it.selection_range.start.col });
if (LENS_KINDS.has(it.kind) && !anonymous(it.name)) out.push({
name: it.name,
line: it.selection_range.start.line,
col: it.selection_range.start.col,
startLine: it.range.start.line,
endLine: it.range.end.line,
});
if (it.children.length) walk(it.children);
}
};
@@ -82,9 +93,8 @@ const lensField = StateField.define<CodeLens[]>({
provide: (f) => codeLenses.from(f),
});
const enabled = () => {
return editorPrefs().inlay.codeVisionUsages;
};
const usagesEnabled = () => editorPrefs().inlay.codeVisionUsages;
const authorEnabled = () => editorPrefs().inlay.codeVisionAuthor;
function openUsages(view: EditorView, offset: number) {
view.dispatch({ selection: EditorSelection.cursor(offset), userEvent: "select.lens" });
@@ -101,6 +111,7 @@ class UsagesLensPlugin {
private generation = 0;
private unsubscribe: () => void;
private offReady: () => void;
private offBlame: () => void;
constructor(private view: EditorView) {
this.refresh();
@@ -112,6 +123,7 @@ class UsagesLensPlugin {
this.cache.clear();
this.refresh();
});
this.offBlame = onBlameInvalidated(() => this.refresh());
}
update(u: ViewUpdate) {
@@ -124,11 +136,15 @@ class UsagesLensPlugin {
const doc = docOf(view);
if (!doc) return;
const gen = ++this.generation;
if (!enabled() || serverBackoff.active(doc.doc)) {
const usages = usagesEnabled() && !serverBackoff.active(doc.doc);
const authors = authorEnabled() && !doc.virtual;
if (!usages && !authors) {
if (view.state.field(lensField).length) view.dispatch({ effects: setLenses.of([]) });
return;
}
const text = view.state.doc;
// Blame runs beside the reference requests; it never holds up the counts.
const blame = authors ? blameFor(doc.path, text) : Promise.resolve(null);
if (this.cacheDoc !== text) {
this.cache = new Map();
this.cacheDoc = text;
@@ -144,7 +160,7 @@ class UsagesLensPlugin {
const lastLine = text.lineAt(to).number - 1 + MARGIN_LINES;
const visible = this.outline.targets.filter((t) => t.line >= firstLine && t.line <= lastLine).slice(0, MAX_PER_PASS);
const key = (t: LensTarget) => `${t.line}:${t.col}:${t.name}`;
const todo = visible.filter((t) => !this.cache.has(key(t)));
const todo = usages ? visible.filter((t) => !this.cache.has(key(t))) : [];
let unavailable = false;
for (let i = 0; i < todo.length && !unavailable; i += CONCURRENCY) {
await Promise.all(
@@ -160,11 +176,15 @@ class UsagesLensPlugin {
if (gen !== this.generation || view.state.doc !== text) return;
}
if (unavailable) serverBackoff.mark(doc.doc);
const lines = await blame;
if (gen !== this.generation || view.state.doc !== text) return;
const lenses: CodeLens[] = [];
for (const t of visible) {
const n = this.cache.get(key(t));
if (n === undefined) continue;
const offset = posToOffset(text, { line: t.line, col: t.col });
const who = lines && authorSummary(lines, t.startLine, t.endLine);
if (who) lenses.push({ pos: offset, title: authorLabel(who), tooltip: authorTooltip(who), icon: "user", order: 20 });
const n = usages ? this.cache.get(key(t)) : undefined;
if (n === undefined) continue;
lenses.push({
pos: offset,
title: usagesLabel(n),
@@ -183,12 +203,16 @@ class UsagesLensPlugin {
this.refresh.cancel();
this.unsubscribe();
this.offReady();
this.offBlame();
this.generation++;
}
}
function sameLenses(a: readonly CodeLens[], b: readonly CodeLens[]) {
return a.length === b.length && a.every((l, i) => l.pos === b[i]!.pos && l.title === b[i]!.title);
return (
a.length === b.length &&
a.every((l, i) => l.pos === b[i]!.pos && l.title === b[i]!.title && l.tooltip === b[i]!.tooltip)
);
}
export function usagesLensExtension(): Extension {
+71 -1
Переглянути файл
@@ -3,8 +3,19 @@ import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { CompletionItem } from "@/bindings";
import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems";
import { applyLspCompletion, commitsLookup, completionExtension, explicitCompletion, makeCompletion } from "./completion";
import {
applyLspCompletion,
callParens,
commitsLookup,
completionExtension,
explicitCompletion,
makeCompletion,
matchRanges,
sortAlphabetically,
} from "./completion";
import type { CompletionSource } from "@codemirror/autocomplete";
import { useProject } from "@/platform/project";
import type { Settings } from "@/platform/ipc";
function item(over: Partial<CompletionItem> = {}): CompletionItem {
return {
@@ -121,6 +132,7 @@ describe("applying completions", () => {
const { view, req } = setup("a.cr|Order.x");
const c = makeCompletion(
item({
kind: "field",
insertText: "create",
replaceRange: { start: { line: 0, col: 2 }, end: { line: 0, col: 4 } },
replaceRangeTab: { start: { line: 0, col: 2 }, end: { line: 0, col: 9 } },
@@ -145,6 +157,7 @@ describe("applying completions", () => {
const { view, req } = setup("\nfoo.ba|");
const c = makeCompletion(
item({
kind: "field",
insertText: "bar",
additionalEdits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "import bar;" }],
}),
@@ -154,6 +167,20 @@ describe("applying completions", () => {
expect(view.state.doc.toString()).toBe("import bar;\nfoo.bar");
});
it("adds parentheses to a called function and opens its parameters", () => {
const { view, req } = setup("svc.cr|");
apply(view, makeCompletion(item({ insertText: "create", labelDetail: "(req: Req)" }), req), 4);
expect(view.state.doc.toString()).toBe("svc.create()");
expect(view.state.selection.main.head).toBe(11);
});
it("places the caret after the parentheses of a parameterless call", () => {
const { view, req } = setup("svc.cl|");
apply(view, makeCompletion(item({ insertText: "close", labelDetail: "()" }), req), 4);
expect(view.state.doc.toString()).toBe("svc.close()");
expect(view.state.selection.main.head).toBe(11);
});
it("expands snippets with fields", () => {
const { view, req } = setup("pri|");
apply(view, makeCompletion(item({ insertText: "println!(${1:arg})$0", isSnippet: true }), req), 0);
@@ -163,6 +190,39 @@ describe("applying completions", () => {
});
});
describe("insert parentheses", () => {
const line = (text: string) => ({ from: 0, text });
it("applies to plain callables only", () => {
expect(callParens(item(), line("a.cr"), 2, 4)).toBe("inside");
expect(callParens(item({ kind: "field" }), line("a.cr"), 2, 4)).toBeNull();
expect(callParens(item({ isSnippet: true }), line("a.cr"), 2, 4)).toBeNull();
expect(callParens(item({ insertText: "create()" }), line("a.cr"), 2, 4)).toBeNull();
});
it("skips calls that already have parentheses and imports", () => {
expect(callParens(item(), line("a.cr(x)"), 2, 4)).toBeNull();
expect(callParens(item(), line("import { cr"), 9, 11)).toBeNull();
expect(callParens(item(), line("use crate::cr"), 11, 13)).toBeNull();
});
});
describe("sort suggestions alphabetically", () => {
const result = (labels: string[]) => ({ from: 0, options: labels.map((label) => ({ label })), validFor: /^\w*$/ });
it("filters and orders by name", () => {
const sorted = sortAlphabetically(result(["zeta", "Alpha", "beta", "gamma", "alphabet"]), "a");
expect(sorted.filter).toBe(false);
expect(sorted.options.map((o) => o.label)).toEqual(["Alpha", "alphabet", "beta", "gamma", "zeta"]);
expect(sortAlphabetically(result(["zeta", "Alpha", "beta"]), "al").options.map((o) => o.label)).toEqual(["Alpha"]);
});
it("matches substrings, then subsequences", () => {
expect(matchRanges("getValue", "val")).toEqual([3, 6]);
expect(matchRanges("getValue", "gv")).toEqual([0, 1, 3, 4]);
expect(matchRanges("getValue", "x")).toBeNull();
});
});
describe("lookup commit characters", () => {
it("finishes only focused lookups unless the setting asks for it (IDEA)", () => {
expect(commitsLookup(".", true, false)).toBe(true);
@@ -206,6 +266,16 @@ describe("explicit completion", () => {
view.destroy();
});
it("keeps a sole candidate in the list when auto-insert is off", async () => {
useProject.setState({ settings: { editor: { completion: { autoInsertSingle: false } } } as unknown as Settings });
const view = editor(["println"]);
explicitCompletion(view);
await new Promise((r) => setTimeout(r, 300));
expect(view.state.doc.toString()).toBe("pri");
view.destroy();
useProject.setState({ settings: null });
});
it("shows the list when there are several candidates", async () => {
const view = editor(["println", "print"]);
explicitCompletion(view);
+123 -10
Переглянути файл
@@ -9,6 +9,7 @@
import {
acceptCompletion,
autocompletion,
closeCompletion,
completeFromList,
completionStatus,
currentCompletions,
@@ -24,7 +25,8 @@ import {
} from "@codemirror/autocomplete";
import { EditorSelection, Prec, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view";
import { commands, isTauri, nextRequestId, type CompletionItem, type Markup, type RequestId } from "@/platform/ipc";
import { call, commands, isTauri, nextRequestId, type CompletionItem, type Markup, type RequestId } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems";
import { lspSnippetToCm, snippetVariables } from "./snippet";
import { docOf, isUnavailable, lspDoc, lspRequest, reportLspError } from "./lsp";
@@ -35,6 +37,7 @@ import { triggerSignatureHelp } from "./signature";
import { serverBackoff } from "./availability";
import { pathCompletionSource } from "./fileReferences";
import { editorPrefs } from "@/platform/editorSettings";
import { tx } from "@/platform/i18n";
import { showHint } from "./hint";
import { inplaceRenameActive } from "./inplaceRename";
@@ -177,6 +180,7 @@ export function applyLspCompletion(
.map((c) => ({ from: mapOld(c.from), to: mapOld(c.to), insert: c.insert }))
.filter((c) => c.to <= start || c.from >= end);
let insertedParens: ReturnType<typeof callParens> = null;
if (item.isSnippet) {
if (extra.length) {
const tr = state.update({ changes: extra, userEvent: "input.complete" });
@@ -186,10 +190,13 @@ export function applyLspCompletion(
}
snippet(lspSnippetToCm(item.insertText, snippetVariables(req.path)))(view, completion, start, end);
} else {
const changes = state.changes([...extra, { from: start, to: end, insert: item.insertText }]);
const parens = editorPrefs().completion.insertParentheses ? callParens(item, state.doc.lineAt(start), start, end) : null;
insertedParens = parens;
const changes = state.changes([...extra, { from: start, to: end, insert: item.insertText + (parens ? "()" : "") }]);
const caret = changes.mapPos(end, 1) - (parens === "inside" ? 1 : 0);
view.dispatch({
changes,
selection: EditorSelection.cursor(changes.mapPos(end, 1)),
selection: EditorSelection.cursor(caret),
userEvent: "input.complete",
annotations: pickedCompletion.of(completion),
scrollIntoView: true,
@@ -197,9 +204,34 @@ export function applyLspCompletion(
}
if (!completion.resolved && !item.additionalEdits.length && item.data != null) void applyLateEdits(view, completion, start);
if (insertedParens === "inside" && !item.command) setTimeout(() => triggerSignatureHelp(view), 0);
runCommand(view, item);
}
const CALLABLE = new Set(["function", "method", "constructor"]);
/** Lines where a callable is named, not called (imports, `use` paths). */
const IMPORT_LINE = /^\s*(import|from|use|pub\s+use|export\s+\{|#include|require)\b/;
/**
* Code Completion › Insert parentheses automatically: `()` after a function or
* method name inserted as plain text (snippets bring their own), with the
* caret `inside` unless the signature shows no parameters. Null when the
* name is followed by `(` or an identifier, or names an import.
*/
export function callParens(
item: Pick<CompletionItem, "kind" | "insertText" | "isSnippet" | "labelDetail" | "detail">,
line: { from: number; text: string },
start: number,
end: number,
): "inside" | "after" | null {
if (item.isSnippet || !item.kind || !CALLABLE.has(item.kind) || /[(<[{]/.test(item.insertText)) return null;
const next = line.text.charAt(end - line.from);
if (next && /[(\p{L}\p{N}_$]/u.test(next)) return null;
if (IMPORT_LINE.test(line.text.slice(0, start - line.from))) return null;
const sig = item.labelDetail ?? item.detail ?? "";
return /^\s*\(\s*\)/.test(sig) || /^\s*fn\s*\(\s*\)|^\s*\(\)\s*(=>|->|:)/.test(sig) ? "after" : "inside";
}
/**
* Some servers (tsserver auto-imports) only compute additional edits on
* resolve. Apply those that lie before the insertion once they arrive, if the
@@ -338,6 +370,57 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
};
}
/**
* Code Completion › Sort suggestions alphabetically: the source's options are
* filtered here (prefix, substring, then subsequence) and listed by name;
* typing on re-filters the same options while the source's `validFor` holds.
*/
export function sortAlphabetically(result: CompletionResult, typed: string): CompletionResult {
if (result.filter === false) return result;
const matched = new Map<Completion, readonly number[]>();
for (const o of result.options) {
const m = matchRanges(o.label, typed);
if (m) matched.set(o, o.displayLabel ? [] : m);
}
const name = (c: Completion) => c.displayLabel ?? c.label;
const options = [...matched.keys()].sort(
(a, b) => name(a).localeCompare(name(b), undefined, { sensitivity: "base" }) || name(a).localeCompare(name(b)),
);
const { validFor } = result;
return {
from: result.from,
to: result.to,
options,
filter: false,
getMatch: (c) => matched.get(c) ?? [],
update: validFor
? (_cur, from, to, ctx) => {
const text = ctx.state.sliceDoc(from, to);
const ok = typeof validFor === "function" ? validFor(text, from, to, ctx.state) : new RegExp(`^(?:${validFor.source})$`, validFor.flags).test(text);
return ok ? sortAlphabetically({ ...result, from }, text) : null;
}
: undefined,
};
}
/** Matched ranges of `typed` in `label`, ignoring case: a substring, else a subsequence; null without a match. */
export function matchRanges(label: string, typed: string): number[] | null {
if (!typed) return [];
const lower = label.toLowerCase();
const p = typed.toLowerCase();
const at = lower.indexOf(p);
if (at >= 0) return [at, at + p.length];
const ranges: number[] = [];
let i = 0;
for (let j = 0; j < lower.length && i < p.length; j++) {
if (lower[j] !== p[i]) continue;
if (ranges.length && ranges[ranges.length - 1] === j) ranges[ranges.length - 1] = j + 1;
else ranges.push(j, j + 1);
i++;
}
return i === p.length ? ranges : null;
}
/** Code Completion › Match case: first letter only, or every typed letter (as a subsequence). */
export function matchesCase(label: string, typed: string, mode: string): boolean {
if (!typed) return true;
@@ -370,7 +453,9 @@ function gated(source: CompletionSource): CompletionSource {
return (ctx) => {
const only = exclusiveSource(ctx);
if (only) return source === contributedSource ? only(ctx) : null;
return !ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state)) ? null : source(ctx);
if (!ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state))) return null;
if (!editorPrefs().completion.sortAlphabetically) return source(ctx);
return Promise.resolve(source(ctx)).then((r) => r && sortAlphabetically(r, ctx.state.sliceDoc(r.from, r.to ?? ctx.pos)));
};
}
@@ -412,9 +497,9 @@ const lookupKeys = Prec.highest(
const itemChars = isLsp(selected) ? selected.lsp.commitCharacters : (selected.commitCharacters ?? []);
if (!commitsLookup(e.key, focusedLookups.has(view), editorPrefs().completion.insertOnSpecialKeys, itemChars)) return false;
if (!acceptCompletion(view)) return false;
// A call snippet already opened the parenthesis: the typed one is consumed.
// A call snippet or inserted parentheses already opened the call: the typed one is consumed.
const head = view.state.selection.main.head;
if (e.key === "(" && view.state.sliceDoc(head - 1, head) === "(") {
if (e.key === "(" && (view.state.sliceDoc(head - 1, head) === "(" || view.state.sliceDoc(head - 2, head) === "()")) {
e.preventDefault();
return true;
}
@@ -423,7 +508,7 @@ const lookupKeys = Prec.highest(
}),
);
/** IDEA inserts a sole candidate right away on explicit invocation. */
/** IDEA inserts a sole candidate right away on explicit invocation (Code Completion › Automatically insert single suggestions). */
const AUTO_INSERT_DELAY = 80; // past CodeMirror's interactionDelay
const lookupTracker = ViewPlugin.fromClass(
@@ -446,7 +531,7 @@ const lookupTracker = ViewPlugin.fromClass(
} else if (status === "active" && explicitPending.has(view)) {
explicitPending.delete(view);
const options = currentCompletions(u.state);
if (options.length === 1) {
if (options.length === 1 && editorPrefs().completion.autoInsertSingle) {
const doc = u.state.doc;
setTimeout(() => {
const now = currentCompletions(view.state);
@@ -494,7 +579,33 @@ function typeColumn(c: Completion): Node | null {
return span;
}
/** IDEA's advertiser bar under the list (one tip: "Press ↵ to insert, ⇥ to replace"). */
/** Toggles Code Completion › Sort suggestions alphabetically (globally) and re-opens the lookup. */
async function toggleSortByName(view: EditorView) {
const on = !editorPrefs().completion.sortAlphabetically;
if (isTauri()) {
const settings = await call(commands.settingsSet("global", "editor.completion.sortAlphabetically", JSON.stringify(on)));
useProject.setState({ settings });
}
if (!view.dom.isConnected) return;
closeCompletion(view);
startCompletion(view);
}
function sortToggle(view: EditorView): HTMLElement {
const button = document.createElement("button");
button.type = "button";
button.className = "cm-lsp-completion-sort";
button.textContent = tx("Sort by name");
const on = editorPrefs().completion.sortAlphabetically;
button.setAttribute("aria-pressed", String(on));
button.title = tx(on ? "Sort suggestions by relevance" : "Sort suggestions alphabetically");
// Keep the editor focused so the lookup survives the click.
button.addEventListener("mousedown", (e) => e.preventDefault());
button.addEventListener("click", () => void toggleSortByName(view).catch(() => {}));
return button;
}
/** IDEA's advertiser bar under the list (one tip: "Press ↵ to insert, ⇥ to replace") and its Sort by Name toggle. */
const completionFooter = ViewPlugin.fromClass(
class {
constructor(private view: EditorView) {}
@@ -512,7 +623,9 @@ const completionFooter = ViewPlugin.fromClass(
if (t.querySelector(":scope > .cm-lsp-completion-footer")) continue;
const footer = document.createElement("div");
footer.className = "cm-lsp-completion-footer";
footer.textContent = "Press ↵ to insert, ⇥ to replace";
const tip = document.createElement("span");
tip.textContent = tx("Press ↵ to insert, ⇥ to replace");
footer.append(tip, sortToggle(this.view));
t.appendChild(footer);
}
},
+32 -5
Переглянути файл
@@ -15,6 +15,13 @@ import { rangeToOffsets } from "./positions";
import { lspDoc } from "./lsp";
import { showHint } from "./hint";
import { editorPrefs } from "@/platform/editorSettings";
import {
applyProfile,
inspectionProfile,
isTypoStyled,
noteDiagnosticCodes,
subscribeInspectionProfile,
} from "@/platform/inspections";
type Listener = () => void;
@@ -29,13 +36,17 @@ export function setFileDiagnostics(path: string, source: string, items: Diagnost
} else {
if (!sources) byPath.set(path, (sources = new Map()));
sources.set(source, items.map((d) => ({ ...d, source: d.source ?? source })));
noteDiagnosticCodes(source, items);
}
listeners.get(path)?.forEach((l) => l());
}
/** Diagnostics of `path` under the inspection profile in effect. */
export function diagnosticsFor(path: string): Diagnostic[] {
const sources = byPath.get(path);
return sources ? [...sources.values()].flat() : [];
if (!sources) return [];
const profile = inspectionProfile();
return [...sources].flatMap(([source, items]) => applyProfile(profile, source, items));
}
export function subscribeDiagnostics(path: string, l: Listener): () => void {
@@ -49,7 +60,13 @@ export function subscribeDiagnostics(path: string, l: Listener): () => void {
}
export function installDiagnosticsEvents(): () => void {
return onIdeEvent("diagnostics", (e) => setFileDiagnostics(e.path, e.source, e.items));
const off = onIdeEvent("diagnostics", (e) => setFileDiagnostics(e.path, e.source, e.items));
// Another inspection profile (or edits to it) re-filters every open file.
const offProfile = subscribeInspectionProfile(() => listeners.forEach((set) => set.forEach((l) => l())));
return () => {
off();
offProfile();
};
}
const SEVERITY_RANK: Record<Severity, number> = { error: 0, warning: 1, info: 2, hint: 3 };
@@ -63,7 +80,11 @@ export function toLintDiagnostics(doc: Text, items: readonly Diagnostic[]): Lint
if (to < line.to) to++;
else if (from > line.from) from--;
}
const classes = [d.unnecessary && "cm-lsp-unnecessary", d.deprecated && "cm-lsp-deprecated"].filter(Boolean);
const classes = [
d.unnecessary && "cm-lsp-unnecessary",
d.deprecated && "cm-lsp-deprecated",
isTypoStyled(d) && "cm-lsp-typo",
].filter(Boolean);
return {
from,
to,
@@ -87,7 +108,7 @@ export function diagnosticMarks(diags: readonly LintDiagnostic[]): ScrollbarMark
const out: ScrollbarMark[] = [];
for (const d of diags) {
const color = MARK_COLOR[d.severity];
if (!color || d.markClass?.includes("cm-lsp-unnecessary")) continue;
if (!color || d.markClass?.includes("cm-lsp-unnecessary") || d.markClass?.includes("cm-lsp-typo")) continue;
out.push({ from: d.from, to: d.to, color, tooltip: d.message, priority: 3 - SEVERITY_RANK[d.severity] });
}
return out;
@@ -136,7 +157,7 @@ const diagnosticsSync = ViewPlugin.fromClass(
);
// Effects follow the IDEA schemes: errors wavy red; warnings wavy yellow (dark) or
// a yellow background (light, via --editor-warning-*); weak warnings dotted.
// a yellow background (light, via --editor-warning-*); weak warnings dotted; typos wavy green.
const lintTheme = EditorView.theme({
".cm-lintRange": { backgroundImage: "none", paddingBottom: "0" },
".cm-lintRange-error": {
@@ -156,6 +177,12 @@ const lintTheme = EditorView.theme({
textUnderlineOffset: "3px",
},
".cm-lintRange-hint": { textDecoration: "none" },
// Typo level: IDEA's green wavy underline.
".cm-lintRange.cm-lsp-typo": {
textDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)",
textDecorationSkipInk: "none",
textUnderlineOffset: "3px",
},
".cm-lintRange.cm-lsp-unnecessary": {
textDecoration: "none",
backgroundColor: "transparent",
+71
Переглянути файл
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { Text } from "@codemirror/state";
import {
formatExcluded,
guardFormatChanges,
guardTextEdits,
protectedRegions,
type FormatGuardOptions,
} from "@/platform/formatGuard";
import { formattedLines } from "./format";
describe("formattedLines", () => {
@@ -26,3 +33,67 @@ describe("formattedLines", () => {
).toBe(3);
});
});
describe("formatter markers and Do not format", () => {
const on: FormatGuardOptions = {
doNotFormat: [],
formatterTagsEnabled: true,
formatterOffTag: "@formatter:off",
formatterOnTag: "@formatter:on",
formatterTagsRegexp: false,
};
const lines = ["a = 1", "// @formatter:off", "b = 2", "// @formatter:on", "c = 3"];
const text = lines.join("\n");
const at = (line: number, col = 0) => lines.slice(0, line).join("\n").length + (line ? 1 : 0) + col;
const apply = (t: string, cs: readonly { from: number; to: number; insert: string }[]) =>
[...cs].sort((x, y) => y.from - x.from).reduce((s, c) => s.slice(0, c.from) + c.insert + s.slice(c.to), t);
const formatted = ["a = 1", "// @formatter:off", "b = 2", "// @formatter:on", "c = 3"].join("\n");
const expected = ["a = 1", "// @formatter:off", "b = 2", "// @formatter:on", "c = 3"].join("\n");
it("finds the regions between markers in comments", () => {
expect(protectedRegions(text, on)).toEqual([{ from: at(1), to: at(3, "// @formatter:on".length) }]);
expect(protectedRegions(text, { ...on, formatterTagsEnabled: false })).toEqual([]);
expect(protectedRegions('s = "@formatter:off"\nx', on)).toEqual([]);
// Without an on marker the region runs to the end.
expect(protectedRegions("x\n# @formatter:off\ny", on)).toEqual([{ from: 2, to: 20 }]);
const regexp = { ...on, formatterOffTag: "fmt:\\s*off", formatterOnTag: "fmt:\\s*on", formatterTagsRegexp: true };
expect(protectedRegions("// fmt: off\nx\n// fmt:on", regexp)).toEqual([{ from: 0, to: 23 }]);
});
it("drops edits inside a region and keeps the others", () => {
const changes = [
{ from: at(0, 1), to: at(0, 3), insert: " " },
{ from: at(2, 1), to: at(2, 3), insert: " " },
{ from: at(4, 1), to: at(4, 3), insert: " " },
];
expect(apply(text, guardFormatChanges(text, changes, on))).toBe(
["a = 1", "// @formatter:off", "b = 2", "// @formatter:on", "c = 3"].join("\n"),
);
expect(guardFormatChanges(text, changes, { ...on, formatterTagsEnabled: false })).toEqual(changes);
});
it("splits a whole-file replacement around the regions", () => {
expect(apply(text, guardFormatChanges(text, [{ from: 0, to: text.length, insert: formatted }], on))).toBe(expected);
const edits = guardTextEdits(
text,
[{ range: { start: { line: 0, col: 0 }, end: { line: 4, col: 7 } }, new_text: formatted }],
on,
);
const asChanges = edits.map((e) => ({
from: at(e.range.start.line, e.range.start.col),
to: at(e.range.end.line, e.range.end.col),
insert: e.new_text,
}));
expect(apply(text, asChanges)).toBe(expected);
});
it("matches Do not format globs against names and project paths", () => {
const globs = ["*.min.js", "generated", "src/vendor/**", "docs/"];
expect(formatExcluded("/p/web/app.min.js", "/p", globs)).toBe(true);
expect(formatExcluded("/p/a/generated/x.ts", "/p", globs)).toBe(true);
expect(formatExcluded("/p/src/vendor/lib/x.ts", "/p", globs)).toBe(true);
expect(formatExcluded("/p/docs/a.md", "/p", globs)).toBe(true);
expect(formatExcluded("/p/src/app.js", "/p", globs)).toBe(false);
expect(formatExcluded("/p/other/docs.md", "/p", globs)).toBe(false);
});
});
+22 -7
Переглянути файл
@@ -12,9 +12,12 @@ import type { EditorView } from "@codemirror/view";
import { commands } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { executeAction } from "@/platform/actions";
import { formatExcluded, formatGuardOptions, guardFormatChanges, type FormatGuardOptions } from "@/platform/formatGuard";
import { useProject } from "@/platform/project";
import { CONTRIBUTED_FORMAT, contributedAt, whenSynced, type ContributedFormatter } from "@/editor/api";
import { docOf, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, textEditsToChanges } from "./positions";
import { tx, txTemplate } from "@/platform/i18n";
import { showHint } from "./hint";
import { openPopup, type ReformatOptions } from "./popupStore";
@@ -46,15 +49,15 @@ function formattedHint(view: EditorView, lines: number) {
const keys = shortcutLabel("code.reformatDialog");
showHint(view, () => {
const box = document.createElement("div");
box.textContent = `Formatted ${lines} ${lines === 1 ? "line" : "lines"}`;
box.textContent = txTemplate(`Formatted ${lines} ${lines === 1 ? "line" : "lines"}`);
const more = document.createElement("div");
const link = document.createElement("button");
link.type = "button";
link.className = "cm-lsp-link-button";
link.textContent = "Show";
link.textContent = tx("Show");
link.addEventListener("mousedown", (e) => e.preventDefault());
link.addEventListener("click", () => void executeAction("code.reformatDialog"));
more.append(link, ` the Reformat dialog${keys ? `: ${keys}` : ""}`);
more.append(link, `${tx(" the Reformat dialog")}${keys ? `: ${keys}` : ""}`);
box.appendChild(more);
return box;
});
@@ -70,8 +73,15 @@ export async function formatDocument(
}
async function format(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean }): Promise<FormatResult> {
const { project, settings } = useProject.getState();
const guard = formatGuardOptions(settings);
const path = docOf(view)?.path;
if (path && formatExcluded(path, project?.root ?? null, guard.doNotFormat)) {
if (opts.explicit) showHint(view, "Formatting is turned off for this file (Code Style › Formatter › Do not format)");
return NOTHING;
}
const own = contributedAt<ContributedFormatter>(view.state, CONTRIBUTED_FORMAT, 0);
if (own) return applyContributedFormat(view, own, opts);
if (own) return applyContributedFormat(view, own, opts, guard);
const doc = docOf(view);
if (!doc || doc.readOnly) return NOTHING;
// The server formats the backend's copy: let pending keystrokes reach it first.
@@ -92,7 +102,7 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly
if (opts.explicit) showHint(view, "The file changed while formatting; try again");
return NOTHING;
}
const changes = textEditsToChanges(state.doc, edits);
const changes = guardFormatChanges(state.doc.toString(), textEditsToChanges(state.doc, edits), guard);
if (!changes.length) {
if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted");
return NOTHING;
@@ -110,13 +120,18 @@ function applyContributedFormat(
view: EditorView,
format: ContributedFormatter,
opts: { explicit: boolean; selectionOnly?: boolean },
guard: FormatGuardOptions,
): FormatResult {
const state = view.state;
if (state.readOnly) return NOTHING;
const sel = state.selection.main;
const useSelection = (opts.selectionOnly ?? true) && !sel.empty;
const changes = format(state, useSelection ? { from: sel.from, to: sel.to } : null).filter(
(c) => state.sliceDoc(c.from, c.to) !== c.insert,
const changes = guardFormatChanges(
state.doc.toString(),
format(state, useSelection ? { from: sel.from, to: sel.to } : null)
.filter((c) => state.sliceDoc(c.from, c.to) !== c.insert)
.sort((a, b) => a.from - b.from),
guard,
);
if (!changes.length) {
if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted");
+2 -1
Переглянути файл
@@ -6,6 +6,7 @@
import { StateEffect, StateField, type Extension } from "@codemirror/state";
import { EditorView, keymap, showTooltip, type Tooltip } from "@codemirror/view";
import { Prec } from "@codemirror/state";
import { txTemplate } from "@/platform/i18n";
interface Hint {
pos: number;
@@ -33,7 +34,7 @@ const hintField = StateField.define<Hint | null>({
const dom = document.createElement("div");
dom.className = `cm-lsp-hint cm-lsp-hint-${h.tone}`;
dom.setAttribute("role", "status");
if (typeof h.content === "string") dom.textContent = h.content;
if (typeof h.content === "string") dom.textContent = txTemplate(h.content);
else dom.appendChild(h.content());
return { dom };
},
+7 -4
Переглянути файл
@@ -230,20 +230,23 @@ async function definitionTarget(loc: Location, fallbackLanguage: string | null):
};
}
/** ⌥Space: the definition's source in a popup, without leaving the editor; ← / → switch between several. */
export async function quickDefinition(view: EditorView) {
/**
* ⌥Space: the definition's source in a popup, without leaving the editor; ← / → switch between several.
* View › Quick Type Definition: the same for the symbol's type (`typeDefinition`).
*/
export async function quickDefinition(view: EditorView, kind: "definition" | "typeDefinition" = "definition") {
const doc = docOf(view);
if (!doc) return;
const pos = view.state.selection.main.head;
try {
const found = await lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, pos), "definition"));
const found = await lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, pos), kind));
const seen = new Set<string>();
const locations = found.filter((l) => {
const key = `${l.path}:${l.range.start.line}:${l.range.start.col}`;
return !seen.has(key) && !!seen.add(key);
});
if (!locations.length) {
showHint(view, "No definition found");
showHint(view, kind === "typeDefinition" ? "No type definition found" : "No definition found");
return;
}
const targets = (await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))).filter(
+6 -1
Переглянути файл
@@ -1,7 +1,7 @@
/**
* LSP-backed editor features: completion, diagnostics, hover / quick docs,
* parameter info, navigation, document highlights, intentions, rename,
* formatting, inlay hints and semantic highlighting. Everything is contributed
* formatting, inlay hints, inline completion and semantic highlighting. Everything is contributed
* through `editor/api` as CodeMirror extensions plus actions; editor/core is
* never imported.
*/
@@ -18,6 +18,7 @@ import { parameterTabKeys, signatureExtension } from "./signature";
import { navigationExtension } from "./navigation";
import { intentionsExtension } from "./intentions";
import { inlayExtension } from "./inlay";
import { inlineCompletionExtension, registerInlineCompletionActions } from "./inlineCompletion";
import { semanticExtension } from "./semantic";
import { usagesLensExtension } from "./codeLens";
import { autoImportExtension } from "./autoImport";
@@ -26,6 +27,7 @@ import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from
import { lspTheme } from "./theme";
import { inspectionWidgetExtension } from "./inspectionWidget";
import { unresolvedReferences } from "./fileReferences";
import { spellingExtension } from "./spelling";
import { DocumentationWindow, LspPopups, UsagesStatus } from "./Popups";
import { DOCUMENTATION_WINDOW } from "./docs";
import { prefetchMarkdown } from "./markdown";
@@ -45,10 +47,12 @@ export function lspExtensions(ctx: EditorDocContext): Extension[] {
navigationExtension(),
intentionsExtension(),
inlayExtension(),
inlineCompletionExtension(),
semanticExtension(),
usagesLensExtension(),
autoImportExtension(),
unresolvedReferences(),
spellingExtension(),
];
}
@@ -73,6 +77,7 @@ export function activate(): () => void {
component: DocumentationWindow,
}),
registerLspActions(),
registerInlineCompletionActions(),
registerNavigationActions(),
registerFormatOnSave(),
registerActionsOnSave(),
+138
Переглянути файл
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { InlineCompletionItem } from "@/bindings";
const ipc = vi.hoisted(() => ({
lspInlineCompletion: vi.fn(),
lspExecuteCommand: vi.fn(async () => ({ status: "ok" as const, data: null })),
cancelRequest: vi.fn(async () => ({ status: "ok" as const, data: null })),
}));
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
const {
acceptInline,
cycleInline,
dismissInline,
ghostField,
ghostText,
inlineCompletionExtension,
nextWord,
requestInline,
toSuggestions,
} = await import("./inlineCompletion");
const { lspDoc } = await import("./lsp");
const item = (insertText: string, extra: Partial<InlineCompletionItem> = {}): InlineCompletionItem => ({
insertText,
range: null,
filterText: null,
command: null,
server: 1,
...extra,
});
const views: EditorView[] = [];
afterEach(() => {
views.splice(0).forEach((v) => v.destroy());
vi.clearAllMocks();
});
function editor(doc: string, caret = doc.length) {
const view = new EditorView({
state: EditorState.create({
doc,
selection: EditorSelection.cursor(caret),
extensions: [
lspDoc.of({ doc: 3, path: "/p/a.rs", language: "rust", virtual: false } as never),
inlineCompletionExtension(),
],
}),
parent: document.body,
});
views.push(view);
return view;
}
const ghostDom = (v: EditorView) => v.dom.querySelector(".cm-inline-ghost")?.textContent ?? null;
async function suggest(view: EditorView, ...items: InlineCompletionItem[]) {
ipc.lspInlineCompletion.mockResolvedValueOnce({ status: "ok", data: items });
return requestInline(view, true);
}
describe("inline completion", () => {
it("shows the server's suggestion as ghost text at the caret", async () => {
const view = editor("let x = fo");
expect(
await suggest(view, item("foo(1);", { range: { start: { line: 0, col: 8 }, end: { line: 0, col: 10 } } })),
).toBe(true);
expect(ipc.lspInlineCompletion.mock.calls[0]!.slice(1)).toEqual([3, { line: 0, col: 10 }, true]);
expect(ghostDom(view)).toBe("o(1);");
});
it("stays while the typed text matches it and goes when it doesn't", async () => {
const view = editor("let x = ");
await suggest(view, item("foo()"));
view.dispatch({ changes: { from: 8, insert: "f" }, selection: EditorSelection.cursor(9), userEvent: "input.type" });
expect(ghostDom(view)).toBe("oo()");
view.dispatch({
changes: { from: 9, insert: "x" },
selection: EditorSelection.cursor(10),
userEvent: "input.type",
});
expect(view.state.field(ghostField)).toBeNull();
expect(ghostDom(view)).toBeNull();
});
it("accepts the whole suggestion or its next word, and runs its command", async () => {
const view = editor("x = ");
const command = { title: "accepted", command: "ghost.accepted", arguments: [] };
await suggest(view, item("compute(a, b)", { command }));
expect(acceptInline(view, true)).toBe(true);
expect(view.state.doc.toString()).toBe("x = compute");
expect(ghostDom(view)).toBe("(a, b)");
expect(acceptInline(view)).toBe(true);
expect(view.state.doc.toString()).toBe("x = compute(a, b)");
expect(view.state.selection.main.head).toBe(17);
expect(view.state.field(ghostField)).toBeNull();
expect(ipc.lspExecuteCommand).toHaveBeenCalledWith(3, command);
expect(acceptInline(view)).toBe(false);
});
it("cycles through variants and dismisses", async () => {
const view = editor("a");
const range = { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } };
await suggest(view, item("abc", { range }), item("axe", { range }));
expect(ghostDom(view)).toBe("bc");
cycleInline(view, 1);
expect(ghostDom(view)).toBe("xe");
cycleInline(view, 1);
expect(ghostDom(view)).toBe("bc");
cycleInline(view, -1);
expect(ghostDom(view)).toBe("xe");
expect(dismissInline(view)).toBe(true);
expect(ghostDom(view)).toBeNull();
expect(dismissInline(view)).toBe(false);
});
it("drops suggestions that don't fit the caret, and trims text the suggestion replaces", () => {
const state = EditorState.create({ doc: "f()", selection: EditorSelection.cursor(2) });
const range = { start: { line: 0, col: 0 }, end: { line: 0, col: 3 } };
const found = toSuggestions(state, 2, [item("f(a)", { range }), item("g(b)", { range }), item("f(a)", { range })]);
expect(found).toHaveLength(1);
// `)` follows the caret and ends the suggestion: only `a` is new.
expect(ghostText(state, found[0]!)).toBe("a");
expect(ghostText(state, { ...found[0]!, text: "f(a, b) + 1" })).toBe("a, b) + 1");
});
it("splits the next word like IDEA", () => {
expect(nextWord("foo(bar)")).toBe("foo");
expect(nextWord("(bar)")).toBe("(");
expect(nextWord(" bar baz")).toBe(" bar");
expect(nextWord(" ")).toBe(" ");
});
});
+324
Переглянути файл
@@ -0,0 +1,324 @@
/**
* Inline completion (LSP 3.18 `textDocument/inlineCompletion`): a server's
* suggestion shown as gray text at the caret while typing (Settings › Editor ›
* General › Inline Completion). Tab accepts it, Escape dismisses it, ⌥] / ⌥[
* cycle through the server's variants and ⌘→ accepts the next word; typing what
* it shows keeps it. Requests are debounced and cancelled (RequestId) as typing
* goes on; only servers advertising `inlineCompletionProvider` are asked.
*/
import { completionStatus } from "@codemirror/autocomplete";
import { EditorSelection, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, WidgetType, type ViewUpdate } from "@codemirror/view";
import { registerActions, type Action } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { editorPrefs } from "@/platform/editorSettings";
import {
call,
commands,
isCancelled,
isTauri,
nextRequestId,
type Command,
type InlineCompletionItem,
type RequestId,
} from "@/platform/ipc";
import { activeEditor } from "@/editor/api";
import { docOf, isUnavailable, lspRequest } from "./lsp";
import { offsetToPos, rangeToOffsets } from "./positions";
import { showHint } from "./hint";
/** A suggestion in document offsets: `text` replaces `[from, to)`. */
export interface Suggestion {
from: number;
to: number;
text: string;
command: Command | null;
}
export interface Ghost {
items: Suggestion[];
index: number;
/** Caret the ghost is shown at. */
caret: number;
}
export const CONTEXT_KEY = "inlineCompletionVisible";
/** Debounce while typing when "show only when typing pauses" is off. */
const TYPING_DELAY = 75;
/** Back-off after "no server supports inline completion", per document. */
const BACKOFF_MS = 30_000;
export const setGhost = StateEffect.define<Ghost | null>();
const cycleGhost = StateEffect.define<1 | -1>();
/** The suggestion still applies at the caret: what was typed since is a prefix of it. */
function stillValid(state: EditorState, s: Suggestion): boolean {
const sel = state.selection;
if (sel.ranges.length !== 1 || !sel.main.empty) return false;
const caret = sel.main.head;
if (caret < s.from) return false;
const typed = state.doc.sliceString(s.from, caret);
return s.text.length > typed.length && s.text.startsWith(typed);
}
/** The text shown after the caret: the rest of the suggestion, without what already follows the caret and it replaces. */
export function ghostText(state: EditorState, s: Suggestion): string {
const caret = state.selection.main.head;
const rest = s.text.slice(caret - s.from);
const after = s.to > caret ? state.doc.sliceString(caret, s.to) : "";
return after && rest.endsWith(after) ? rest.slice(0, rest.length - after.length) : rest;
}
export const ghostField = StateField.define<Ghost | null>({
create: () => null,
update(value, tr) {
for (const e of tr.effects) {
if (e.is(setGhost)) value = e.value;
else if (e.is(cycleGhost) && value && value.items.length > 1)
value = { ...value, index: (value.index + e.value + value.items.length) % value.items.length };
}
if (!value) return null;
let items = value.items;
if (tr.docChanged)
items = items.map((s) => ({
...s,
from: tr.changes.mapPos(s.from, -1),
to: Math.max(tr.changes.mapPos(s.to, 1), tr.changes.mapPos(s.from, -1)),
}));
if (!tr.docChanged && !tr.selection && items === value.items) return value;
if (completionStatus(tr.state) === "active") return null;
const current = items[value.index];
const valid = items.filter((s) => stillValid(tr.state, s));
if (!valid.length) return null;
const index = current && valid.includes(current) ? valid.indexOf(current) : 0;
return { items: valid, index, caret: tr.state.selection.main.head };
},
provide: (f) =>
EditorView.decorations.compute([f], (state) => {
const g = state.field(f);
const s = g?.items[g.index];
const text = s ? ghostText(state, s) : "";
if (!g || !text) return Decoration.none;
const label = g.items.length > 1 ? `${g.index + 1}/${g.items.length}` : null;
return Decoration.set([Decoration.widget({ widget: new GhostWidget(text, label), side: 1 }).range(g.caret)]);
}),
});
class GhostWidget extends WidgetType {
constructor(
readonly text: string,
readonly label: string | null,
) {
super();
}
eq(other: GhostWidget) {
return other.text === this.text && other.label === this.label;
}
toDOM() {
const el = document.createElement("span");
el.className = "cm-inline-ghost";
el.textContent = this.text;
if (this.label) el.title = `Suggestion ${this.label}`;
el.setAttribute("aria-hidden", "true");
return el;
}
ignoreEvent() {
return false;
}
}
/** Inline completion items → suggestions at `caret` of `state` (no range: inserted at the caret). */
export function toSuggestions(state: EditorState, caret: number, items: readonly InlineCompletionItem[]): Suggestion[] {
const out: Suggestion[] = [];
for (const item of items) {
const { from, to } = item.range ? rangeToOffsets(state.doc, item.range) : { from: caret, to: caret };
const s = { from, to, text: item.insertText, command: item.command };
if (from <= caret && stillValid(state, s) && !out.some((o) => o.from === from && o.text === s.text)) out.push(s);
}
return out;
}
function enabledFor(language: string): boolean {
const p = editorPrefs().inlineCompletion;
return p.enabled && !p.disabledLanguages.includes(language);
}
const backoff = new Map<number, number>();
let focused: EditorView | null = null;
let inflight: RequestId | null = null;
function cancelInflight() {
if (inflight !== null && isTauri()) void commands.cancelRequest(inflight);
inflight = null;
}
/** Asks the servers for suggestions at the caret and shows them; false when there are none. */
export async function requestInline(view: EditorView, automatic: boolean): Promise<boolean> {
const doc = docOf(view);
const state = view.state;
const sel = state.selection;
if (!doc || sel.ranges.length !== 1 || !sel.main.empty || view.state.readOnly) return false;
if (automatic && (backoff.get(doc.doc) ?? 0) > Date.now()) return false;
cancelInflight();
const id = (inflight = nextRequestId());
const caret = sel.main.head;
try {
const items = await lspRequest(() =>
commands.lspInlineCompletion(id, doc.doc, offsetToPos(state.doc, caret), automatic),
);
if (view.state.doc !== state.doc || !view.state.selection.eq(sel)) return false;
const suggestions = toSuggestions(view.state, caret, items);
view.dispatch({ effects: setGhost.of(suggestions.length ? { items: suggestions, index: 0, caret } : null) });
return suggestions.length > 0;
} catch (e) {
if (isUnavailable(e)) backoff.set(doc.doc, Date.now() + BACKOFF_MS);
else if (!isCancelled(e)) console.debug("[lsp] inline completion", e);
if (!automatic && !isCancelled(e)) throw e;
return false;
} finally {
if (inflight === id) inflight = null;
}
}
function publish(view: EditorView) {
if (view.hasFocus) focused = view;
if (focused === view) setContext(CONTEXT_KEY, view.hasFocus && !!view.state.field(ghostField, false));
}
const typedChange = (u: ViewUpdate) =>
u.transactions.some((tr) => tr.isUserEvent("input.type") || tr.isUserEvent("delete"));
const inlinePlugin = ViewPlugin.fromClass(
class {
private timer: ReturnType<typeof setTimeout> | undefined;
constructor(private view: EditorView) {}
update(u: ViewUpdate) {
if (u.docChanged || u.selectionSet) {
clearTimeout(this.timer);
cancelInflight();
}
if (u.docChanged && typedChange(u) && !u.state.field(ghostField)) this.schedule();
if (
u.focusChanged ||
u.docChanged ||
u.selectionSet ||
u.startState.field(ghostField) !== u.state.field(ghostField)
) {
if (u.focusChanged && !u.view.hasFocus && u.state.field(ghostField))
queueMicrotask(() => {
if (this.view.dom.isConnected && !this.view.hasFocus) dismissInline(this.view);
});
publish(u.view);
}
}
schedule() {
const doc = docOf(this.view);
if (!doc || !enabledFor(doc.language) || !isTauri()) return;
const p = editorPrefs().inlineCompletion;
this.timer = setTimeout(
() => {
if (completionStatus(this.view.state) !== "active") void requestInline(this.view, true);
},
p.onlyOnPause ? p.pauseMs : TYPING_DELAY,
);
}
destroy() {
clearTimeout(this.timer);
if (focused === this.view) {
focused = null;
setContext(CONTEXT_KEY, false);
}
}
},
);
export function inlineCompletionExtension(): Extension {
return [ghostField, inlinePlugin];
}
/** The next word of a suggestion's rest: leading spaces plus one word or run of punctuation. */
export function nextWord(rest: string): string {
return /^\s*(?:[\p{L}\p{N}_$]+|[^\p{L}\p{N}_$\s]+)|^\s+/u.exec(rest)?.[0] ?? rest;
}
/** Accepts the shown suggestion (`word`: only its next word). False when none is shown. */
export function acceptInline(view: EditorView, word = false): boolean {
const g = view.state.field(ghostField, false);
const s = g?.items[g.index];
if (!g || !s) return false;
const caret = view.state.selection.main.head;
if (word) {
const chunk = nextWord(s.text.slice(caret - s.from));
view.dispatch({
changes: { from: caret, insert: chunk },
selection: EditorSelection.cursor(caret + chunk.length),
userEvent: "input.complete",
scrollIntoView: true,
});
return true;
}
view.dispatch({
changes: { from: s.from, to: Math.max(s.to, caret), insert: s.text },
selection: EditorSelection.cursor(s.from + s.text.length),
effects: setGhost.of(null),
userEvent: "input.complete",
scrollIntoView: true,
});
const doc = docOf(view);
if (s.command && doc && isTauri()) void call(commands.lspExecuteCommand(doc.doc, s.command)).catch(() => undefined);
return true;
}
export function dismissInline(view: EditorView): boolean {
if (!view.state.field(ghostField, false)) return false;
view.dispatch({ effects: setGhost.of(null) });
return true;
}
export function cycleInline(view: EditorView, dir: 1 | -1): boolean {
if (!view.state.field(ghostField, false)) return false;
view.dispatch({ effects: cycleGhost.of(dir) });
return true;
}
const VISIBLE = `editorTextFocus && ${CONTEXT_KEY}`;
function onView(run: (view: EditorView) => unknown): Action["run"] {
return () => {
const view = activeEditor()?.view;
if (view) void run(view);
};
}
export function registerInlineCompletionActions(): () => void {
const shown = (id: string, title: string, run: (v: EditorView) => unknown): Action => ({
id,
title,
category: "Inline Completion",
when: VISIBLE,
run: onView(run),
});
return registerActions([
{
id: "editor.inlineCompletion.trigger",
title: "Call Inline Completion",
category: "Code",
when: "editorTextFocus",
keywords: ["ghost text", "inline suggestion"],
run: onView(async (v) => {
const doc = docOf(v);
if (!doc || !enabledFor(doc.language)) return showHint(v, "Inline completion is off for this language");
try {
if (!(await requestInline(v, false))) showHint(v, "No inline suggestions");
} catch (e) {
showHint(v, isUnavailable(e) ? "No language server provides inline completion" : String(e));
}
}),
},
shown("editor.inlineCompletion.accept", "Insert Inline Proposal", (v) => acceptInline(v)),
shown("editor.inlineCompletion.acceptWord", "Insert Inline Proposal: Next Word", (v) => acceptInline(v, true)),
shown("editor.inlineCompletion.dismiss", "Hide Inline Proposal", dismissInline),
shown("editor.inlineCompletion.next", "Show Next Inline Proposal", (v) => cycleInline(v, 1)),
shown("editor.inlineCompletion.previous", "Show Previous Inline Proposal", (v) => cycleInline(v, -1)),
]);
}
+31 -15
Переглянути файл
@@ -17,6 +17,7 @@ import { editPreviewLines } from "./workspaceEdit";
import { serverBackoff } from "./availability";
import { iconNode } from "./iconMarkup";
import { editorPrefs } from "@/platform/editorSettings";
import { spellingIntentions } from "./spelling";
const isQuickFix = (a: CodeAction) => !!a.kind && (a.kind === "quickfix" || a.kind.startsWith("quickfix."));
@@ -172,22 +173,29 @@ export async function intentionPreview(
/** ⌥↵ */
export async function showIntentions(view: EditorView) {
try {
// Like IDEA, only applicable intentions are listed (servers also report disabled refactorings).
const actions = await fetchIntentions(view);
if (!actions.length) {
// Typo fixes come first and need no language server.
const local = spellingIntentions(view);
let actions: CodeAction[] = [];
try {
// Like IDEA, only applicable intentions are listed (servers also report disabled refactorings).
actions = await fetchIntentions(view);
} catch (e) {
if (!local.length) throw e;
}
if (!actions.length && !local.length) {
showHint(view, "No intentions available");
return;
}
const sorted = sortCodeActions(actions);
const resolved = new Map<CodeAction, Promise<CodeAction>>();
const items = actionItems(view, sorted, resolved);
const items = [...local, ...actionItems(view, sorted, resolved)];
openPopup({
kind: "list",
anchor: caretAnchor(view, view.state.selection.main.head),
title: "Intentions",
items,
preview: (item, signal) => {
const action = sorted[items.indexOf(item)];
const action = sorted[items.indexOf(item) - local.length];
return action ? intentionPreview(view, action, resolved, signal) : Promise.resolve(null);
},
});
@@ -291,18 +299,26 @@ const bulbPlugin = ViewPlugin.fromClass(
}
async run() {
const doc = docOf(this.view);
if (!doc || doc.readOnly || serverBackoff.active(doc.doc) || !this.view.dom.isConnected) return;
if (!doc || doc.readOnly || !this.view.dom.isConnected) return;
const state = this.view.state;
try {
const actions = await fetchIntentions(this.view);
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection)) return;
const line = state.doc.lineAt(state.selection.main.head).number;
this.view.dispatch({
effects: setBulb.of(actions.length ? { line, quickFix: actions.some(isQuickFix) } : null),
});
} catch (e) {
if (isUnavailable(e)) serverBackoff.mark(doc.doc);
const typoFix = spellingIntentions(this.view).length > 0;
const backoff = serverBackoff.active(doc.doc);
if (backoff && !typoFix) return;
let actions: CodeAction[] = [];
if (!backoff) {
try {
actions = await fetchIntentions(this.view);
} catch (e) {
if (isUnavailable(e)) serverBackoff.mark(doc.doc);
if (!typoFix) return;
}
}
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection)) return;
const line = state.doc.lineAt(state.selection.main.head).number;
const any = actions.length > 0 || typoFix;
this.view.dispatch({
effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null),
});
}
destroy() {
this.lookup.cancel();
+101
Переглянути файл
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState, Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { Settings, Typo } from "@/bindings";
const typos: Typo[] = [{ range: { start: { line: 0, col: 3 }, end: { line: 0, col: 9 } }, word: "mesage" }];
const spellCheck = vi.fn(async () => ({ status: "ok" as const, data: typos }));
const settingsSet = vi.fn(async (_scope: string, _key: string, value: string) => ({
status: "ok" as const,
data: { spelling: { ideWords: [], projectWords: JSON.parse(value), customDictionaries: [] } } as unknown as Settings,
}));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: { ...actual.commands, spellCheck, settingsSet, cancelRequest: vi.fn(async () => {}) },
};
});
const { spellingExtension, spellingIntentions } = await import("./spelling");
const { diagnosticsFor, toLintDiagnostics, diagnosticMarks } = await import("./diagnostics");
const { lspDoc } = await import("./lsp");
const { useProject } = await import("@/platform/project");
const views: EditorView[] = [];
afterEach(() => {
views.splice(0).forEach((v) => v.destroy());
useProject.setState({ project: null, settings: null });
vi.clearAllMocks();
vi.useRealTimers();
});
function editor(doc: string) {
const view = new EditorView({
state: EditorState.create({
doc,
extensions: [
lspDoc.of({ doc: 7, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: false }),
spellingExtension(),
],
}),
});
views.push(view);
return view;
}
async function settle() {
await vi.advanceTimersByTimeAsync(1000);
}
describe("Typo inspection", () => {
it("reports typos at the Typo level and offers fixes at the caret", async () => {
vi.useFakeTimers();
useProject.setState({ project: { root: "/p" } as never, settings: {} as never });
const view = editor("// mesage here");
await settle();
expect(spellCheck).toHaveBeenCalledWith(7, expect.any(Number));
const [diag] = diagnosticsFor("/p/a.rs");
expect(diag).toMatchObject({ message: "Typo: In word 'mesage'", severity: "info", typo: true });
const lint = toLintDiagnostics(view.state.doc, diagnosticsFor("/p/a.rs"));
expect(lint[0]!.markClass).toBe("cm-lsp-typo");
expect(diagnosticMarks(lint)).toEqual([]);
view.dispatch({ selection: { anchor: 5 } });
expect(spellingIntentions(view).map((i) => i.label)).toEqual([
"Typo: Change to…",
"Save 'mesage' to project-level dictionary",
"Save 'mesage' to application-level dictionary",
]);
view.dispatch({ selection: { anchor: 12 } });
expect(spellingIntentions(view)).toEqual([]);
view.dispatch({ selection: { anchor: 5 } });
await spellingIntentions(view)[1]!.run();
expect(settingsSet).toHaveBeenCalledWith("project", "spelling.projectWords", JSON.stringify(["mesage"]));
// The dictionary change re-checks the file.
await settle();
expect(spellCheck).toHaveBeenCalledTimes(2);
});
it("does not run while the inspection is disabled", async () => {
vi.useFakeTimers();
useProject.setState({
settings: { inspections: { default: { "builtin:spelling": { enabled: false } } } } as never,
});
const view = editor("// mesage");
await settle();
expect(spellCheck).not.toHaveBeenCalled();
view.dispatch({ selection: { anchor: 5 } });
expect(spellingIntentions(view)).toEqual([]);
});
it("keeps lint conversion of plain info diagnostics unchanged", () => {
const doc = Text.of(["abc"]);
const out = toLintDiagnostics(doc, [
{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }, severity: "info", message: "m", source: null, code: null },
]);
expect(out[0]!.markClass).toBeUndefined();
});
});
+202
Переглянути файл
@@ -0,0 +1,202 @@
/**
* Proofreading › Typo: misspelt words of comments, strings and declared names
* (the backend extracts and checks them off the UI thread), reported as
* diagnostics of the `spelling` producer at the Typo level, with the quick
* fixes "Change to…" and "Save 'word' to project / application dictionary".
*/
import { StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import { whenSynced } from "@/editor/api";
import { call, commands, isCancelled, isTauri, nextRequestId, type RequestId } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { inspectionProfile, SPELLING_INSPECTION, SPELLING_PRODUCER } from "@/platform/inspections";
import type { Settings } from "@/bindings";
import { setFileDiagnostics } from "./diagnostics";
import { docOf } from "./lsp";
import { rangeToOffsets } from "./positions";
import { showHint } from "./hint";
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
const CHECK_DELAY = 800;
interface PlacedTypo {
from: number;
to: number;
word: string;
}
const setTypos = StateEffect.define<readonly PlacedTypo[]>();
/** The last check's typos, mapped through later edits (for the quick fixes). */
const typosField = StateField.define<readonly PlacedTypo[]>({
create: () => [],
update(typos, tr) {
for (const e of tr.effects) if (e.is(setTypos)) return e.value;
if (!tr.docChanged || !typos.length) return typos;
return typos
.map((t) => ({ ...t, from: tr.changes.mapPos(t.from, 1), to: tr.changes.mapPos(t.to, -1) }))
.filter((t) => t.to > t.from);
},
});
const spellingEnabled = () => inspectionProfile()[SPELLING_INSPECTION]?.enabled !== false;
const spellingOf = (s: Settings | null) => s?.spelling;
/** Lowercased words of the project and application dictionaries. */
function dictionaryWords(level: "project" | "ide"): string[] {
const sp = spellingOf(useProject.getState().settings);
return [...((level === "project" ? sp?.projectWords : sp?.ideWords) ?? [])];
}
export async function saveToDictionary(word: string, level: "project" | "ide") {
const w = word.toLowerCase();
const list = dictionaryWords(level);
if (list.includes(w)) return;
const key = level === "project" ? "spelling.projectWords" : "spelling.ideWords";
const scope = level === "project" ? "project" : "global";
const settings = await call(commands.settingsSet(scope, key, JSON.stringify([...list, w].sort())));
useProject.setState({ settings });
}
function typoAt(state: EditorState, pos: number): PlacedTypo | undefined {
return state
.field(typosField, false)
?.find((t) => t.from <= pos && pos <= t.to && state.sliceDoc(t.from, t.to) === t.word);
}
function replaceWord(view: EditorView, typo: PlacedTypo, replacement: string) {
if (view.state.sliceDoc(typo.from, typo.to) !== typo.word) return;
view.dispatch({
changes: { from: typo.from, to: typo.to, insert: replacement },
selection: { anchor: typo.from + replacement.length },
userEvent: "input.spelling",
});
}
async function changeTo(view: EditorView, typo: PlacedTypo) {
let suggestions: string[];
try {
suggestions = await call(commands.spellSuggest(typo.word));
} catch {
suggestions = [];
}
if (!suggestions.length) {
showHint(view, `No suggestions for '${typo.word}'`);
return;
}
openPopup({
kind: "list",
anchor: caretAnchor(view, typo.from),
title: `Change '${typo.word}' to`,
items: suggestions.map((s, i) => ({ id: `${i}`, label: s, run: () => replaceWord(view, typo, s) })),
});
}
/** Typo quick fixes at the caret (⌥↵), or none. */
export function spellingIntentions(view: EditorView): ListPopupItem[] {
const doc = docOf(view);
const sel = view.state.selection.main;
if (!doc || doc.readOnly || !sel.empty || !spellingEnabled()) return [];
const typo = typoAt(view.state, sel.head);
if (!typo) return [];
const hasProject = !!useProject.getState().project;
return [
{ id: "spelling.change", label: "Typo: Change to…", icon: "bulb-fix", emphasis: true, run: () => changeTo(view, typo) },
...(hasProject
? [
{
id: "spelling.saveProject",
label: `Save '${typo.word}' to project-level dictionary`,
icon: "bulb-fix",
run: () => saveToDictionary(typo.word, "project"),
},
]
: []),
{
id: "spelling.saveIde",
label: `Save '${typo.word}' to application-level dictionary`,
icon: "bulb-fix",
run: () => saveToDictionary(typo.word, "ide"),
},
];
}
/** Checks the file after edits (debounced), dictionary and profile changes. */
export function spellingExtension(): Extension {
const plugin = ViewPlugin.define((view: EditorView) => {
const doc = docOf(view);
if (!doc || doc.virtual || !isTauri()) return {};
let timer: ReturnType<typeof setTimeout> | undefined;
let request: RequestId | null = null;
let seq = 0;
const cancel = () => {
if (request !== null) void commands.cancelRequest(request);
request = null;
};
const clear = () => {
setFileDiagnostics(doc.path, SPELLING_PRODUCER, []);
if (view.state.field(typosField).length) view.dispatch({ effects: setTypos.of([]) });
};
const run = async () => {
const mine = ++seq;
cancel();
if (!spellingEnabled()) {
clear();
return;
}
const id = nextRequestId();
request = id;
try {
await whenSynced(view);
if (mine !== seq) return;
const typos = await call(commands.spellCheck(doc.doc, id));
if (mine !== seq) return;
const text = view.state.doc;
setFileDiagnostics(
doc.path,
SPELLING_PRODUCER,
typos.map((t) => ({
range: t.range,
severity: "info",
message: `Typo: In word '${t.word}'`,
source: "Spelling",
code: null,
})),
);
const placed = typos.map((t) => ({ ...rangeToOffsets(text, t.range), word: t.word }));
view.dispatch({ effects: setTypos.of(placed) });
} catch (e) {
if (!isCancelled(e)) console.debug("[spelling]", e);
} finally {
if (request === id) request = null;
}
};
const schedule = (delay = CHECK_DELAY) => {
clearTimeout(timer);
timer = setTimeout(() => void run(), delay);
};
schedule(200);
let lastConfig = JSON.stringify([spellingOf(useProject.getState().settings), spellingEnabled()]);
const offSettings = useProject.subscribe((s, prev) => {
if (s.settings === prev.settings) return;
const next = JSON.stringify([spellingOf(s.settings), spellingEnabled()]);
if (next === lastConfig) return;
lastConfig = next;
schedule(0);
});
return {
update: (u) => {
if (u.docChanged) schedule();
},
destroy: () => {
clearTimeout(timer);
offSettings();
seq++;
cancel();
setFileDiagnostics(doc.path, SPELLING_PRODUCER, []);
},
};
});
return [typosField, plugin];
}
+14
Переглянути файл
@@ -152,7 +152,19 @@ export const lspTheme = EditorView.theme({
fontFamily: "var(--font-ui)",
fontSize: "var(--fs-small)",
whiteSpace: "nowrap",
gap: "12px",
justifyContent: "space-between",
},
".cm-lsp-completion-sort": {
padding: "0",
border: "none",
background: "none",
color: "var(--fg-subtle)",
font: "inherit",
cursor: "pointer",
},
".cm-lsp-completion-sort:hover": { color: "var(--fg)" },
'.cm-lsp-completion-sort[aria-pressed="true"]': { color: "var(--link)" },
".cm-tooltip.cm-completionInfo": {
...tooltipSurface,
padding: "8px 12px",
@@ -310,6 +322,8 @@ export const lspTheme = EditorView.theme({
},
// A part naming a declaration (⌘-click navigates there).
".cm-lsp-inlay-part:hover": { textDecoration: "underline", textUnderlineOffset: "2px" },
// Inline completion: the suggestion as gray text at the caret.
".cm-inline-ghost": { color: "var(--fg-subtle)", whiteSpace: "pre" },
".cm-lsp-inlay-pl": { marginLeft: "4px" },
".cm-lsp-inlay-pr": { marginRight: "4px" },
+1 -1
Переглянути файл
@@ -4,5 +4,5 @@
* is not usable (see `@/editor/structure`).
*/
export { introduceFallback, type FallbackReason, type IntroduceKind } from "./introduce";
export { refactorThis, runRefactoring } from "./refactorThis";
export { refactorThis, runRefactoring, showNothingHere } from "./refactorThis";
export { askUsagesDetected, fileUsages, safeDelete, viewUsages, type FileUsages, type UsagesAnswer } from "./safeDelete";
+4 -2
Переглянути файл
@@ -188,8 +188,10 @@ describe("Refactor This", () => {
it("says so when nothing applies", async () => {
const v = editor();
await refactorThis(v, vi.fn());
expect(useLspPopup.getState().popup).toBeNull();
expect(showHint).toHaveBeenCalledWith(v, "No refactorings available at the caret");
expect(labels()).toEqual(["Nothing here"]);
const popup = useLspPopup.getState().popup as { items: { disabled?: string | null }[] };
expect(popup.items[0]!.disabled).toBeTruthy();
expect(showHint).not.toHaveBeenCalled();
});
it("cancels a superseded query and drops answers after the caret moved", async () => {
+11 -1
Переглянути файл
@@ -261,8 +261,18 @@ export async function refactorThis(view: EditorView, rename: (view: EditorView)
// Superseded, or the caret moved / the text changed meanwhile: the answer is stale.
if (run.signal.aborted || view.state.doc !== state.doc || !view.state.selection.eq(state.selection)) return;
if (!items.length) {
showHint(view, "No refactorings available at the caret");
showNothingHere(caretAnchor(view, state.selection.main.head));
return;
}
openPopup({ kind: "list", anchor: caretAnchor(view, state.selection.main.head), title: "Refactor This", items, numbered: true });
}
/** IDEA's "Nothing here" popup: Refactor This where no refactoring applies (or outside an editor). */
export function showNothingHere(anchor: { x: number; y: number } = { x: window.innerWidth / 2, y: window.innerHeight / 3 }) {
openPopup({
kind: "list",
anchor,
title: "Refactor This",
items: [{ id: "nothing", label: "Nothing here", disabled: "No refactoring applies here", run: () => {} }],
});
}
+85 -4
Переглянути файл
@@ -8,12 +8,15 @@ import {
type StructuralFile,
type StructuralLanguage,
} from "@/platform/ipc";
import { basename } from "@/platform/project";
import { Checkbox, Dialog, Input, Select, Spinner, T } from "@/ui";
import { executeAction } from "@/platform/actions";
import { activeProfileId, listProfiles, validateInspections, type ProfileId } from "@/platform/inspections";
import { basename, useProject } from "@/platform/project";
import { Button, Checkbox, Dialog, Input, Select, Spinner, T } from "@/ui";
import { ScopeFields, openDialog } from "./dialogs";
import { locateDuplicates, loadDuplicateOptions } from "./duplicates";
import { runInspection } from "./inspect";
import { defaultScope, type ScopeChoice } from "./scope";
import { runCodeCleanup, type CleanupOptions } from "./cleanup";
import { currentFile, defaultScope, type ScopeChoice } from "./scope";
import {
applyStructuralReplace,
buildReplaceEdit,
@@ -28,9 +31,21 @@ import styles from "./Analysis.module.css";
function InspectCodeDialog({ onClose }: { onClose: () => void }) {
const [scope, setScope] = useState<ScopeChoice>(() => defaultScope());
const [profile, setProfile] = useState<ProfileId>(activeProfileId);
const hasProject = useProject((s) => !!s.project);
const settings = useProject((s) => s.settings);
const profiles = useMemo(
() =>
listProfiles(validateInspections(settings?.inspections), hasProject).map((p) => ({
value: p.id,
label: p.label,
detail: p.level === "project" ? "Project" : "IDE",
})),
[hasProject, settings],
);
const ok = () => {
onClose();
void runInspection(scope);
void runInspection(scope, profile);
};
return (
<Dialog
@@ -45,6 +60,24 @@ function InspectCodeDialog({ onClose }: { onClose: () => void }) {
>
<div className={styles.form}>
<ScopeFields name="inspect-scope" value={scope} onChange={setScope} />
<div className={styles.fieldRow}>
<span>
<T>Inspection profile:</T>
</span>
<Select
className={styles.grow}
aria-label="Inspection profile"
value={profile}
options={profiles}
onChange={setProfile}
/>
<Button
aria-label="Configure inspections"
onClick={() => void executeAction("file.settings", "editor.inspections")}
>
…
</Button>
</div>
<div className={styles.hint}>
<T>Files are checked by their language servers; files without one are skipped.</T>
</div>
@@ -53,6 +86,53 @@ function InspectCodeDialog({ onClose }: { onClose: () => void }) {
);
}
const CLEANUP_SCOPES = ["project", "directory", "file", "changed"] as const;
function CodeCleanupDialog({ onClose }: { onClose: () => void }) {
const [scope, setScope] = useState<ScopeChoice>(() => defaultScope(currentFile() ? "file" : "project"));
const [options, setOptions] = useState<CleanupOptions>({ fixAll: true, optimizeImports: true, reformat: true });
const any = options.fixAll || options.optimizeImports || options.reformat;
const ok = () => {
if (!any) return;
onClose();
void runCodeCleanup(scope, options);
};
return (
<Dialog
open
title="Run Code Cleanup"
onClose={onClose}
width={440}
buttons={[
{ label: "OK", role: "default", disabled: !any, onClick: ok },
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.form}>
<ScopeFields name="cleanup-scope" value={scope} onChange={setScope} kinds={CLEANUP_SCOPES} />
<Checkbox
checked={options.fixAll}
onChange={(fixAll) => setOptions({ ...options, fixAll })}
label="Apply the language servers' automatic fixes"
/>
<Checkbox
checked={options.optimizeImports}
onChange={(optimizeImports) => setOptions({ ...options, optimizeImports })}
label="Optimize imports"
/>
<Checkbox
checked={options.reformat}
onChange={(reformat) => setOptions({ ...options, reformat })}
label="Reformat code"
/>
<div className={styles.hint}>
<T>Files are cleaned up by their language servers and saved; files without one are skipped.</T>
</div>
</div>
</Dialog>
);
}
function DuplicatesDialog({ onClose }: { onClose: () => void }) {
const [scope, setScope] = useState<ScopeChoice>(() => defaultScope());
const [options, setOptions] = useState<DuplicateOptions>(loadDuplicateOptions);
@@ -303,6 +383,7 @@ function ReplacePreviewDialog({ files, onClose }: { files: StructuralFile[]; onC
export const showInspectCodeDialog = () => openDialog((close) => <InspectCodeDialog onClose={close} />);
export const showDuplicatesDialog = () => openDialog((close) => <DuplicatesDialog onClose={close} />);
export const showCodeCleanupDialog = () => openDialog((close) => <CodeCleanupDialog onClose={close} />);
export const showStructuralDialog = (replace: boolean) =>
openDialog((close) => <StructuralDialog replace={replace} onClose={close} />);
export const showReplacePreview = (files: StructuralFile[]) =>
+8
Переглянути файл
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Diagnostic, StructuralFile } from "@/platform/ipc";
import { useCustomScopes } from "@/platform/search";
import { fragmentLines } from "./duplicates";
import { groupInspection, kindOf } from "./inspect";
import { buildInspectionNodes } from "./InspectionView";
@@ -149,6 +150,13 @@ describe("analysis scopes and fragments", () => {
expect(await resolveScope({ kind: "directory", directory: "" })).toBe("Choose a directory");
expect(await resolveScope({ kind: "file", directory: "" })).toBe("No file is open");
expect(await resolveScope({ kind: "changed", directory: "" })).toBe("There are no uncommitted files");
expect(await resolveScope({ kind: "custom", directory: "" })).toBe("Choose a scope");
useCustomScopes.setState({ root: null, scopes: [{ name: "Src", pattern: "file:src//*" }] });
expect(await resolveScope({ kind: "custom", directory: "", custom: "Src" })).toEqual({
kind: "pattern",
pattern: "file:src//*",
});
useCustomScopes.setState({ root: null, scopes: [] });
});
it("numbers fragment lines from the fragment's first line", () => {
+136
Переглянути файл
@@ -0,0 +1,136 @@
/**
* Code › Code Cleanup…: the language servers' fix-all, organize imports and
* reformat over a scope, one file after another (opened, cleaned, saved; tabs
* opened only for the cleanup are closed again). Cancellable from the
* Background Tasks popup.
*/
import { executeAction } from "@/platform/actions";
import { call, commands, isTauri, type FileEntry } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { confirm } from "@/workbench/messages";
import { removeTask, upsertTask } from "@/workbench/tasks";
import { changedFiles, currentFile, type ScopeChoice } from "./scope";
export interface CleanupOptions {
fixAll: boolean;
optimizeImports: boolean;
reformat: boolean;
}
/** Above this many files the user confirms first. */
const CONFIRM_ABOVE = 100;
const extOf = (path: string) => {
const name = path.slice(path.lastIndexOf("/") + 1);
const i = name.lastIndexOf(".");
return i > 0 ? name.slice(i + 1).toLowerCase() : "";
};
/** Extensions of languages that have a language server (the only files a cleanup can change). */
async function serverExtensions(): Promise<Set<string>> {
const out = new Set<string>();
for (const l of await commands.languages())
if (l.languageServers?.length) for (const e of l.extensions) out.add(e.replace(/^\./, "").toLowerCase());
return out;
}
/** Files under `dir`, skipping ignored, excluded and hidden entries and symlinks. */
export async function walkFiles(
dir: string,
readDir: (path: string) => Promise<FileEntry[]>,
accept: (path: string) => boolean,
limit = 5000,
): Promise<string[]> {
const out: string[] = [];
const queue = [dir];
while (queue.length && out.length < limit) {
let entries: FileEntry[];
try {
entries = await readDir(queue.shift()!);
} catch {
continue;
}
for (const e of entries) {
if (e.ignored || e.excluded || e.hidden || e.kind === "symlink") continue;
if (e.kind === "dir") queue.push(e.path);
else if (accept(e.path)) out.push(e.path);
}
}
return out.sort();
}
/** The files of `choice` a cleanup applies to, or a message why there are none. */
export async function cleanupFiles(choice: ScopeChoice): Promise<string[] | string> {
const root = useProject.getState().project?.root;
if (!root) return "No project is open";
const exts = await serverExtensions();
const accept = (p: string) => exts.has(extOf(p));
const readDir = (p: string) => call(commands.fsReadDir(p));
let files: string[];
switch (choice.kind) {
case "file": {
const f = currentFile();
files = f ? [f] : [];
break;
}
case "changed":
files = (await changedFiles()).filter(accept);
break;
case "directory":
files = choice.directory.trim() ? await walkFiles(choice.directory.trim(), readDir, accept) : [];
break;
default:
files = await walkFiles(root, readDir, accept);
}
return files.length ? files : "No files with a language server in this scope";
}
let running: { cancelled: boolean } | null = null;
export async function runCodeCleanup(choice: ScopeChoice, options: CleanupOptions): Promise<void> {
if (!isTauri() || running) return;
const files = await cleanupFiles(choice);
if (typeof files === "string") {
notifications().notify({ group: "analysis", level: "warning", title: "Code Cleanup", body: files, key: "code.cleanup" });
return;
}
if (
files.length > CONFIRM_ABOVE &&
!(await confirm({ title: "Code Cleanup", message: `Clean up ${files.length} files?`, ok: "Clean Up" }))
)
return;
const editors = maybeEditorService();
const wasOpen = new Set(editors?.openPaths?.() ?? []);
const run: { cancelled: boolean } = { cancelled: false };
running = run;
const id = "code.cleanup";
let done = 0;
try {
for (const path of files) {
if (run.cancelled) break;
upsertTask({
id,
title: "Code Cleanup",
message: path.slice(path.lastIndexOf("/") + 1),
fraction: done / files.length,
done: false,
cancel: () => (run.cancelled = true),
});
await executeAction("code.cleanupFile", { path, ...options });
await editors?.saveAll();
if (!wasOpen.has(path)) editors?.closePath?.(path);
done++;
}
} finally {
running = null;
upsertTask({ id, title: "Code Cleanup", message: null, fraction: 1, done: true });
if (run.cancelled) removeTask(id);
}
notifications().notify({
group: "analysis",
level: "info",
title: run.cancelled ? `Code Cleanup cancelled after ${done} of ${files.length} files` : `Code Cleanup processed ${done} files`,
key: "code.cleanup",
});
}
+29 -4
Переглянути файл
@@ -1,8 +1,10 @@
/** Dialog stack of the analysis feature and the shared "Specify Scope" fields. */
import { Fragment, type ReactNode } from "react";
import { create } from "zustand";
import { executeAction } from "@/platform/actions";
import { isTauri } from "@/platform/ipc";
import { Button, Input, T } from "@/ui";
import { EDIT_SCOPES_ACTION, useCustomScopeList } from "@/platform/search";
import { Button, IconButton, Input, Select, T } from "@/ui";
import { SCOPE_LABELS, currentFile, type ScopeChoice, type ScopeKind } from "./scope";
import styles from "./Analysis.module.css";
@@ -42,32 +44,38 @@ async function browseDirectory(initial: string): Promise<string | null> {
return typeof picked === "string" ? picked : null;
}
const KINDS: ScopeKind[] = ["project", "directory", "file", "changed"];
const KINDS: ScopeKind[] = ["project", "directory", "file", "changed", "custom"];
/** IDEA's "Specify Scope" radio group. */
export function ScopeFields({
value,
onChange,
name,
kinds = KINDS,
}: {
value: ScopeChoice;
onChange: (v: ScopeChoice) => void;
name: string;
/** The choices offered (default: all). */
kinds?: readonly ScopeKind[];
}) {
const file = currentFile();
const scopes = useCustomScopeList();
return (
<fieldset className={styles.scope}>
<legend className={styles.fieldLabel}>
<T>Scope</T>
</legend>
{KINDS.map((kind) => (
{kinds.map((kind) => (
<label key={kind} className={styles.radioRow}>
<input
type="radio"
name={name}
checked={value.kind === kind}
disabled={kind === "file" && !file}
onChange={() => onChange({ ...value, kind })}
onChange={() =>
onChange({ ...value, kind, custom: kind === "custom" ? (value.custom ?? scopes[0]?.name) : value.custom })
}
/>
<span>{kind === "file" && file ? `File '${file.split("/").pop()}'` : SCOPE_LABELS[kind]}</span>
</label>
@@ -87,6 +95,23 @@ export function ScopeFields({
</Button>
</div>
)}
{value.kind === "custom" && (
<div className={styles.dirRow}>
<Select<string>
className={styles.grow}
value={value.custom ?? null}
options={scopes.map((s) => ({ value: s.name, label: s.name, detail: s.pattern }))}
placeholder="No custom scopes"
onChange={(custom) => onChange({ ...value, custom })}
aria-label="Custom scope"
/>
<IconButton
icon="more-horizontal"
title="Edit Scopes…"
onClick={() => void executeAction(EDIT_SCOPES_ACTION, value.custom ? { select: value.custom } : undefined)}
/>
</div>
)}
</fieldset>
);
}
+9 -1
Переглянути файл
@@ -5,7 +5,7 @@
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerOverlay, registerToolWindow } from "@/workbench/contributions";
import { showDuplicatesDialog, showInspectCodeDialog, showStructuralDialog } from "./AnalysisDialogs";
import { showCodeCleanupDialog, showDuplicatesDialog, showInspectCodeDialog, showStructuralDialog } from "./AnalysisDialogs";
import { AnalysisDialogHost } from "./dialogs";
import { resetDuplicates } from "./duplicates";
import { DuplicatesHeaderActions, DuplicatesView } from "./DuplicatesView";
@@ -51,6 +51,14 @@ export function activate(): () => void {
keywords: ["inspection", "analyze", "diagnostics", "problems"],
run: () => void showInspectCodeDialog(),
},
{
id: "code.cleanup",
title: "Code Cleanup…",
category: "Code",
when: "projectOpen",
keywords: ["fix all", "cleanup", "organize imports", "reformat"],
run: () => void showCodeCleanupDialog(),
},
{
id: "analyze.locateDuplicates",
title: "Locate Duplicates…",
+28 -6
Переглянути файл
@@ -14,6 +14,14 @@ import {
type InspectSummary,
type RequestId,
} from "@/platform/ipc";
import {
activeProfileId,
applyProfileMixed,
noteDiagnosticCodes,
producerOf,
profileOverrides,
type ProfileId,
} from "@/platform/inspections";
import { notifications, toolWindowService } from "@/workbench/contributions";
import { describeScope, resolveScope, type ScopeChoice } from "./scope";
@@ -99,6 +107,8 @@ interface InspectState {
error: string | null;
/** Scope of the last run (Rerun). */
last: ScopeChoice | null;
/** Inspection profile of the last run. */
profile: ProfileId | null;
/** Problems excluded from the results (⌫), by `problemId`. */
excluded: ReadonlySet<string>;
}
@@ -112,6 +122,7 @@ const initialInspection = (): InspectState => ({
scopeLabel: null,
error: null,
last: null,
profile: null,
excluded: new Set(),
});
@@ -144,7 +155,8 @@ export function resetInspection() {
useInspection.setState(initialInspection());
}
export async function runInspection(choice: ScopeChoice): Promise<void> {
/** Inspect Code over `choice` with `profileId` (default: the profile in effect). */
export async function runInspection(choice: ScopeChoice, profileId: ProfileId = activeProfileId()): Promise<void> {
const scope = await resolveScope(choice);
if (typeof scope === "string") {
notifications().notify({ group: "analysis", level: "warning", title: "Inspect Code", body: scope });
@@ -162,8 +174,10 @@ export async function runInspection(choice: ScopeChoice): Promise<void> {
error: null,
scopeLabel: describeScope(choice, scope),
last: choice,
profile: profileId,
excluded: new Set(),
});
const profile = profileOverrides(profileId);
toolWindowService().show("inspection");
if (!isTauri()) {
useInspection.setState({ running: false, error: "Inspect Code needs the desktop app" });
@@ -182,15 +196,23 @@ export async function runInspection(choice: ScopeChoice): Promise<void> {
case "started":
useInspection.setState({ total: ev.total });
break;
case "file":
if (ev.file.diagnostics.length) found.set(ev.file.path, ev.file);
case "file": {
const diagnostics = applyProfileMixed(profile, ev.file.diagnostics);
for (const d of ev.file.diagnostics) if (d.code) noteDiagnosticCodes(producerOf(d), [d]);
if (diagnostics.length) found.set(ev.file.path, { ...ev.file, diagnostics });
useInspection.setState({ done: ev.done });
flush ??= setTimeout(publish, 150);
break;
}
case "done":
if (flush) clearTimeout(flush);
inflight = null;
useInspection.setState({ running: false, files: new Map(found), summary: ev.summary });
useInspection.setState({
running: false,
files: new Map(found),
// Problems the profile kept, not all the servers reported.
summary: { ...ev.summary, problems: [...found.values()].reduce((n, f) => n + f.diagnostics.length, 0) },
});
break;
}
};
@@ -205,6 +227,6 @@ export async function runInspection(choice: ScopeChoice): Promise<void> {
}
export function rerunInspection() {
const last = useInspection.getState().last;
if (last) void runInspection(last);
const { last, profile } = useInspection.getState();
if (last) void runInspection(last, profile ?? undefined);
}
+11 -1
Переглянути файл
@@ -5,13 +5,16 @@
import { activeEditor } from "@/editor/api";
import { call, commands, isTauri, type SearchScope } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { findCustomScope } from "@/platform/search";
export type ScopeKind = "project" | "directory" | "file" | "changed";
export type ScopeKind = "project" | "directory" | "file" | "changed" | "custom";
export interface ScopeChoice {
kind: ScopeKind;
/** Absolute directory for `directory`. */
directory: string;
/** Name of the custom scope for `custom` (`@/platform/search`). */
custom?: string;
}
export const SCOPE_LABELS: Record<ScopeKind, string> = {
@@ -19,6 +22,7 @@ export const SCOPE_LABELS: Record<ScopeKind, string> = {
directory: "Module or directory",
file: "Current file",
changed: "Uncommitted files",
custom: "Custom scope",
};
/** The file of the active editor, if it is a real file. */
@@ -66,6 +70,10 @@ export async function resolveScope(choice: ScopeChoice): Promise<SearchScope | s
}
return paths.length ? { kind: "files", paths } : "There are no uncommitted files";
}
case "custom": {
const scope = choice.custom ? findCustomScope(choice.custom) : null;
return scope ? { kind: "pattern", pattern: scope.pattern } : "Choose a scope";
}
}
}
@@ -84,5 +92,7 @@ export function describeScope(choice: ScopeChoice, scope: SearchScope): string {
return scope.kind === "files"
? `${scope.paths.length} uncommitted file${scope.paths.length === 1 ? "" : "s"}`
: "Uncommitted files";
case "custom":
return `Scope '${choice.custom ?? ""}'`;
}
}
+146
Переглянути файл
@@ -0,0 +1,146 @@
/**
* File › New Project…: an empty project folder (no framework templates),
* optionally a Git repository, opened in a new window (this one when it has
* no project).
*/
import { useEffect, useState } from "react";
import { create } from "zustand";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useTxTemplate } from "@/platform/i18n";
import { Button, Checkbox, Dialog, Input, T } from "@/ui";
import { defaultProjectDirectory, openProjectInWindow } from "./windows";
import styles from "./app.module.css";
const useNewProject = create<{ open: boolean }>(() => ({ open: false }));
export const showNewProject = () => useNewProject.setState({ open: true });
export function NewProjectOverlay() {
const open = useNewProject((s) => s.open);
return open ? <NewProjectDialog onClose={() => useNewProject.setState({ open: false })} /> : null;
}
/** Why `name` cannot be a project folder name, or null. */
export function projectNameError(name: string): string | null {
const n = name.trim();
if (!n) return "Enter a project name";
if (/[/\\:*?"<>|]/.test(n) || n === "." || n === "..") return "The name contains characters a folder name cannot have";
return null;
}
export function joinPath(dir: string, name: string): string {
const sep = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
return dir.endsWith("/") || dir.endsWith("\\") ? `${dir}${name.trim()}` : `${dir}${sep}${name.trim()}`;
}
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
}
/** Creates `path` (or reuses it when it is an empty folder) and runs `git init` when asked. */
export async function createProjectFolder(path: string, gitInit: boolean): Promise<void> {
let entries: unknown[] | null;
try {
entries = await call(commands.fsReadDir(path));
} catch {
entries = null;
}
if (entries && entries.length) throw new Error(`${path} already exists and is not empty`);
if (!entries) await call(commands.fsCreateDir(path));
if (gitInit) await call(commands.vcsInit(path));
}
/** Default Project Directory, else the parent of the open project. */
function initialLocation(): string {
const preset = defaultProjectDirectory();
if (preset) return preset;
const root = useProject.getState().project?.root;
return root ? root.slice(0, Math.max(root.lastIndexOf("/"), 1)) : "";
}
function NewProjectDialog({ onClose }: { onClose: () => void }) {
const [name, setName] = useState("untitled");
const [location, setLocation] = useState(initialLocation);
const [git, setGit] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const txTemplate = useTxTemplate();
useEffect(() => {
if (initialLocation()) return;
// Otherwise the user's home.
void import("@tauri-apps/api/path")
.then((p) => p.homeDir())
.then(setLocation)
.catch(() => {});
}, []);
const nameError = projectNameError(name);
const invalid = !!nameError || !location.trim();
const path = location.trim() && !nameError ? joinPath(location.trim(), name) : "";
const browse = async () => {
if (!isTauri()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, title: "Project Location", defaultPath: location || undefined });
const dir = Array.isArray(picked) ? picked[0] : picked;
if (dir) setLocation(dir);
};
const create = async () => {
if (invalid || busy) return;
setBusy(true);
setError(null);
try {
await createProjectFolder(path, git);
} catch (e) {
setBusy(false);
setError(errorText(e));
return;
}
onClose();
await openProjectInWindow(path, useProject.getState().project !== null);
};
return (
<Dialog
open
title="New Project"
onClose={onClose}
width={520}
buttons={[
{ label: "Create", role: "default", disabled: invalid || busy, onClick: () => void create() },
{ label: "Cancel", role: "cancel" },
]}
>
<form
className={styles.newProject}
onSubmit={(e) => {
e.preventDefault();
void create();
}}
>
<label className={styles.newProjectRow}>
<span className={styles.newProjectLabel}>
<T>Name:</T>
</span>
<Input data-autofocus value={name} invalid={!!nameError} onChange={(e) => setName(e.target.value)} aria-label="Name" />
</label>
<div className={styles.newProjectRow}>
<span className={styles.newProjectLabel}>
<T>Location:</T>
</span>
<Input value={location} onChange={(e) => setLocation(e.target.value)} aria-label="Location" />
<Button onClick={() => void browse()} disabled={!isTauri()}>
<T>Browse…</T>
</Button>
</div>
<div className={styles.newProjectHint}>
{nameError ? txTemplate(nameError) : path && txTemplate(`Project will be created in: ${path}`)}
</div>
<Checkbox checked={git} onChange={setGit} label="Create Git repository" />
{error && <div className={styles.newProjectError}>{txTemplate(error)}</div>}
</form>
</Dialog>
);
}
+59 -1
Переглянути файл
@@ -3,7 +3,65 @@ import { registerAction } from "@/platform/actions";
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
import { useKeymap } from "@/platform/keymap";
import { resetDistractionFree, setDistractionFree } from "@/workbench/distractionFree";
import { buildMenuConfig, menuShortcut, routeMenuEvent } from "../menuBridge";
import { registerToolWindow } from "@/workbench/contributions";
import { _reloadLayouts, saveLayout } from "@/workbench/toolwindows/layouts";
import { setContext } from "@/platform/context";
import {
buildMenuConfig,
disabledMenuActions,
dynamicMenuItems,
menuActionIds,
menuShortcut,
routeMenuEvent,
} from "../menuBridge";
describe("menu state from the frontend", () => {
it("lists registered tool windows by title and saved layouts", () => {
localStorage.clear();
_reloadLayouts();
const offs = [
registerToolWindow({ id: "zeta", title: "Terminal", icon: "terminal", anchor: "bottom", component: () => null }),
registerToolWindow({ id: "alpha", title: "Problems", icon: "problems", anchor: "bottom", component: () => null }),
];
saveLayout("Review");
const dyn = dynamicMenuItems();
expect(dyn.toolWindows!.map((i) => [i.action, i.label])).toEqual([
["toolwindow.alpha", "Problems"],
["toolwindow.zeta", "Terminal"],
]);
expect(dyn.toolWindowLayouts).toEqual([{ action: "layout.restore", arg: "Review", label: "Review" }]);
expect(buildMenuConfig("macos").dynamic).toEqual(dyn);
offs.forEach((d) => d());
localStorage.clear();
_reloadLayouts();
});
it("disables the registered actions that cannot run now", () => {
const offs = [
registerAction({ id: "test.menu.always", title: "A", run: () => {} }),
registerAction({ id: "test.menu.whenEditor", title: "B", when: "testMenuEditor", run: () => {} }),
];
const ids = menuActionIds([
{
kind: "submenu",
id: "x",
label: "X",
children: [
{ kind: "action", id: "test.menu.whenEditor", label: "B", hint: null, arg: null },
{ kind: "action", id: "test.menu.always", label: "A", hint: null, arg: null },
],
},
{ kind: "action", id: "not.registered", label: "C", hint: null, arg: null },
]);
expect([...ids].sort()).toEqual(["not.registered", "test.menu.always", "test.menu.whenEditor"]);
// Unregistered ids stay enabled: the action may register later (lazy features).
expect(disabledMenuActions(ids)).toEqual(["test.menu.whenEditor"]);
setContext("testMenuEditor", true);
expect(disabledMenuActions(ids)).toEqual([]);
setContext("testMenuEditor", false);
offs.forEach((d) => d());
});
});
afterEach(() => {
_resetI18nForTests();
+36
Переглянути файл
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { joinPath, projectNameError } from "../NewProject";
import { exportedStorage } from "../projectActions";
describe("File › New Project…", () => {
it("validates the folder name", () => {
expect(projectNameError("demo")).toBeNull();
expect(projectNameError(" ")).toBe("Enter a project name");
expect(projectNameError("a/b")).not.toBeNull();
expect(projectNameError("..")).not.toBeNull();
});
it("joins the location and name with the location's separator", () => {
expect(joinPath("/Users/me/Projects", " demo ")).toBe("/Users/me/Projects/demo");
expect(joinPath("/Users/me/", "demo")).toBe("/Users/me/demo");
expect(joinPath("C:\\Users\\me", "demo")).toBe("C:\\Users\\me\\demo");
});
});
describe("Manage IDE Settings › Export Settings…", () => {
it("exports templates, macros and layouts, not per-project state", () => {
const data: Record<string, string> = {
"openidea.liveTemplates.v1": "[]",
"openidea.macros.v1": "{}",
"openidea.toolWindowLayouts.v1": "{}",
"openidea.runAnything.history.v1:/p": "[]",
};
const keys = Object.keys(data);
const storage = { length: keys.length, key: (i: number) => keys[i] ?? null, getItem: (k: string) => data[k] ?? null };
expect(Object.keys(exportedStorage(storage)).sort()).toEqual([
"openidea.liveTemplates.v1",
"openidea.macros.v1",
"openidea.toolWindowLayouts.v1",
]);
});
});
+34
Переглянути файл
@@ -180,3 +180,37 @@
margin: 4px 0 0;
color: var(--fg-weak-warning);
}
/* File › New Project… */
.newProject {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
}
.newProjectRow {
display: flex;
align-items: center;
gap: 8px;
}
.newProjectRow > :nth-child(2) {
flex: 1;
min-width: 0;
}
.newProjectLabel {
flex: none;
width: 72px;
color: var(--fg);
}
.newProjectHint {
min-height: 1.5em;
padding-left: 80px;
color: var(--fg-subtle);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
.newProjectError {
color: var(--danger);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
+5
Переглянути файл
@@ -11,6 +11,8 @@ import { buildInfo } from "./host";
import { KeymapReferenceOverlay } from "./KeymapReference";
import { installLocaleSync, registerLocalizedActions } from "./locale";
import { installMenuBridge } from "./menuBridge";
import { NewProjectOverlay } from "./NewProject";
import { projectActions, registerPowerSaveBanner } from "./projectActions";
import { installWindowTitle } from "./title";
import { installLaunchHandling, windowActions } from "./windows";
@@ -24,6 +26,9 @@ export function activate(): () => void {
registerOverlay({ id: "app.about", component: AboutOverlay }),
registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }),
registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }),
registerOverlay({ id: "app.newProject", component: NewProjectOverlay }),
registerLocalizedActions(projectActions),
registerPowerSaveBanner(),
registerInvalidateCachesActions(),
installMenuBridge(),
installWindowTitle(),
+75 -6
Переглянути файл
@@ -7,12 +7,15 @@
* shortcut text of every keymap binding, so the menu shows the JS keymap's
* shortcuts without registering native accelerators (see src-tauri/src/menu.rs).
*/
import { executeAction } from "@/platform/actions";
import { keysWithPrefix, t, useI18n } from "@/platform/i18n";
import { commands, isTauri, type MenuConfig } from "@/platform/ipc";
import { executeAction, getAction, isActionEnabled, useActions } from "@/platform/actions";
import { useContextKeys } from "@/platform/context";
import { keysWithPrefix, t, tx, useI18n } from "@/platform/i18n";
import { commands, isTauri, type DynamicItem, type MenuConfig, type MenuNode } from "@/platform/ipc";
import { bindingsFor, effectiveBindings, formatBinding, useKeymap } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { useContributions } from "@/workbench/contributions";
import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree";
import { useToolWindowLayouts } from "@/workbench/toolwindows/layouts";
import { hostOs, type HostOs } from "./host";
export const MENU_EVENT = "openidea://menu";
@@ -48,7 +51,37 @@ export function buildMenuConfig(os: HostOs): MenuConfig {
const first = bindingsFor(id)[0];
if (first) shortcuts[id] = menuShortcut(first, os);
}
return { locale: useI18n.getState().locale, labels, shortcuts };
return { locale: useI18n.getState().locale, labels, shortcuts, dynamic: dynamicMenuItems() };
}
/** View › Tool Windows (every registered window, by title) and Window › Tool Window Layouts. */
export function dynamicMenuItems(): Record<string, DynamicItem[]> {
const toolWindows = [...useContributions.getState().toolWindows.values()]
.map((tw) => ({ action: `toolwindow.${tw.id}`, arg: null, label: tx(tw.title) ?? tw.title }))
.sort((a, b) => a.label.localeCompare(b.label));
const layouts = useToolWindowLayouts
.getState()
.layouts.map((l) => ({ action: "layout.restore", arg: l.name, label: l.name }));
return { toolWindows, toolWindowLayouts: layouts };
}
/** Action ids of every IDE item in a menu model. */
export function menuActionIds(nodes: readonly MenuNode[], out = new Set<string>()): Set<string> {
for (const n of nodes) {
if (n.kind === "submenu") menuActionIds(n.children, out);
else if (n.kind === "action") out.add(n.id);
}
return out;
}
/** The registered ones among `ids` that cannot run in the current context. */
export function disabledMenuActions(ids: Iterable<string>): string[] {
const out: string[] = [];
for (const id of ids) {
const action = getAction(id);
if (action && !isActionEnabled(action)) out.push(id);
}
return out.sort();
}
export function installMenuBridge(): () => void {
@@ -73,9 +106,38 @@ export function installMenuBridge(): () => void {
else unlisten = off;
});
// Enablement of the native items follows this window's context (IDEA greys out
// what cannot run). Recomputed shortly after context changes, sent only when it changed.
let ids: Set<string> | null = null;
let lastDisabled = "";
let enableTimer: ReturnType<typeof setTimeout> | undefined;
const pushEnablement = (force = false) => {
clearTimeout(enableTimer);
enableTimer = setTimeout(() => {
if (!ids || disposed || !document.hasFocus()) return;
const dynamic = Object.values(dynamicMenuItems()).flatMap((items) => items.map((d) => d.action));
const disabled = disabledMenuActions(new Set([...ids, ...dynamic]));
const key = disabled.join(",");
if (!force && key === lastDisabled) return;
lastDisabled = key;
void commands.menuSetDisabled(disabled);
}, 120);
};
void commands
.menuModel()
.then((model) => {
ids = menuActionIds(model);
pushEnablement(true);
})
.catch(() => {});
push();
const onFocus = () => {
push();
pushEnablement(true);
};
// The native menu bar is shared: a focused window re-pushes its own window-scoped labels.
window.addEventListener("focus", push);
window.addEventListener("focus", onFocus);
const unsubs = [
useI18n.subscribe((s, prev) => {
if (s.messages !== prev.messages) push();
@@ -84,6 +146,12 @@ export function installMenuBridge(): () => void {
useDistractionFree.subscribe((s, prev) => {
if (s.active !== prev.active) push();
}),
useContributions.subscribe((s, prev) => {
if (s.toolWindows !== prev.toolWindows) push();
}),
useToolWindowLayouts.subscribe(push),
useContextKeys.subscribe(() => pushEnablement()),
useActions.subscribe(() => pushEnablement()),
// Opening / closing a project changes the Open Recent list.
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) void commands.menuRefresh();
@@ -92,7 +160,8 @@ export function installMenuBridge(): () => void {
return () => {
disposed = true;
clearTimeout(timer);
window.removeEventListener("focus", push);
clearTimeout(enableTimer);
window.removeEventListener("focus", onFocus);
unlisten?.();
unsubs.forEach((u) => u());
};
+182
Переглянути файл
@@ -0,0 +1,182 @@
/**
* File menu actions around the project and the IDE settings: New Project…,
* Rename Project…, Manage IDE Settings › Export / Import Settings… and Power
* Save Mode (with its editor banner).
*/
import { executeAction, type Action } from "@/platform/actions";
import { isPowerSaveMode } from "@/platform/editorSettings";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications, registerEditorBanner } from "@/workbench/contributions";
import { promptInput } from "@/workbench/inputDialogs";
import { showNewProject } from "./NewProject";
/** localStorage prefixes of IDE-level state exported with the settings (templates, macros, layouts). */
export const EXPORTED_STORAGE = [
"openidea.liveTemplates.",
"openidea.fileTemplates.",
"openidea.postfix.",
"openidea.macros.",
"openidea.toolWindowLayouts.",
];
export function exportedStorage(storage: Pick<Storage, "length" | "key" | "getItem"> = localStorage): Record<string, string> {
const out: Record<string, string> = {};
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (!key || !EXPORTED_STORAGE.some((p) => key.startsWith(p))) continue;
const value = storage.getItem(key);
if (value !== null) out[key] = value;
}
return out;
}
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
}
async function exportSettings() {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
title: "Export Settings",
defaultPath: "openidea-settings.json",
filters: [{ name: "OpenIDEA settings", extensions: ["json"] }],
});
if (!path) return;
try {
await call(commands.settingsExport(path, exportedStorage()));
notifications().notify({ level: "info", title: "Settings exported", body: path, key: "app.settingsExport" });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot export settings", body: errorText(e), key: "app.settingsExport" });
}
}
async function importSettings() {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
title: "Import Settings",
multiple: false,
filters: [{ name: "OpenIDEA settings", extensions: ["json"] }],
});
const path = Array.isArray(picked) ? picked[0] : picked;
if (!path) return;
try {
const result = await call(commands.settingsImport(path));
for (const [key, value] of Object.entries(result.storage))
if (EXPORTED_STORAGE.some((p) => key.startsWith(p)))
try {
localStorage.setItem(key, value);
} catch {
/* storage full */
}
useProject.setState({ settings: result.settings });
notifications().notify({
level: "info",
title: "Settings imported",
body: "Restart to apply imported templates, macros and layouts.",
key: "app.settingsImport",
sticky: true,
actions: [{ label: "Restart", run: () => void executeAction("app.restart") }],
});
} catch (e) {
notifications().notify({ level: "error", title: "Cannot import settings", body: errorText(e), key: "app.settingsImport" });
}
}
async function renameProject() {
const project = useProject.getState().project;
if (!project) return;
const folder = project.root.slice(project.root.replace(/[\\/]+$/, "").search(/[^\\/]*$/));
const name = await promptInput({
title: "Rename Project",
label: "New project name:",
initial: project.name,
okLabel: "Rename",
validate: () => null,
hint: `Leave empty to use the folder name (${folder}).`,
});
if (name === null) return;
try {
const info = await call(commands.projectRename(name));
useProject.setState((s) => ({ project: s.project && { ...s.project, name: info.name }, settings: info.settings }));
void commands.menuRefresh();
} catch (e) {
notifications().notify({ level: "error", title: "Cannot rename project", body: errorText(e) });
}
}
async function togglePowerSave() {
const on = !isPowerSaveMode();
try {
useProject.setState({
settings: await call(commands.settingsSet("global", "general.powerSaveMode", JSON.stringify(on))),
});
} catch (e) {
notifications().notify({ level: "error", title: "Cannot change Power Save Mode", body: errorText(e) });
}
}
const desktop = (run: () => Promise<void>) => () => {
if (isTauri()) return run();
notifications().notify({ level: "warning", title: "This needs the desktop app" });
};
export function projectActions(): Action[] {
return [
{
id: "file.newProject",
title: "New Project…",
category: "File",
icon: "folder-open",
keywords: ["create project", "empty project", "git init"],
run: desktop(async () => showNewProject()),
},
{
id: "file.renameProject",
title: "Rename Project…",
category: "File",
keywords: ["project name"],
when: "projectOpen",
run: desktop(renameProject),
},
{
id: "file.exportSettings",
title: "Export Settings…",
category: "File",
keywords: ["backup settings", "keymap", "templates", "manage ide settings"],
run: desktop(exportSettings),
},
{
id: "file.importSettings",
title: "Import Settings…",
category: "File",
keywords: ["restore settings", "keymap", "templates", "manage ide settings"],
run: desktop(importSettings),
},
{
id: "file.togglePowerSave",
title: "Power Save Mode",
category: "File",
keywords: ["battery", "performance", "code insight"],
toggled: () => isPowerSaveMode(),
run: togglePowerSave,
},
];
}
/** The banner above every editor while Power Save Mode is on. */
export function registerPowerSaveBanner(): () => void {
return registerEditorBanner({
id: "app.powerSave",
order: 0,
useBanner: () => {
const on = useProject((s) => isPowerSaveMode(s.settings));
return on
? {
text: "Power save mode is on: completion and documentation popups and usage highlighting are off.",
actions: [{ label: "Turn Off", run: () => void togglePowerSave() }],
}
: null;
},
});
}
+4 -24
Переглянути файл
@@ -8,16 +8,9 @@ import { useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import type { BuildTool } from "@/platform/ipc";
import { HighlightedText, Input, List, Popup, Select, listNavigate, T } from "@/ui";
import {
goalCompletions,
gradleTaskConfig,
mavenGoalConfig,
parseGoalLine,
parseTaskLine,
taskCompletions,
type Completion,
} from "./model";
import { pushHistory, readHistory, runConfig, useBuild } from "./store";
import { goalCompletions, taskCompletions, type Completion } from "./model";
import { executeBuildLine } from "./runAnything";
import { readHistory, useBuild } from "./store";
import styles from "./BuildTools.module.css";
export function ExecutePopupHost() {
@@ -58,20 +51,7 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
const execute = () => {
const text = line.trim();
if (!text) return;
if (tool === "maven" && maven) {
const p = maven.projects.find((x) => x.pom === project);
const goals = parseGoalLine(text);
if (!p || !goals.length) return;
runConfig(mavenGoalConfig(p, goals, maven, useBuild.getState().settings.maven), "run");
} else if (gradle) {
const p = gradle.projects.find((x) => x.path === project);
const tasks = parseTaskLine(text);
if (!p || !tasks.length) return;
runConfig(gradleTaskConfig(gradle, p, tasks, useBuild.getState().settings.gradle), "run");
}
pushHistory(tool, text);
close();
if (text && executeBuildLine(tool, text, project)) close();
};
const accept = (c: Completion | undefined) => {
+3 -1
Переглянути файл
@@ -7,9 +7,10 @@ import { registerActions } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { onIdeEvent, type BuildModel, type BuildTool } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { registerOverlay, registerToolWindow } from "@/workbench/contributions";
import { registerOverlay, registerRunAnythingProvider, registerToolWindow } from "@/workbench/contributions";
import { GradleView, MavenView } from "./BuildToolView";
import { ExecutePopupHost } from "./ExecutePopup";
import { buildToolsRunAnything } from "./runAnything";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model";
import {
cancelSync,
@@ -83,6 +84,7 @@ export function activate(): () => void {
};
const disposers: Array<() => void> = [
registerOverlay({ id: "build-tools.execute", component: ExecutePopupHost }),
registerRunAnythingProvider(buildToolsRunAnything),
useBuild.subscribe((s, prev) => {
if (s.model !== prev.model || s.root !== prev.root) apply(s.root ? s.model : null, registered);
}),
+97
Переглянути файл
@@ -0,0 +1,97 @@
/**
* Maven / Gradle command lines: what Execute Maven Goal / Execute Gradle Task
* run, and the Run Anything provider for `mvn …` / `gradle …` lines (with goal
* and task completion in the selected project).
*/
import type { BuildTool } from "@/platform/ipc";
import type { RunAnythingItem, RunAnythingProvider } from "@/workbench/contributions";
import {
goalCompletions,
gradleTaskConfig,
mavenGoalConfig,
parseGoalLine,
parseTaskLine,
taskCompletions,
} from "./model";
import { pushHistory, readHistory, runConfig, useBuild } from "./store";
const COMMANDS: Record<BuildTool, RegExp> = {
maven: /^(\.\/)?mvnw?(\.cmd)?(\s|$)/,
gradle: /^(\.\/)?gradlew?(\.bat)?(\s|$)/,
};
const WORD: Record<BuildTool, string> = { maven: "mvn", gradle: "gradle" };
/** The build tool a Run Anything line is for (`mvn clean`, `./gradlew build`), when the project has it. */
export function lineTool(line: string): BuildTool | null {
const model = useBuild.getState().model;
if (model?.maven && COMMANDS.maven.test(line)) return "maven";
if (model?.gradle && COMMANDS.gradle.test(line)) return "gradle";
return null;
}
/** The project a line runs in: the given one, else the tool window's current one, else the first. */
function projectFor(tool: BuildTool, key?: string | null) {
const { model, current } = useBuild.getState();
if (tool === "maven") {
const projects = model?.maven?.projects ?? [];
return projects.find((p) => p.pom === (key ?? current.maven)) ?? projects[0];
}
const projects = model?.gradle?.projects ?? [];
return projects.find((p) => p.path === (key ?? current.gradle)) ?? projects[0];
}
/** Runs goals / tasks typed for `tool` in a project; false when there is nothing to run. */
export function executeBuildLine(tool: BuildTool, text: string, projectKey?: string | null): boolean {
const { model, settings } = useBuild.getState();
if (tool === "maven") {
const maven = model?.maven;
const project = projectFor("maven", projectKey);
const goals = parseGoalLine(text);
if (!maven || !project || !("pom" in project) || !goals.length) return false;
runConfig(mavenGoalConfig(project, goals, maven, settings.maven), "run");
} else {
const gradle = model?.gradle;
const project = projectFor("gradle", projectKey);
const tasks = parseTaskLine(text);
if (!gradle || !project || "pom" in project || !tasks.length) return false;
runConfig(gradleTaskConfig(gradle, project, tasks, settings.gradle), "run");
}
pushHistory(tool, text.replace(COMMANDS[tool], "").trim());
return true;
}
function completions(tool: BuildTool, rest: string) {
const model = useBuild.getState().model;
if (tool === "maven") {
const project = projectFor("maven");
return goalCompletions(rest, project && "pom" in project ? project.plugins : [], readHistory("maven"));
}
return model?.gradle ? taskCompletions(rest, model.gradle, readHistory("gradle")) : [];
}
export const buildToolsRunAnything: RunAnythingProvider = {
id: "build-tools",
group: "Build Tools",
order: 20,
items(query) {
const tool = lineTool(query);
if (!tool) return [];
const head = /^\S+\s*/.exec(query)![0];
return completions(tool, query.slice(head.length))
.slice(0, 50)
.map(
(c): RunAnythingItem => ({
key: `${c.detail === "recent" ? "h" : "c"}:${c.line}`,
label: `${WORD[tool]} ${c.line}`,
detail: c.detail,
icon: tool === "maven" ? "file-maven" : "file-gradle",
completion: `${WORD[tool]} ${c.detail === "recent" ? c.line : `${c.line} `}`,
run: () => void executeBuildLine(tool, c.line),
}),
);
},
execute(line) {
const tool = lineTool(line);
return !!tool && executeBuildLine(tool, line);
},
};
+3 -2
Переглянути файл
@@ -35,7 +35,7 @@ export function activate(): () => void {
headerActions: BuildHeaderActions,
useBadge: useErrorBadge,
}),
registerTitleItem({ id: "build.project", align: "right", priority: -1, component: BuildTitleItem }),
registerTitleItem({ id: "build.project", title: "Build Project", align: "right", priority: -1, component: BuildTitleItem }),
registerActions([
{
id: "build.buildProject",
@@ -45,7 +45,8 @@ export function activate(): () => void {
icon: "build-project",
when: WHEN,
keywords: ["compile", "make", "mvn", "gradle", "cargo", "tsc", "go build"],
run: () => void startBuild("build"),
// Awaitable: Run's "Build" before launch task waits for the result.
run: async () => void (await startBuild("build")),
},
{
id: "build.buildModule",
+3
Переглянути файл
@@ -30,6 +30,8 @@ import { appendOutput, finishSummary, firstError, messageFiles, type BuildStatus
export const BUILD_TASK_ID = "build:compile";
export const BUILD_RUNNING = "build.running";
/** Outcome of the last build (`success` | `failed` | `cancelled`), e.g. for Run's "Build" before launch task. */
export const BUILD_LAST_STATUS = "build.lastStatus";
/** How builds reach the backend; replaced in tests. */
export interface BuildBackend {
@@ -205,6 +207,7 @@ export async function startBuild(kind: BuildKind, file: string | null = null): P
}
} finally {
if (useBuildRun.getState().request === request) useBuildRun.setState({ request: null });
setContext(BUILD_LAST_STATUS, result?.success ? "success" : useBuildRun.getState().status === "cancelled" ? "cancelled" : "failed");
setContext(BUILD_RUNNING, false);
upsertTask({ id: BUILD_TASK_ID, title: taskTitle, message: null, fraction: 1, done: true });
}
+1 -1
Переглянути файл
@@ -225,7 +225,7 @@ export function UsagesView() {
return (
<div className={styles.view}>
<div className={styles.toolbar} role="toolbar" aria-orientation="vertical" aria-label="Usages actions">
<div className={styles.toolbar} role="toolbar" aria-orientation="vertical" aria-label={tx("Usages actions")}>
<IconButton
icon="rerun"
title={tx("Rerun Find Usages")}
+12
Переглянути файл
@@ -6,6 +6,7 @@ import {
countSeverities,
fileDiagnostics,
mergeDiagnostics,
profiledProblems,
projectCounts,
useProblems,
worstSeverity,
@@ -69,3 +70,14 @@ describe("store + decorations", () => {
expect(deco.get("/p/src/a.rs")).toEqual({ error: true, propagate: true, tooltip: "1 error, 1 warning" });
});
});
describe("profiledProblems", () => {
it("applies the inspection profile per producer", () => {
let raw = mergeDiagnostics(new Map(), "/a", "ra", [d(1), d(2, "warning")]);
raw = mergeDiagnostics(raw, "/b", "eslint", [d(3)]);
const out = profiledProblems(raw, { "lsp:eslint": { enabled: false }, "lsp:ra": { severity: "weakWarning" } });
expect([...out.keys()]).toEqual(["/a"]);
expect(fileDiagnostics(out, "/a").map((x) => x.severity)).toEqual(["info", "info"]);
expect(fileDiagnostics(profiledProblems(raw, {}), "/b")).toHaveLength(1);
});
});
+34 -5
Переглянути файл
@@ -6,6 +6,12 @@
import { create } from "zustand";
import type { Diagnostic, Severity } from "@/bindings";
import { onIdeEvent } from "@/platform/ipc";
import {
applyProfile,
inspectionProfile,
subscribeInspectionProfile,
type ProfileOverrides,
} from "@/platform/inspections";
export type FileDiagnostics = ReadonlyMap<string, readonly Diagnostic[]>;
/** path → source → diagnostics. */
@@ -89,23 +95,46 @@ export function worstSeverity(diags: Iterable<Diagnostic>): Severity | null {
return best;
}
/** `raw` under the inspection profile: disabled inspections dropped, severities remapped. */
export function profiledProblems(raw: ProblemsMap, profile: ProfileOverrides): ProblemsMap {
let out: ProblemsMap = new Map();
for (const [path, sources] of raw) {
for (const [source, items] of sources) {
out = mergeDiagnostics(out, path, source, applyProfile(profile, source, items));
}
}
return out;
}
interface ProblemsState {
/** As reported, before the inspection profile. */
raw: ProblemsMap;
/** What the views show. */
map: ProblemsMap;
}
export const useProblems = create<ProblemsState>(() => ({ map: new Map() }));
export const useProblems = create<ProblemsState>(() => ({ raw: new Map(), map: new Map() }));
export function applyDiagnosticsEvent(path: string, source: string, items: readonly Diagnostic[]) {
useProblems.setState((s) => {
const map = mergeDiagnostics(s.map, path, source, items);
return map === s.map ? s : { map };
const raw = mergeDiagnostics(s.raw, path, source, items);
if (raw === s.raw) return s;
const map = mergeDiagnostics(s.map, path, source, applyProfile(inspectionProfile(), source, items));
return { raw, map };
});
}
export function clearProblems() {
useProblems.setState({ map: new Map() });
useProblems.setState({ raw: new Map(), map: new Map() });
}
export function installProblemsEvents(): () => void {
return onIdeEvent("diagnostics", (e) => applyDiagnosticsEvent(e.path, e.source, e.items));
const off = onIdeEvent("diagnostics", (e) => applyDiagnosticsEvent(e.path, e.source, e.items));
const offProfile = subscribeInspectionProfile(() =>
useProblems.setState((s) => ({ map: profiledProblems(s.raw, inspectionProfile()) })),
);
return () => {
off();
offProfile();
};
}
+39
Переглянути файл
@@ -108,3 +108,42 @@
.link:hover {
color: var(--link-hover);
}
.links {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
/* Manage Coverage Reports */
.reports {
height: 260px;
margin: 12px;
border: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.reportRow {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
white-space: nowrap;
}
.reportDetail {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-subtle);
}
.reportTime {
flex: none;
font-size: var(--fs-small);
color: var(--fg-muted);
}
.reportsEmpty {
padding: 12px;
color: var(--fg-muted);
}
+19 -10
Переглянути файл
@@ -35,13 +35,17 @@ export function CoverageHeaderActions() {
const visible = useCoverage((s) => s.visible);
const hasSuite = useCoverage((s) => !!s.activeId);
return (
<IconButton
icon={visible ? "close" : "eye"}
size="small"
title={visible ? "Hide Coverage" : "Show Coverage"}
disabled={!hasSuite}
onClick={() => setCoverageVisible(!visible)}
/>
<>
<IconButton icon="folder-open" size="small" action="coverage.importReport" />
<IconButton icon="list" size="small" action="coverage.manageReports" />
<IconButton
icon={visible ? "close" : "eye"}
size="small"
title={visible ? "Hide Coverage" : "Show Coverage"}
disabled={!hasSuite}
onClick={() => setCoverageVisible(!visible)}
/>
</>
);
}
@@ -87,9 +91,14 @@ export function CoverageView() {
<span>
<T>Run a configuration with coverage to see which lines its tests execute.</T>
</span>
<button type="button" className={styles.link} onClick={() => void executeAction("run.coverage")}>
<T>Run with Coverage</T>
</button>
<span className={styles.links}>
<button type="button" className={styles.link} onClick={() => void executeAction("run.coverage")}>
<T>Run with Coverage</T>
</button>
<button type="button" className={styles.link} onClick={() => void executeAction("coverage.importReport")}>
<T>Import a report collected in CI from disk</T>
</button>
</span>
</EmptyState>
);
}
+21 -1
Переглянути файл
@@ -3,7 +3,7 @@ import { EditorState } from "@codemirror/state";
import type { CoverageSuite, FileCoverage, RunConfig } from "@/platform/ipc";
import { coverageMarkers } from "./gutter";
import { coverageBadges, coverageSupports, coverageTree, formatPercent, lineState, sortTree } from "./model";
import { MAX_SUITES, addSuite, closeSuite, resetCoverage, setCoverageVisible, shownFileCoverage, useCoverage } from "./store";
import { MAX_SUITES, addSuite, closeSuite, reportEntries, resetCoverage, setCoverageVisible, shownFileCoverage, useCoverage } from "./store";
function cfg(command: string, args: string[], extra: Partial<RunConfig> = {}): RunConfig {
return { id: "c", name: "c", kind: "shell", command, args, cwd: null, env: {}, detected: true, isTest: false, debugAdapter: null, ...extra };
@@ -115,4 +115,24 @@ describe("gutter and suites", () => {
expect(useCoverage.getState().activeId).toBe(s.suites[1]!.id);
resetCoverage();
});
it("replaces a re-imported report and lists remembered imports in Manage Coverage Reports", () => {
addSuite("tests", suite([file("/p/a.rs", 1, 2)]));
const first = addSuite("lcov.info", suite([file("/p/a.rs", 1, 2)]), "/ci/lcov.info");
const again = addSuite("lcov.info", suite([file("/p/a.rs", 2, 2)]), "/ci/lcov.info");
const { suites } = useCoverage.getState();
expect(suites.map((s) => s.id)).toEqual([again.id, suites[1]!.id]);
expect(suites.some((s) => s.id === first.id)).toBe(false);
expect(shownFileCoverage("/p/a.rs")?.linesCovered).toBe(2);
const entries = reportEntries(suites, [
{ path: "/ci/lcov.info", name: "lcov.info", importedAt: 1 },
{ path: "/ci/jacoco.xml", name: "jacoco.xml", importedAt: 2 },
]);
expect(entries.map((e) => [e.name, e.detail, !!e.suite])).toEqual([
["lcov.info", "/ci/lcov.info", true],
["tests", "t", true],
["jacoco.xml", "/ci/jacoco.xml", false],
]);
resetCoverage();
});
});
+23 -1
Переглянути файл
@@ -6,10 +6,11 @@
import { registerEditorContribution } from "@/editor/api";
import { executeAction, registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerRunExecutor, registerToolWindow, toolWindowService } from "@/workbench/contributions";
import { registerOverlay, registerRunExecutor, registerToolWindow, toolWindowService } from "@/workbench/contributions";
import { CoverageHeaderActions, CoverageTabs, CoverageView } from "./CoverageView";
import { coverageExecutor } from "./executor";
import { coverageGutter } from "./gutter";
import { ManageReportsOverlay, importReport, openManageReports } from "./reports";
import { resetCoverage, setCoverageVisible, syncCoverageViews, useCoverage } from "./store";
export function activate(): () => void {
@@ -28,6 +29,7 @@ export function activate(): () => void {
}),
registerEditorContribution({ id: "coverage.gutter", order: 65, extensions: coverageGutter }),
syncCoverageViews(),
registerOverlay({ id: "coverage.manageReports", component: ManageReportsOverlay }),
registerActions([
{
id: "run.coverage",
@@ -49,6 +51,26 @@ export function activate(): () => void {
keywords: ["coverage"],
run: ({ arg }) => executeAction("run.withExecutor", { executor: "coverage", context: arg ?? true }).then(() => undefined),
},
{
id: "coverage.importReport",
title: "Import a Coverage Report…",
description: "Show a report collected elsewhere (CI): lcov, JaCoCo XML or a Go cover profile; argument: the file path",
category: "Run",
icon: "folder-open",
when: "projectOpen",
keywords: ["coverage", "lcov", "jacoco", "ci", "import"],
run: ({ arg }) => importReport(typeof arg === "string" ? arg : undefined).then(() => undefined),
},
{
id: "coverage.manageReports",
title: "Manage Coverage Reports…",
description: "Choose the shown coverage suite, delete suites and imported reports",
category: "Run",
icon: "list",
when: "projectOpen",
keywords: ["coverage", "suites", "show coverage data"],
run: () => openManageReports(),
},
{
id: "coverage.hide",
title: "Hide Coverage",
+165
Переглянути файл
@@ -0,0 +1,165 @@
/**
* Coverage reports collected elsewhere: "Import a report collected in CI from
* disk" (lcov, JaCoCo XML, Go cover profile) and "Manage Coverage Reports"
* (choose the shown suite, delete suites and remembered imports).
*/
import { useMemo, useState } from "react";
import { create } from "zustand";
import { call, commands, isTauri } from "@/platform/ipc";
import { basename } from "@/platform/project";
import { Dialog, Icon, List, T } from "@/ui";
import { notifications, toolWindowService } from "@/workbench/contributions";
import { formatPercent } from "./model";
import {
addSuite,
closeSuite,
forgetImport,
importedReports,
rememberImport,
reportEntries,
selectSuite,
useCoverage,
type ReportEntry,
type Suite,
} from "./store";
import styles from "./Coverage.module.css";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
async function pickReport(): Promise<string | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({
multiple: false,
directory: false,
title: "Import Coverage Report",
filters: [
{
name: "Coverage reports (lcov, JaCoCo XML, Go cover profile)",
extensions: ["info", "lcov", "xml", "out", "txt", "cov", "coverprofile"],
},
{ name: "All files", extensions: ["*"] },
],
});
return typeof file === "string" ? file : null;
}
/** Imports `path` (asks for a file without one) and shows it like a run's suite. */
export async function importReport(path?: string): Promise<Suite | null> {
if (!isTauri()) {
notifications().notify({ group: "run", level: "warning", title: "Importing coverage needs the desktop app" });
return null;
}
const file = path ?? (await pickReport());
if (!file) return null;
try {
const data = await call(commands.coverageImport(file));
const suite = addSuite(basename(file), data, file);
rememberImport({ path: file, name: suite.name, importedAt: suite.createdAt });
try {
toolWindowService().show("coverage");
} catch {
/* no workbench (tests) */
}
const [covered, total] = data.files.reduce((a, f) => [a[0]! + f.linesCovered, a[1]! + f.linesTotal], [0, 0]);
notifications().notify({
group: "run",
level: "info",
key: "coverage-import",
title: `Imported coverage '${suite.name}': ${formatPercent(covered!, total!)} lines`,
body: data.files.length ? undefined : "The report covers no files of this project.",
});
return suite;
} catch (e) {
notifications().notify({
group: "run",
level: "error",
key: "coverage-import",
title: `Cannot import ${basename(file)}`,
body: errorText(e),
});
return null;
}
}
const useManageDialog = create<{ open: boolean }>(() => ({ open: false }));
export const openManageReports = () => useManageDialog.setState({ open: true });
export function ManageReportsOverlay() {
const open = useManageDialog((s) => s.open);
return open ? <ManageReportsDialog onClose={() => useManageDialog.setState({ open: false })} /> : null;
}
function ManageReportsDialog({ onClose }: { onClose: () => void }) {
const suites = useCoverage((s) => s.suites);
const activeId = useCoverage((s) => s.activeId);
const [imported, setImported] = useState(() => importedReports());
const entries = useMemo(() => reportEntries(suites, imported), [suites, imported]);
const [active, setActive] = useState(() =>
Math.max(
0,
entries.findIndex((e) => e.suite?.id === activeId),
),
);
const current: ReportEntry | undefined = entries[Math.min(active, entries.length - 1)];
const show = async (entry: ReportEntry | undefined) => {
if (!entry) return;
if (entry.suite) selectSuite(entry.suite.id);
else if (entry.file && !(await importReport(entry.file))) return;
onClose();
};
const remove = (entry: ReportEntry | undefined) => {
if (!entry) return;
if (entry.suite) closeSuite(entry.suite.id);
if (entry.file) {
forgetImport(entry.file);
setImported(importedReports());
}
};
const importMore = async () => {
if (await importReport()) setImported(importedReports());
};
return (
<Dialog
open
title="Coverage Reports"
onClose={onClose}
width={560}
buttons={[
{ label: "Import…", role: "extra", onClick: () => void importMore() },
{ label: "Delete", role: "extra", disabled: !current, onClick: () => remove(current) },
{ label: "Show Selected", role: "default", disabled: !current, onClick: () => void show(current) },
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.reports}>
<List<ReportEntry>
aria-label="Coverage reports"
items={entries}
getKey={(e) => e.key}
activeIndex={active}
onActiveChange={setActive}
onActivate={(e) => void show(e)}
renderItem={(e) => (
<span className={styles.reportRow}>
<Icon name={e.file ? "folder-open" : "run-coverage"} size={16} className={styles.icon} />
<span className={styles.label}>{e.name}</span>
<span className={styles.reportDetail}>{e.detail}</span>
<span className={styles.reportTime}>
{e.suite?.id === activeId ? <T>shown</T> : e.suite ? null : <T>not loaded</T>}{" "}
{new Date(e.time).toLocaleString()}
</span>
</span>
)}
empty={
<div className={styles.reportsEmpty}>
<T>No coverage reports. Run with coverage or import a report.</T>
</div>
}
/>
</div>
</Dialog>
);
}
+83 -3
Переглянути файл
@@ -14,9 +14,11 @@ export const MAX_SUITES = 5;
export interface Suite {
id: string;
/** Run configuration name. */
/** Run configuration name, or the imported report's file name. */
name: string;
createdAt: number;
/** The report file of an imported suite ("Import a report collected in CI"). */
file?: string;
data: CoverageSuite;
byPath: Map<string, FileCoverage>;
}
@@ -43,15 +45,21 @@ export function shownFileCoverage(path: string, s: CoverageState = useCoverage.g
return activeSuite(s)?.byPath.get(path) ?? null;
}
export function addSuite(name: string, data: CoverageSuite): Suite {
export function addSuite(name: string, data: CoverageSuite, file?: string): Suite {
const suite: Suite = {
id: `suite${++seq}`,
name,
createdAt: Date.now(),
file,
data,
byPath: new Map(data.files.map((f) => [f.path, f])),
};
useCoverage.setState((s) => ({ suites: [suite, ...s.suites].slice(0, MAX_SUITES), activeId: suite.id, visible: true }));
useCoverage.setState((s) => ({
// A re-imported file replaces its earlier suite.
suites: [suite, ...s.suites.filter((x) => !file || x.file !== file)].slice(0, MAX_SUITES),
activeId: suite.id,
visible: true,
}));
return suite;
}
@@ -97,3 +105,75 @@ export function syncCoverageViews(): () => void {
setFileDecorations("coverage", new Map());
};
}
// ---------------------------------------------------------------------------
// Imported reports, remembered per project so "Manage Coverage Reports" lists
// them after a restart.
export interface ImportedReport {
path: string;
name: string;
importedAt: number;
}
const IMPORTED_KEY = "openidea.coverage.imported.v1:";
const MAX_IMPORTED = 20;
export function importedReports(root: string | null = useProject.getState().project?.root ?? null): ImportedReport[] {
if (!root) return [];
try {
const v: unknown = JSON.parse(globalThis.localStorage?.getItem(IMPORTED_KEY + root) ?? "[]");
if (!Array.isArray(v)) return [];
return v.filter(
(r): r is ImportedReport =>
!!r && typeof r.path === "string" && typeof r.name === "string" && typeof r.importedAt === "number",
);
} catch {
return [];
}
}
function saveImported(root: string, reports: ImportedReport[]) {
try {
globalThis.localStorage?.setItem(IMPORTED_KEY + root, JSON.stringify(reports.slice(0, MAX_IMPORTED)));
} catch {
/* storage unavailable */
}
}
export function rememberImport(report: ImportedReport) {
const root = useProject.getState().project?.root;
if (root) saveImported(root, [report, ...importedReports(root).filter((r) => r.path !== report.path)]);
}
export function forgetImport(path: string) {
const root = useProject.getState().project?.root;
if (root) saveImported(root, importedReports(root).filter((r) => r.path !== path));
}
/** A row of "Manage Coverage Reports": a loaded suite, or an imported report not loaded now. */
export interface ReportEntry {
key: string;
name: string;
/** Tool or file shown beside the name. */
detail: string;
time: number;
suite: Suite | null;
file: string | null;
}
export function reportEntries(suites: readonly Suite[], imported: readonly ImportedReport[]): ReportEntry[] {
const out: ReportEntry[] = suites.map((s) => ({
key: s.id,
name: s.name,
detail: s.file ?? s.data.runner,
time: s.createdAt,
suite: s,
file: s.file ?? null,
}));
for (const r of imported) {
if (suites.some((s) => s.file === r.path)) continue;
out.push({ key: `file:${r.path}`, name: r.name, detail: r.path, time: r.importedAt, suite: null, file: r.path });
}
return out;
}
+129
Переглянути файл
@@ -245,6 +245,13 @@
gap: 6px;
min-width: 0;
}
.tinted {
flex: 1;
margin: 0 -4px;
padding: 0 4px;
border-radius: var(--radius-control, 4px);
background: color-mix(in srgb, var(--db-tint) var(--tint-strength), transparent);
}
.nodeLabel {
overflow: hidden;
text-overflow: ellipsis;
@@ -334,6 +341,128 @@
gap: 8px;
align-items: center;
}
/* Data source dialog. */
.formSection {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
color: var(--fg-strong);
}
.formSection::after {
content: "";
flex: 1;
height: 1px;
background: var(--border);
}
.formWide {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.portInput {
width: 80px;
}
.secondsInput {
width: 72px;
}
.narrowSelect {
min-width: 140px;
}
.segmented {
display: inline-flex;
justify-self: start;
border-radius: var(--radius-control, 4px);
box-shadow: inset 0 0 0 1px var(--border-button);
}
.segmented button {
height: var(--h-field, 28px);
padding: 0 14px;
border: 0;
border-radius: inherit;
background: transparent;
color: var(--fg);
font: inherit;
}
.segmented button[aria-pressed="true"] {
background: var(--bg-tab-selected);
box-shadow: inset 0 0 0 1px var(--border-tab-selected);
color: var(--fg-strong);
}
.segmented button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}
.textArea {
width: 100%;
min-height: 64px;
resize: vertical;
padding: 6px 8px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control, 4px);
background: var(--bg-input);
color: var(--fg-editor, var(--fg-strong));
font-family: var(--font-mono);
font-size: var(--fs-code);
line-height: 1.5;
}
.textArea:focus {
outline: none;
border-color: var(--accent);
}
.linkButton {
justify-self: start;
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.linkButton:hover {
text-decoration: underline;
}
.colorValue {
display: inline-flex;
align-items: center;
gap: 6px;
}
.colorSwatch {
width: 12px;
height: 12px;
border-radius: 3px;
}
.namePicker {
display: flex;
flex-direction: column;
gap: 6px;
}
.nameList {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow: auto;
padding-left: 22px;
}
.propertyTable {
display: flex;
flex-direction: column;
gap: 4px;
}
.propertyHead,
.propertyRow {
display: grid;
grid-template-columns: 1fr 1fr 24px;
align-items: center;
gap: 6px;
}
.propertyHead {
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.testResult {
grid-column: 1 / -1;
font-size: var(--fs-small);
+28 -10
Переглянути файл
@@ -4,8 +4,8 @@
* with counts and column types, loaded lazily. Double-click a table to open its data, a data
* source to open its query console.
*/
import { useEffect, useMemo, useRef } from "react";
import type { DbTableDetails } from "@/platform/ipc";
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import type { DbDatabases, DbTableDetails } from "@/platform/ipc";
import { setContext } from "@/platform/context";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
@@ -21,7 +21,7 @@ import {
type TreeHandle,
T,
} from "@/ui";
import { databaseKey, driverIcon, splitTarget, targetOf } from "./model";
import { colorVar, databaseKey, shownNames, sourceIcon, splitTarget, targetOf } from "./model";
import { nodeTarget, type DbNode as Node, type Group } from "./nodes";
import {
connect,
@@ -105,9 +105,20 @@ export function DatabaseView() {
return d?.status === "ok" ? d.value : null;
};
const sourceOf = (target: string) => st.sources.find((s) => s.id === splitTarget(target).id);
/** The Schemas tab's choice. */
const shownSchemas = (target: string, names: string[]) => {
const i = sourceOf(target)?.introspection;
return shownNames(names, i?.allSchemas, i?.schemas);
};
const shownDatabases = (id: string, v: DbDatabases) => {
const i = sourceOf(id)?.introspection;
return shownNames(v.names, i?.allDatabases, i?.databases, v.current);
};
const schemasOf = (key: string, id: string) =>
status(key, st.schemas[id], (v) =>
v.map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
shownSchemas(id, v).map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
);
const children = (n: Node): readonly Node[] | undefined => {
@@ -116,7 +127,7 @@ export function DatabaseView() {
const id = n.source.id ?? "";
if (listsDatabases(n.source))
return status(n.key, st.databases[id], (v) =>
v.names.map((name) => ({
shownDatabases(id, v).map((name) => ({
kind: "database",
key: databaseKey(id, name),
id: targetOf(id, name, v.current),
@@ -226,14 +237,14 @@ export function DatabaseView() {
const id = n.source.id ?? "";
if (listsDatabases(n.source)) {
const d = st.databases[id];
return d?.status === "ok" ? String(d.value.names.length) : undefined;
return d?.status === "ok" ? String(shownDatabases(id, d.value).length) : undefined;
}
const s = st.schemas[id];
return s?.status === "ok" ? String(s.value.length) : undefined;
return s?.status === "ok" ? String(shownSchemas(id, s.value).length) : undefined;
}
case "database": {
const s = st.schemas[n.id];
return s?.status === "ok" ? String(s.value.length) : undefined;
return s?.status === "ok" ? String(shownSchemas(n.id, s.value).length) : undefined;
}
case "folder":
return String(
@@ -259,7 +270,7 @@ export function DatabaseView() {
const icon = (n: Node): string | undefined => {
switch (n.kind) {
case "source":
return driverIcon(n.source.driver);
return sourceIcon(n.source);
case "database":
return "database";
case "schema":
@@ -334,8 +345,15 @@ export function DatabaseView() {
? styles.keyLabel
: styles.nodeLabel;
const live = n.kind === "source" && [...st.connected].some((t) => splitTarget(t).id === n.source.id);
const target = nodeTarget(n);
// IDEA tints every row of a coloured data source.
const tint = colorVar(target ? sourceOf(target)?.color : null);
return (
<span className={styles.nodeRow}>
<span
className={tint ? `${styles.nodeRow} ${styles.tinted}` : styles.nodeRow}
style={tint ? ({ "--db-tint": tint } as CSSProperties) : undefined}
title={n.kind === "source" && n.source.comment ? n.source.comment : undefined}
>
{i && (
<span className={styles.nodeIcon}>
<Icon name={i} size={16} />
+5 -395
Переглянути файл
@@ -1,404 +1,14 @@
/** Data source (add / edit / test connection) and password dialogs. */
import { Fragment, useState } from "react";
import {
call,
commands,
IpcError,
type DataSource,
type DbDriver,
type DbSsh,
type DbSshAuth,
type DbSslMode,
} from "@/platform/ipc";
import { Button, Checkbox, Dialog, Input, Select, Tabs, lazyComponent, T } from "@/ui";
import { connectRemedy, fileSourceName, newSourceId, sshLabel } from "./model";
import { closeDialog, saveSource, useDatabase } from "./store";
import { call, commands, type DataSource } from "@/platform/ipc";
import { Checkbox, Dialog, Input, lazyComponent, T } from "@/ui";
import { sshLabel } from "./model";
import { SourceDialog } from "./SourceDialog";
import { closeDialog, useDatabase } from "./store";
import styles from "./Database.module.css";
const ObjectDialogs = lazyComponent(() => import("./ObjectDialogs").then((m) => m.ObjectDialogs));
const DRIVERS: Array<{ value: DbDriver; label: string }> = [
{ value: "postgres", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL / MariaDB" },
{ value: "sqlite", label: "SQLite" },
];
const SSL: Array<{ value: DbSslMode; label: string }> = [
{ value: "prefer", label: "Prefer" },
{ value: "require", label: "Require" },
{ value: "disable", label: "Disable" },
];
const SSH_AUTH: Array<{ value: DbSshAuth; label: string }> = [
{ value: "openSsh", label: "OpenSSH config and authentication agent" },
{ value: "password", label: "Password" },
{ value: "keyPair", label: "Key pair" },
];
const blankSsh = (): DbSsh => ({
enabled: false,
host: "",
port: null,
user: "",
auth: "openSsh",
keyFile: "",
saveSecret: true,
});
const parsePort = (v: string) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 && n < 65536 ? n : null;
};
function blank(): DataSource {
return {
id: newSourceId(),
name: "",
driver: "postgres",
host: "localhost",
port: null,
database: "",
user: "",
file: null,
ssl: "prefer",
savePassword: true,
readOnly: false,
};
}
function SourceDialog({
initial,
preset,
presetPassword,
}: {
initial: DataSource | null;
preset?: Partial<DataSource>;
presetPassword?: string | null;
}) {
const [s, setS] = useState<DataSource>(() => ({ ...blank(), ...preset, ...initial }));
// `undefined`: unchanged (editing keeps the stored password).
const [password, setPassword] = useState<string | undefined>(presetPassword ?? undefined);
// Same convention for the SSH password / passphrase.
const [sshSecret, setSshSecret] = useState<string | undefined>(undefined);
const [tab, setTab] = useState<"general" | "ssh">("general");
const [test, setTest] = useState<{ ok: boolean; text: string; trust?: boolean } | null>(null);
const [busy, setBusy] = useState(false);
const set = (patch: Partial<DataSource>) => setS((x) => ({ ...x, ...patch }));
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
const sqlite = s.driver === "sqlite";
const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${s.database || s.user || "db"}@${s.host}`);
const sshOn = !sqlite && !!ssh.enabled;
const valid = sqlite ? !!s.file?.trim() : !!s.host?.trim() && (!sshOn || !!ssh.host?.trim());
const runTest = async () => {
setBusy(true);
setTest(null);
try {
const v = await call(commands.dbTest({ ...s, name }, password ?? null, sshOn ? (sshSecret ?? null) : null));
setTest({ ok: true, text: `Succeeded: ${v}` });
} catch (e) {
const text = e instanceof Error ? e.message : String(e);
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey";
setTest({ ok: false, text, trust });
} finally {
setBusy(false);
}
};
const trustAndRetry = async () => {
await call(commands.dbTrustSshHost(s.id ?? ""));
await runTest();
};
const save = async () => {
setBusy(true);
try {
await saveSource(
{ ...s, name },
sqlite ? undefined : password === undefined ? undefined : password || null,
!sshOn || sshSecret === undefined || ssh.auth === "openSsh" ? undefined : sshSecret || null,
);
closeDialog();
} catch (e) {
setTest({ ok: false, text: e instanceof Error ? e.message : String(e) });
} finally {
setBusy(false);
}
};
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "SQLite Database File" });
if (typeof file === "string") set({ file });
};
const browseKey = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "Private Key File" });
if (typeof file === "string") setSsh({ keyFile: file });
};
return (
<Dialog
open
onClose={closeDialog}
title={initial ? `Data Source: ${initial.name}` : "New Data Source"}
width={560}
footerStart={
<Button onClick={() => void runTest()} disabled={busy || !valid}>
<T>Test Connection</T>
</Button>
}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "OK", role: "default", disabled: busy || !valid, onClick: () => void save() },
]}
>
{!sqlite && (
<Tabs
variant="compact"
className={styles.dialogTabs}
activeId={tab}
onSelect={(id) => setTab(id as "general" | "ssh")}
aria-label="Data source settings"
panelIdPrefix="db-source"
items={[
{ id: "general", label: "General" },
{ id: "ssh", label: "SSH/SSL" },
]}
/>
)}
<div
className={styles.form}
role={sqlite ? undefined : "tabpanel"}
id={sqlite ? undefined : `db-source-panel-${tab}`}
>
{sqlite || tab === "general" ? (
<>
<label className={styles.formLabel} htmlFor="db-name">
<T>Name:</T>
</label>
<Input
id="db-name"
value={s.name ?? ""}
placeholder={name}
onChange={(e) => set({ name: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Driver:</T>
</span>
<Select
aria-label="Driver"
value={s.driver ?? "postgres"}
options={DRIVERS}
onChange={(driver) => set({ driver })}
/>
{sqlite ? (
<>
<label className={styles.formLabel} htmlFor="db-file">
<T>File:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-file"
className={styles.fill}
value={s.file ?? ""}
placeholder="path/to/database.db (absolute or project-relative)"
onChange={(e) => set({ file: e.target.value || null })}
/>
<Button onClick={() => void browse()}>
<T>Browse…</T>
</Button>
</div>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-host"
className={styles.fill}
value={s.host ?? ""}
onChange={(e) => set({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-port">
<T>Port:</T>
</label>
<Input
id="db-port"
style={{ width: 80 }}
inputMode="numeric"
value={s.port ?? ""}
placeholder={s.driver === "mysql" ? "3306" : "5432"}
onChange={(e) => set({ port: parsePort(e.target.value) })}
/>
</div>
{sshOn && (
<>
<span />
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh })}.</span>
</>
)}
<label className={styles.formLabel} htmlFor="db-user">
<T>User:</T>
</label>
<Input id="db-user" value={s.user ?? ""} onChange={(e) => set({ user: e.target.value })} />
<label className={styles.formLabel} htmlFor="db-password">
<T>Password:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-password"
className={styles.fill}
type="password"
value={password ?? ""}
placeholder={initial && password === undefined ? "<unchanged>" : ""}
onChange={(e) => setPassword(e.target.value)}
/>
<Checkbox
checked={!!s.savePassword}
onChange={(savePassword) => set({ savePassword })}
label="Save password"
/>
</div>
<label className={styles.formLabel} htmlFor="db-database">
<T>Database:</T>
</label>
<Input id="db-database" value={s.database ?? ""} onChange={(e) => set({ database: e.target.value })} />
</>
)}
<span />
<Checkbox checked={!!s.readOnly} onChange={(readOnly) => set({ readOnly })} label="Read-only" />
</>
) : (
<>
<span />
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
<label className={styles.formLabel} htmlFor="db-ssh-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-host"
className={styles.fill}
disabled={!sshOn}
value={ssh.host ?? ""}
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
onChange={(e) => setSsh({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-ssh-port">
<T>Port:</T>
</label>
<Input
id="db-ssh-port"
style={{ width: 80 }}
inputMode="numeric"
disabled={!sshOn}
value={ssh.port ?? ""}
placeholder="22"
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
/>
</div>
<label className={styles.formLabel} htmlFor="db-ssh-user">
<T>User:</T>
</label>
<Input
id="db-ssh-user"
disabled={!sshOn}
value={ssh.user ?? ""}
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
onChange={(e) => setSsh({ user: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Authentication:</T>
</span>
<Select
aria-label="SSH authentication type"
disabled={!sshOn}
value={ssh.auth ?? "openSsh"}
options={SSH_AUTH}
onChange={(auth) => setSsh({ auth })}
/>
{ssh.auth === "keyPair" && (
<>
<label className={styles.formLabel} htmlFor="db-ssh-key">
<T>Private key:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-key"
className={styles.fill}
disabled={!sshOn}
value={ssh.keyFile ?? ""}
placeholder="~/.ssh/id_ed25519"
onChange={(e) => setSsh({ keyFile: e.target.value })}
/>
<Button disabled={!sshOn} onClick={() => void browseKey()}>
<T>Browse…</T>
</Button>
</div>
</>
)}
{ssh.auth === "openSsh" ? (
<>
<span />
<span className={styles.formHint}>
<T>
{
"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."
}
</T>
</span>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-ssh-secret">
{ssh.auth === "password" ? "Password:" : "Passphrase:"}
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-secret"
className={styles.fill}
type="password"
disabled={!sshOn}
value={sshSecret ?? ""}
placeholder={initial && sshSecret === undefined ? "<unchanged>" : ""}
onChange={(e) => setSshSecret(e.target.value)}
/>
<Checkbox
checked={!!ssh.saveSecret}
disabled={!sshOn}
onChange={(saveSecret) => setSsh({ saveSecret })}
label="Save password"
/>
</div>
</>
)}
<span className={styles.formLabel}>
<T>SSL:</T>
</span>
<Select aria-label="SSL mode" value={s.ssl ?? "prefer"} options={SSL} onChange={(ssl) => set({ ssl })} />
</>
)}
<div className={`${styles.testResult} ${test?.ok ? styles.ok : styles.bad}`} role="status">
{busy && !test ? "Connecting…" : test?.text}
{test?.trust && (
<>
{" "}
<Button size="small" onClick={() => void trustAndRetry()} disabled={busy}>
<T>Trust Host and Retry</T>
</Button>
</>
)}
</div>
</div>
</Dialog>
);
}
function PasswordDialog({ source, resolve }: { source: DataSource; resolve: (ok: boolean) => void }) {
const [password, setPassword] = useState("");
const [save, setSave] = useState(!!source.savePassword);
+3 -2
Переглянути файл
@@ -515,8 +515,8 @@ function SourceUrlDialog() {
/>
<span className={styles.formHint}>
{url.trim() && !parsed
? "Not a PostgreSQL, MySQL / MariaDB or SQLite URL"
: "postgresql://, mysql://, mariadb://, jdbc:…, sqlite:path"}
? "Not a URL of PostgreSQL, MySQL, SQLite or a compatible database"
: "postgresql://, mysql://, mariadb://, redshift://, tidb://…, jdbc:…, sqlite:path"}
</span>
</div>
</Dialog>
@@ -550,6 +550,7 @@ function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: numb
switch (node.kind) {
case "source":
rows.push(["Driver", sourceLabel(node.source).replace(/^.*\((.*)\)$/, "$1")]);
if (node.source.comment?.trim()) rows.push(["Comment", node.source.comment.trim()]);
rows.push(
node.source.driver === "sqlite"
? ["File", node.source.file ?? ""]
+935
Переглянути файл
@@ -0,0 +1,935 @@
/**
* Data source properties (IDEA's Data Sources and Drivers page): name, colour and
* comment above General / Options / SSH/SSL / Schemas / Advanced tabs, Test Connection.
*/
import { useState, type ReactNode } from "react";
import {
call,
commands,
IpcError,
type DataSource,
type DbColor,
type DbIntrospection,
type DbOptions,
type DbProperty,
type DbSsh,
type DbSshAuth,
type DbSslMode,
type DbTxMode,
} from "@/platform/ipc";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { Button, Checkbox, Dialog, IconButton, Input, Select, Tabs, T, type SelectOption } from "@/ui";
import { colorVar, connectRemedy, fileSourceName, newSourceId, parseSourceUrl, sshLabel } from "./model";
import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store";
import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors";
import styles from "./Database.module.css";
type Tab = "general" | "options" | "ssh" | "schemas" | "advanced";
const TYPES: SelectOption[] = VENDORS.map((v) => ({ value: v.id, label: v.label, icon: v.icon }));
const COLORS: Array<SelectOption<DbColor | "none">> = [
{ value: "none", label: "No color" },
{ value: "red", label: "Red" },
{ value: "orange", label: "Orange" },
{ value: "yellow", label: "Yellow" },
{ value: "green", label: "Green" },
{ value: "blue", label: "Blue" },
{ value: "violet", label: "Violet" },
];
const SSL: Array<SelectOption<DbSslMode>> = [
{ value: "disable", label: "Disable" },
{ value: "prefer", label: "Prefer" },
{ value: "require", label: "Require" },
{ value: "verifyCa", label: "Verify CA" },
{ value: "verifyFull", label: "Verify Full" },
];
const TX: Array<SelectOption<DbTxMode>> = [
{ value: "auto", label: "Auto" },
{ value: "manual", label: "Manual" },
];
const SSH_AUTH: Array<SelectOption<DbSshAuth>> = [
{ value: "openSsh", label: "OpenSSH config and authentication agent" },
{ value: "password", label: "Password" },
{ value: "keyPair", label: "Key pair" },
];
/** Driver property names the Advanced tab suggests, and what the backend does with them. */
const PROPERTIES: Record<string, { names: string[]; hint: string }> = {
postgres: {
names: ["statement_timeout", "lock_timeout", "idle_in_transaction_session_timeout", "search_path", "work_mem", "application_name", "connect_timeout"],
hint: "Sent as run-time parameters when connecting (any server setting, e.g. statement_timeout). application_name names the session; connect_timeout is in seconds.",
},
mysql: {
names: ["sql_mode", "max_execution_time", "wait_timeout", "charset", "collation", "connect_timeout"],
hint: "Set as session variables after connecting (SET SESSION name = value). charset and collation pick the connection character set; connect_timeout is in seconds.",
},
sqlite: {
names: ["foreign_keys", "busy_timeout", "cache_size", "journal_mode", "synchronous"],
hint: "Applied as pragmas when opening the file (PRAGMA name = value).",
},
};
const blankSsh = (): DbSsh => ({
enabled: false,
host: "",
port: null,
user: "",
auth: "openSsh",
keyFile: "",
saveSecret: true,
});
const blankOptions = (): DbOptions => ({
transaction: "auto",
defaultSchema: "",
timeZone: "",
startupScript: "",
keepAliveSecs: null,
autoDisconnectSecs: null,
});
const blankIntrospection = (): DbIntrospection => ({
allDatabases: true,
databases: [],
allSchemas: true,
schemas: [],
showSystem: false,
});
const parsePort = (v: string) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 && n < 65536 ? n : null;
};
const parseSecs = (v: string) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : null;
};
function blank(): DataSource {
return {
id: newSourceId(),
name: "",
driver: "postgres",
vendor: "",
host: "localhost",
port: null,
database: "",
user: "",
file: null,
ssl: "prefer",
savePassword: true,
readOnly: false,
color: null,
comment: "",
urlOnly: false,
url: "",
sslCaFile: "",
sslCertFile: "",
sslKeyFile: "",
options: blankOptions(),
introspection: blankIntrospection(),
properties: [],
};
}
async function pickFile(title: string): Promise<string | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title });
return typeof file === "string" ? file : null;
}
/** A path field with Browse…. */
function FileField({
id,
label,
value,
placeholder,
disabled,
title,
onChange,
}: {
id: string;
label: string;
value: string;
placeholder?: string;
disabled?: boolean;
title: string;
onChange: (v: string) => void;
}) {
return (
<>
<label className={styles.formLabel} htmlFor={id}>
<T>{label}</T>
</label>
<div className={styles.formRow}>
<Input
id={id}
className={styles.fill}
disabled={disabled}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
<Button
disabled={disabled}
onClick={() =>
void pickFile(title).then((f) => {
if (f) onChange(f);
})
}
>
<T>Browse…</T>
</Button>
</div>
</>
);
}
/** Seconds after a checkbox (Run keep-alive query each N sec.). */
function SecondsOption({
label,
value,
fallback,
onChange,
}: {
label: string;
value: number | null | undefined;
fallback: number;
onChange: (v: number | null) => void;
}) {
const [draft, setDraft] = useState(String(value ?? fallback));
const on = value != null;
return (
<>
<span />
<div className={styles.formRow}>
<Checkbox checked={on} onChange={(c) => onChange(c ? (parseSecs(draft) ?? fallback) : null)} label={label} />
<Input
className={styles.secondsInput}
inputMode="numeric"
aria-label={`${label} (seconds)`}
disabled={!on}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
const n = parseSecs(e.target.value);
if (n) onChange(n);
}}
/>
<span>
<T>sec.</T>
</span>
</div>
</>
);
}
/** Schemas tab: "All …" or a choice of names and `*` / `?` patterns. */
function NamePicker({
title,
all,
chosen,
known,
onAll,
onChosen,
}: {
title: string;
all: boolean;
chosen: string[];
known: string[];
onAll: (all: boolean) => void;
onChosen: (chosen: string[]) => void;
}) {
const [pattern, setPattern] = useState("");
const names = [...new Set([...known, ...chosen])];
const add = () => {
const p = pattern.trim();
if (p && !chosen.includes(p)) onChosen([...chosen, p]);
setPattern("");
};
return (
<div className={styles.namePicker}>
<Checkbox checked={all} onChange={onAll} label={`All ${title}`} />
{!all && (
<div className={styles.nameList}>
{names.map((n) => (
<Checkbox
key={n}
checked={chosen.includes(n)}
onChange={(c) => onChosen(c ? [...chosen, n] : chosen.filter((x) => x !== n))}
label={n}
/>
))}
<div className={styles.formRow}>
<Input
className={styles.fill}
value={pattern}
placeholder="Name or pattern, e.g. app_*"
aria-label={`Add ${title} pattern`}
onChange={(e) => setPattern(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault();
add();
}
}}
/>
<Button disabled={!pattern.trim()} onClick={add}>
<T>Add Pattern</T>
</Button>
</div>
</div>
)}
</div>
);
}
function Section({ title }: { title: string }) {
return (
<div className={styles.formSection}>
<T>{title}</T>
</div>
);
}
export function SourceDialog({
initial,
preset,
presetPassword,
}: {
initial: DataSource | null;
preset?: Partial<DataSource>;
presetPassword?: string | null;
}) {
const tx = useTx();
const txTemplate = useTxTemplate();
const [s, setS] = useState<DataSource>(() => ({ ...blank(), ...preset, ...initial }));
// `undefined`: unchanged (editing keeps the stored password).
const [password, setPassword] = useState<string | undefined>(presetPassword ?? undefined);
// Same convention for the SSH password / passphrase.
const [sshSecret, setSshSecret] = useState<string | undefined>(undefined);
const [tab, setTab] = useState<Tab>("general");
const [test, setTest] = useState<{ ok: boolean; text: string; trust?: boolean } | null>(null);
const [busy, setBusy] = useState(false);
const [showComment, setShowComment] = useState(!!s.comment);
// The URL being typed in default mode (null: show the fields' URL).
const [urlDraft, setUrlDraft] = useState<string | null>(null);
const loadedDbs = useDatabase((st) => (initial?.id ? st.databases[initial.id] : undefined));
const loadedSchemas = useDatabase((st) => (initial?.id ? st.schemas[initial.id] : undefined));
const set = (patch: Partial<DataSource>) => setS((x) => ({ ...x, ...patch }));
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
const options: DbOptions = { ...blankOptions(), ...s.options };
const setOptions = (patch: Partial<DbOptions>) => setS((x) => ({ ...x, options: { ...blankOptions(), ...x.options, ...patch } }));
const intro: DbIntrospection = { ...blankIntrospection(), ...s.introspection };
const setIntro = (patch: Partial<DbIntrospection>) =>
setS((x) => ({ ...x, introspection: { ...blankIntrospection(), ...x.introspection, ...patch } }));
const properties = s.properties ?? [];
const setProperty = (i: number, patch: Partial<DbProperty>) =>
set({ properties: properties.map((p, j) => (j === i ? { ...p, ...patch } : p)) });
const vendor = vendorOf(s);
const sqlite = s.driver === "sqlite";
const postgres = s.driver === "postgres";
const urlOnly = !sqlite && !!s.urlOnly;
const parsedUrl = urlOnly ? parseSourceUrl(s.url ?? "") : null;
const urlValid = !!parsedUrl && parsedUrl.source.driver === s.driver;
const host = urlOnly ? (parsedUrl?.source.host ?? "") : (s.host ?? "");
const database = urlOnly ? (parsedUrl?.source.database ?? "") : (s.database ?? "");
const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`);
const sshOn = !sqlite && !!ssh.enabled;
const valid = sqlite ? !!s.file?.trim() : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || !!ssh.host?.trim());
const sslOn = (s.ssl ?? "prefer") !== "disable";
/** What is saved / tested: a preset's port when the field was left empty. */
const effective = (): DataSource => ({ ...s, name, port: s.port ?? (s.vendor ? vendor.port : null) });
const setType = (id: string) => {
const v = vendorById(id);
if (!v) return;
setS((x) => {
const old = vendorOf(x);
const port = x.port == null || x.port === old.port ? v.port : x.port;
return { ...x, driver: v.driver, vendor: v.id === v.driver ? "" : v.id, port, ssl: v.ssl ?? x.ssl };
});
};
/** Typing a URL in default mode fills the fields it names. */
const typeUrl = (text: string) => {
setUrlDraft(text);
const p = parseSourceUrl(text);
if (!p || p.source.driver !== s.driver) return;
const { host: h, port, database: db, user, ssl, sslCaFile, sslCertFile, sslKeyFile, vendor: v } = p.source;
set({
host: h,
port,
database: db,
...(user ? { user } : {}),
...(ssl ? { ssl } : {}),
...(sslCaFile ? { sslCaFile } : {}),
...(sslCertFile ? { sslCertFile } : {}),
...(sslKeyFile ? { sslKeyFile } : {}),
...(v ? { vendor: v } : {}),
});
if (p.password) setPassword(p.password);
};
const setUrlOnly = (on: boolean) => set(on && !s.url?.trim() ? { urlOnly: on, url: sourceUrl(s) } : { urlOnly: on });
const runTest = async () => {
setBusy(true);
setTest(null);
try {
const v = await call(commands.dbTest(effective(), password ?? null, sshOn ? (sshSecret ?? null) : null));
setTest({ ok: true, text: `Succeeded: ${v}` });
} catch (e) {
const text = e instanceof Error ? e.message : String(e);
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey";
setTest({ ok: false, text, trust });
} finally {
setBusy(false);
}
};
const trustAndRetry = async () => {
await call(commands.dbTrustSshHost(s.id ?? ""));
await runTest();
};
const save = async () => {
setBusy(true);
try {
await saveSource(
{ ...effective(), properties: properties.filter((p) => p.name?.trim()) },
sqlite ? undefined : password === undefined ? undefined : password || null,
!sshOn || sshSecret === undefined || ssh.auth === "openSsh" ? undefined : sshSecret || null,
);
closeDialog();
} catch (e) {
setTest({ ok: false, text: e instanceof Error ? e.message : String(e) });
} finally {
setBusy(false);
}
};
const loadNames = async () => {
const id = initial?.id;
if (!id) return;
await Promise.all([postgres ? loadDatabases(id) : Promise.resolve(), loadSchemas(id)]);
};
const tabs: Array<{ id: Tab; label: string }> = [
{ id: "general", label: "General" },
{ id: "options", label: "Options" },
...(sqlite
? []
: [
{ id: "ssh" as const, label: "SSH/SSL" },
{ id: "schemas" as const, label: "Schemas" },
]),
{ id: "advanced", label: "Advanced" },
];
const current = tabs.some((t) => t.id === tab) ? tab : "general";
const props = PROPERTIES[s.driver ?? "sqlite"]!;
const general = (
<>
<span className={styles.formLabel}>
<T>Driver:</T>
</span>
<Select aria-label="Data source type" value={vendor.id} options={TYPES} onChange={setType} />
{sqlite ? (
<FileField
id="db-file"
label="File:"
value={s.file ?? ""}
placeholder="path/to/database.db (absolute or project-relative)"
title="SQLite Database File"
onChange={(file) => set({ file: file || null })}
/>
) : (
<>
<span className={styles.formLabel}>
<T>Connection type:</T>
</span>
<div className={styles.segmented} role="group" aria-label={tx("Connection type")}>
<button type="button" aria-pressed={!urlOnly} onClick={() => setUrlOnly(false)}>
<T>default</T>
</button>
<button type="button" aria-pressed={urlOnly} onClick={() => setUrlOnly(true)}>
<T>URL only</T>
</button>
</div>
{!urlOnly && (
<>
<label className={styles.formLabel} htmlFor="db-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input id="db-host" className={styles.fill} value={s.host ?? ""} onChange={(e) => set({ host: e.target.value })} />
<label className={styles.formLabel} htmlFor="db-port">
<T>Port:</T>
</label>
<Input
id="db-port"
className={styles.portInput}
inputMode="numeric"
value={s.port ?? ""}
placeholder={String(vendor.port ?? "")}
onChange={(e) => set({ port: parsePort(e.target.value) })}
/>
</div>
</>
)}
{sshOn && (
<>
<span />
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh })}.</span>
</>
)}
<label className={styles.formLabel} htmlFor="db-user">
<T>User:</T>
</label>
<Input
id="db-user"
value={s.user ?? ""}
placeholder={urlOnly ? "from the URL" : ""}
onChange={(e) => set({ user: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-password">
<T>Password:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-password"
className={styles.fill}
type="password"
value={password ?? ""}
placeholder={initial && password === undefined ? "<unchanged>" : ""}
onChange={(e) => setPassword(e.target.value)}
/>
<Checkbox checked={!!s.savePassword} onChange={(savePassword) => set({ savePassword })} label="Save password" />
</div>
{!urlOnly && (
<>
<label className={styles.formLabel} htmlFor="db-database">
<T>Database:</T>
</label>
<Input id="db-database" value={s.database ?? ""} onChange={(e) => set({ database: e.target.value })} />
</>
)}
<label className={styles.formLabel} htmlFor="db-url">
<T>URL:</T>
</label>
<Input
id="db-url"
mono
value={urlOnly ? (s.url ?? "") : (urlDraft ?? sourceUrl(s))}
invalid={urlOnly ? !urlValid : urlDraft !== null && parseSourceUrl(urlDraft)?.source.driver !== s.driver}
placeholder={sourceUrl(s)}
onChange={(e) => (urlOnly ? set({ url: e.target.value }) : typeUrl(e.target.value))}
onBlur={() => setUrlDraft(null)}
/>
<span />
<span className={styles.formHint}>
{txTemplate(
urlOnly
? urlValid || !s.url?.trim()
? "Connects with the URL alone: host, port, database, SSL and parameters come from it."
: `Not a ${vendor.label} URL`
: "Editing the URL fills in the settings above.",
)}
</span>
</>
)}
</>
);
const optionsTab = (
<>
<Section title="Connection" />
<span />
<Checkbox checked={!!s.readOnly} onChange={(readOnly) => set({ readOnly })} label="Read-only" />
<span className={styles.formLabel}>
<T>Transaction control:</T>
</span>
<Select
aria-label="Transaction control"
className={styles.narrowSelect}
value={options.transaction ?? "auto"}
options={TX}
onChange={(transaction) => setOptions({ transaction })}
/>
{postgres && (
<>
<label className={styles.formLabel} htmlFor="db-default-schema">
<T>Default schema:</T>
</label>
<Input
id="db-default-schema"
value={options.defaultSchema ?? ""}
placeholder="public (search_path, e.g. app, public)"
onChange={(e) => setOptions({ defaultSchema: e.target.value })}
/>
</>
)}
{!sqlite && (
<>
<label className={styles.formLabel} htmlFor="db-time-zone">
<T>Time zone:</T>
</label>
<Input
id="db-time-zone"
value={options.timeZone ?? ""}
placeholder={postgres ? "Server default, e.g. UTC or Europe/Berlin" : "UTC (+00:00), e.g. Europe/Berlin or +08:00"}
onChange={(e) => setOptions({ timeZone: e.target.value })}
/>
<SecondsOption
label="Run keep-alive query each"
value={options.keepAliveSecs}
fallback={60}
onChange={(keepAliveSecs) => setOptions({ keepAliveSecs })}
/>
</>
)}
<SecondsOption
label="Auto-disconnect after"
value={options.autoDisconnectSecs}
fallback={300}
onChange={(autoDisconnectSecs) => setOptions({ autoDisconnectSecs })}
/>
<label className={styles.formLabel} htmlFor="db-startup">
<T>Startup script:</T>
</label>
<textarea
id="db-startup"
className={styles.textArea}
spellCheck={false}
value={options.startupScript ?? ""}
placeholder={postgres ? "SET statement_timeout = '30s';" : sqlite ? "PRAGMA foreign_keys = ON;" : "SET SESSION sql_mode = 'ANSI';"}
onChange={(e) => setOptions({ startupScript: e.target.value })}
/>
<span />
<span className={styles.formHint}>
<T>Runs on every new connection, before any other statement.</T>
</span>
</>
);
const sshTab = (
<>
<span />
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
<label className={styles.formLabel} htmlFor="db-ssh-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-host"
className={styles.fill}
disabled={!sshOn}
value={ssh.host ?? ""}
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
onChange={(e) => setSsh({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-ssh-port">
<T>Port:</T>
</label>
<Input
id="db-ssh-port"
className={styles.portInput}
inputMode="numeric"
disabled={!sshOn}
value={ssh.port ?? ""}
placeholder="22"
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
/>
</div>
<label className={styles.formLabel} htmlFor="db-ssh-user">
<T>User:</T>
</label>
<Input
id="db-ssh-user"
disabled={!sshOn}
value={ssh.user ?? ""}
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
onChange={(e) => setSsh({ user: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Authentication:</T>
</span>
<Select
aria-label="SSH authentication type"
disabled={!sshOn}
value={ssh.auth ?? "openSsh"}
options={SSH_AUTH}
onChange={(auth) => setSsh({ auth })}
/>
{ssh.auth === "keyPair" && (
<FileField
id="db-ssh-key"
label="Private key:"
value={ssh.keyFile ?? ""}
placeholder="~/.ssh/id_ed25519"
disabled={!sshOn}
title="Private Key File"
onChange={(keyFile) => setSsh({ keyFile })}
/>
)}
{ssh.auth === "openSsh" ? (
<>
<span />
<span className={styles.formHint}>
<T>{"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."}</T>
</span>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-ssh-secret">
{tx(ssh.auth === "password" ? "Password:" : "Passphrase:")}
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-secret"
className={styles.fill}
type="password"
disabled={!sshOn}
value={sshSecret ?? ""}
placeholder={initial && sshSecret === undefined ? "<unchanged>" : ""}
onChange={(e) => setSshSecret(e.target.value)}
/>
<Checkbox
checked={!!ssh.saveSecret}
disabled={!sshOn}
onChange={(saveSecret) => setSsh({ saveSecret })}
label="Save password"
/>
</div>
</>
)}
<Section title="SSL" />
<span className={styles.formLabel}>
<T>Mode:</T>
</span>
<Select
aria-label="SSL mode"
className={styles.narrowSelect}
value={s.ssl ?? "prefer"}
options={SSL}
onChange={(ssl) => set({ ssl })}
/>
<FileField
id="db-ssl-ca"
label="CA file:"
value={s.sslCaFile ?? ""}
placeholder={s.ssl === "verifyCa" || s.ssl === "verifyFull" ? "System roots when empty" : ""}
disabled={!sslOn}
title="CA Certificate File"
onChange={(sslCaFile) => set({ sslCaFile })}
/>
<FileField
id="db-ssl-cert"
label="Client certificate file:"
value={s.sslCertFile ?? ""}
disabled={!sslOn}
title="Client Certificate File"
onChange={(sslCertFile) => set({ sslCertFile })}
/>
<FileField
id="db-ssl-key"
label="Client key file:"
value={s.sslKeyFile ?? ""}
disabled={!sslOn}
title="Client Key File"
onChange={(sslKeyFile) => set({ sslKeyFile })}
/>
</>
);
const knownDbs = loadedDbs?.status === "ok" ? loadedDbs.value.names : [];
const knownSchemas = loadedSchemas?.status === "ok" ? loadedSchemas.value : [];
const schemasTab = (
<>
<div className={styles.formWide}>
{postgres && (
<NamePicker
title="databases"
all={intro.allDatabases !== false}
chosen={intro.databases ?? []}
known={knownDbs}
onAll={(allDatabases) => setIntro({ allDatabases })}
onChosen={(databases) => setIntro({ databases })}
/>
)}
<NamePicker
title={s.driver === "mysql" ? "schemas (databases)" : "schemas"}
all={intro.allSchemas !== false}
chosen={intro.schemas ?? []}
known={knownSchemas}
onAll={(allSchemas) => setIntro({ allSchemas })}
onChosen={(schemas) => setIntro({ schemas })}
/>
<Checkbox
checked={!!intro.showSystem}
onChange={(showSystem) => setIntro({ showSystem })}
label={postgres ? "Show system schemas and template databases" : "Show system schemas"}
/>
<div className={styles.formRow}>
<Button disabled={!initial?.id || busy} onClick={() => void loadNames()}>
<T>Load Names from Server</T>
</Button>
<span className={styles.formHint}>
{initial?.id
? "The Database tree shows only the checked names and patterns (* and ?); the connected database always shows."
: "Save the data source to pick from the server's names; patterns (* and ?) work any time."}
</span>
</div>
</div>
</>
);
const advancedTab = (
<div className={styles.formWide}>
<div className={styles.propertyTable} role="table" aria-label={tx("Driver properties")}>
<div role="row" className={styles.propertyHead}>
<span role="columnheader">
<T>Name</T>
</span>
<span role="columnheader">
<T>Value</T>
</span>
<span />
</div>
{properties.map((p, i) => (
<div role="row" key={i} className={styles.propertyRow}>
<Input
aria-label="Property name"
list="db-property-names"
value={p.name ?? ""}
placeholder="<user defined>"
onChange={(e) => setProperty(i, { name: e.target.value })}
/>
<Input aria-label="Property value" value={p.value ?? ""} placeholder="<value>" onChange={(e) => setProperty(i, { value: e.target.value })} />
<IconButton
icon="close"
size="small"
aria-label="Remove property"
title="Remove"
onClick={() => set({ properties: properties.filter((_, j) => j !== i) })}
/>
</div>
))}
</div>
<datalist id="db-property-names">
{props.names.map((n) => (
<option key={n} value={n} />
))}
</datalist>
<div className={styles.formRow}>
<Button icon="plus" onClick={() => set({ properties: [...properties, { name: "", value: "" }] })}>
<T>Add Property</T>
</Button>
</div>
<span className={styles.formHint}>
<T>{props.hint}</T>
</span>
</div>
);
const body: Record<Tab, ReactNode> = {
general,
options: optionsTab,
ssh: sshTab,
schemas: schemasTab,
advanced: advancedTab,
};
return (
<Dialog
open
onClose={closeDialog}
title={initial ? `Data Source: ${initial.name}` : "New Data Source"}
width={640}
footerStart={
<Button onClick={() => void runTest()} disabled={busy || !valid}>
<T>Test Connection</T>
</Button>
}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "OK", role: "default", disabled: busy || !valid, onClick: () => void save() },
]}
>
<div className={styles.form}>
<label className={styles.formLabel} htmlFor="db-name">
<T>Name:</T>
</label>
<div className={styles.formRow}>
<Input id="db-name" className={styles.fill} value={s.name ?? ""} placeholder={name} onChange={(e) => set({ name: e.target.value })} />
<span className={styles.formLabel}>
<T>Color:</T>
</span>
<Select
aria-label="Color"
className={styles.narrowSelect}
value={s.color ?? "none"}
options={COLORS}
onChange={(c) => set({ color: c === "none" ? null : c })}
renderValue={(o) => (
<span className={styles.colorValue}>
{o && o.value !== "none" && <span className={styles.colorSwatch} style={{ background: colorVar(o.value as DbColor) }} />}
<T>{o?.label ?? "No color"}</T>
</span>
)}
/>
</div>
<span />
{showComment ? (
<textarea
className={styles.textArea}
aria-label={tx("Comment")}
autoFocus={!s.comment}
rows={2}
value={s.comment ?? ""}
placeholder={tx("Comment")}
onChange={(e) => set({ comment: e.target.value })}
/>
) : (
<button type="button" className={styles.linkButton} onClick={() => setShowComment(true)}>
<T>Add comment</T>
</button>
)}
</div>
<Tabs
variant="compact"
className={styles.dialogTabs}
activeId={current}
onSelect={(id) => setTab(id as Tab)}
aria-label="Data source settings"
panelIdPrefix="db-source"
items={tabs}
/>
<div className={styles.form} role="tabpanel" id={`db-source-panel-${current}`}>
{body[current]}
<div className={`${styles.testResult} ${test?.ok ? styles.ok : styles.bad}`} role="status">
{busy && !test ? tx("Connecting…") : test?.text}
{test?.trust && (
<>
{" "}
<Button size="small" onClick={() => void trustAndRetry()} disabled={busy}>
<T>Trust Host and Retry</T>
</Button>
</>
)}
</div>
</div>
</Dialog>
);
}
+103 -1
Переглянути файл
@@ -1,6 +1,23 @@
import { describe, expect, it } from "vitest";
import { exportData, type ExportData } from "./export";
import { buildRowEdits, connectRemedy, emptyEdits, hasEdits, parseSourceUrl, sourceLabel, splitTarget, sshLabel, targetOf, toCsv, toTsv } from "./model";
import {
buildRowEdits,
colorVar,
connectRemedy,
emptyEdits,
hasEdits,
idleTargets,
matchesPattern,
parseSourceUrl,
shownNames,
sourceLabel,
splitTarget,
sshLabel,
targetOf,
toCsv,
toTsv,
} from "./model";
import { VENDORS, sourceUrl, vendorById, vendorOf, vendorPreset } from "./vendors";
describe("clipboard formats", () => {
it("copies TSV with NULL as empty and whitespace flattened", () => {
@@ -131,3 +148,88 @@ describe("parseSourceUrl", () => {
expect(parseSourceUrl("oracle://x")).toBeNull();
});
});
describe("data source types", () => {
it("presets wire-compatible products on the existing drivers", () => {
expect(vendorPreset(vendorById("cockroachdb")!)).toEqual({ driver: "postgres", vendor: "cockroachdb", port: 26257 });
expect(vendorPreset(vendorById("postgres")!)).toEqual({ driver: "postgres", vendor: "", port: 5432 });
expect(vendorPreset(vendorById("supabase")!).ssl).toBe("require");
expect(VENDORS.every((v) => ["postgres", "mysql", "sqlite"].includes(v.driver))).toBe(true);
const labels = VENDORS.map((v) => v.label);
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
});
it("falls back to the driver for unknown or mismatched presets", () => {
expect(vendorOf({ driver: "mysql", vendor: "tidb" }).label).toBe("TiDB");
expect(vendorOf({ driver: "postgres", vendor: "tidb" }).id).toBe("postgres");
expect(vendorOf({ driver: "mysql" }).id).toBe("mysql");
expect(sourceLabel({ name: "tidb", driver: "mysql", vendor: "tidb" })).toBe("tidb (TiDB)");
});
it("previews the JDBC URL of the fields", () => {
expect(sourceUrl({ driver: "postgres", host: "localhost", port: 5432, database: "postgres" })).toBe(
"jdbc:postgresql://localhost:5432/postgres",
);
expect(sourceUrl({ driver: "postgres", vendor: "redshift", host: "r", port: null, database: "" })).toBe("jdbc:redshift://r:5439");
expect(sourceUrl({ driver: "mysql", vendor: "mariadb", host: "::1", port: 3307, database: "a b" })).toBe(
"jdbc:mariadb://[::1]:3307/a%20b",
);
expect(sourceUrl({ driver: "sqlite", file: "app.db" })).toBe("jdbc:sqlite:app.db");
});
it("reads URLs of compatible products, SSL settings and driver properties", () => {
const tidb = parseSourceUrl("jdbc:tidb://[email protected]:4000/test?sslmode=verify-full&sslrootcert=/ca.pem&sql_mode=ANSI");
expect(tidb?.source).toEqual({
driver: "mysql",
vendor: "tidb",
host: "tidb.local",
port: 4000,
user: "root",
database: "test",
ssl: "verifyFull",
sslCaFile: "/ca.pem",
properties: [{ name: "sql_mode", value: "ANSI" }],
});
expect(parseSourceUrl("redshift://r.example:5439/dev")?.source.vendor).toBe("redshift");
expect(parseSourceUrl("postgresql://h:70000/db")).toBeNull();
});
});
describe("Schemas tab", () => {
it("matches names and * / ? patterns", () => {
expect(matchesPattern("app_1", "app_*")).toBe(true);
expect(matchesPattern("app.1", "app?1")).toBe(true);
expect(matchesPattern("apps", "app")).toBe(false);
expect(matchesPattern("a.b", "a.b")).toBe(true);
});
it("shows all names, or the chosen ones and the connected database", () => {
const names = ["app", "app_test", "billing", "postgres"];
expect(shownNames(names, undefined, undefined)).toEqual(names);
expect(shownNames(names, true, ["billing"])).toEqual(names);
expect(shownNames(names, false, ["app*"], "postgres")).toEqual(["app", "app_test", "postgres"]);
expect(shownNames(names, false, [])).toEqual([]);
});
});
describe("Options tab", () => {
it("finds connections idle past their auto-disconnect", () => {
const sources = [
{ id: "a", options: { autoDisconnectSecs: 60 } },
{ id: "b", options: { autoDisconnectSecs: null } },
];
const used = new Map([
["a", 0],
["a:other", 50_000],
["b", 0],
]);
const idle = (busy: string[]) => idleTargets(["a", "a:other", "b", "c"], sources, used, (t) => busy.includes(t), 60_000);
expect(idle([])).toEqual(["a"]);
expect(idle(["a"])).toEqual([]);
});
it("names the colour tokens", () => {
expect(colorVar("red")).toBe("var(--db-color-red)");
expect(colorVar(null)).toBeUndefined();
});
});
+16 -1
Переглянути файл
@@ -8,7 +8,14 @@ import { lazyComponent } from "@/ui";
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerEditorKind, registerOverlay, registerServiceView, registerToolWindow, toolWindowService } from "@/workbench/contributions";
import {
registerEditorKind,
registerOverlay,
registerServiceView,
registerToolWindow,
setFileDecorations,
toolWindowService,
} from "@/workbench/contributions";
import { SQL_LANGUAGE, consoleExtension, executeInView } from "./console";
import { DatabaseDialogs } from "./Dialogs";
import { TABLE_KIND, tableTitle } from "./tableInput";
@@ -18,10 +25,12 @@ import {
DB_TOOL_WINDOW,
RESULTS_VIEW,
consoleSource,
consoleTints,
loadSources,
openConsole,
openSourceDialog,
useDatabase,
watchIdleConnections,
} from "./store";
export function activate(): () => void {
@@ -72,6 +81,12 @@ export function activate(): () => void {
newMenuOpen: false,
});
}),
// Consoles of coloured data sources get a tinted tab.
useDatabase.subscribe((s, prev) => {
if (s.consoles !== prev.consoles || s.sources !== prev.sources) setFileDecorations("database", consoleTints(s));
}),
() => setFileDecorations("database", new Map()),
watchIdleConnections(),
registerDatabaseTreeActions(),
registerActions([
{
+110 -20
Переглянути файл
@@ -1,5 +1,6 @@
/** Pure helpers of the database tools: clipboard formats, edits, labels. */
import type { DbCellValue, DbDriver, DbRowEdit, DataSource } from "@/platform/ipc";
import type { DbCellValue, DbColor, DbProperty, DbRowEdit, DbSslMode, DataSource } from "@/platform/ipc";
import { vendorOf, vendorOfScheme } from "./vendors";
export type Cell = string | null;
@@ -62,14 +63,55 @@ export function fileSourceName(file: string): string {
return base.replace(/\.[^.]+$/, "") || base;
}
/** The tree / menu icon of a driver (IDEA shows the vendor's mark). */
export function driverIcon(driver: DbDriver | null | undefined): string {
return driver === "postgres" ? "db-postgres" : driver === "mysql" ? "db-mysql" : "db-sqlite";
/** The tree / menu icon of a data source (IDEA shows the vendor's mark). */
export function sourceIcon(s: Pick<DataSource, "vendor" | "driver">): string {
return vendorOf(s).icon;
}
export function sourceLabel(s: Pick<DataSource, "name" | "driver">): string {
const kind = s.driver === "postgres" ? "PostgreSQL" : s.driver === "mysql" ? "MySQL" : "SQLite";
return `${s.name} (${kind})`;
export function sourceLabel(s: Pick<DataSource, "name" | "driver" | "vendor">): string {
return `${s.name} (${vendorOf(s).label})`;
}
/** The token of a data source colour (tree rows, console tab). */
export function colorVar(color: DbColor | null | undefined): string | undefined {
return color ? `var(--db-color-${color})` : undefined;
}
/** `*` / `?` wildcard match of the Schemas tab (case-sensitive, like the server's names). */
export function matchesPattern(name: string, pattern: string): boolean {
if (!/[*?]/.test(pattern)) return name === pattern;
const re = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
return new RegExp(`^${re}$`).test(name);
}
/**
* The databases / schemas the tree shows (Schemas tab): all of them, or the
* ones matching a chosen name or pattern; `keep` (the connected database) always stays.
*/
export function shownNames(names: readonly string[], all: boolean | undefined, chosen: readonly string[] | undefined, keep?: string | null): string[] {
if (all !== false) return [...names];
const picks = (chosen ?? []).map((p) => p.trim()).filter(Boolean);
return names.filter((n) => n === keep || picks.some((p) => matchesPattern(n, p)));
}
/**
* Connected targets idle for longer than their data source's Auto-disconnect
* (`lastUsed`: ms of the last statement / introspection; `busy`: running or holding a transaction).
*/
export function idleTargets(
connected: Iterable<string>,
sources: readonly DataSource[],
lastUsed: ReadonlyMap<string, number>,
busy: (target: string) => boolean,
now: number,
): string[] {
const out: string[] = [];
for (const t of connected) {
const secs = sources.find((s) => s.id === splitTarget(t).id)?.options?.autoDisconnectSecs;
if (!secs || busy(t)) continue;
if (now - (lastUsed.get(t) ?? now) >= secs * 1000) out.push(t);
}
return out;
}
export function newSourceId(): string {
@@ -120,20 +162,36 @@ export function targetOf(id: string, database: string | null, current: string |
/** Key of the Database tree node of database `name` of data source `id`. */
export const databaseKey = (id: string, name: string) => `${id}/@${name}`;
const SSL_MODES: Record<string, DbSslMode> = {
disable: "disable",
disabled: "disable",
prefer: "prefer",
preferred: "prefer",
require: "require",
required: "require",
"verify-ca": "verifyCa",
verify_ca: "verifyCa",
"verify-full": "verifyFull",
verify_identity: "verifyFull",
"verify-identity": "verifyFull",
};
/**
* Data Source from URL: `postgres://user:pass@host:5432/db`, `mysql://…`,
* `mariadb://…`, the `jdbc:` forms of those, `jdbc:sqlite:path` / `sqlite:path`.
* Null when the URL names no supported driver.
* Data Source from URL (and the General tab's URL field): `postgres://user:pass@host:5432/db`,
* `mysql://…`, `mariadb://…`, wire-compatible products (`redshift://`, `tidb://`…), the
* `jdbc:` forms of those, `jdbc:sqlite:path` / `sqlite:path`. SSL parameters fill the
* SSL tab, other parameters become driver properties. Null when the URL names no supported driver.
*/
export function parseSourceUrl(raw: string): { source: Partial<DataSource>; password: string | null } | null {
const url = raw.trim().replace(/^jdbc:/i, "");
const sqlite = /^sqlite:(?:\/\/)?(.+)$/i.exec(url);
const sqlite = /^sqlite3?:(?:\/\/)?(.+)$/i.exec(url);
if (sqlite) {
const file = sqlite[1]!.replace(/^\/([A-Za-z]:)/, "$1");
return { source: { driver: "sqlite", file, name: fileSourceName(file) }, password: null };
}
const m = /^(postgres(?:ql)?|mysql|mariadb):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?]+|\[[^\]]+\])(?::(\d+))?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(url);
if (!m) return null;
const m = /^([a-z][a-z0-9]*):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?,]+|\[[^\]]+\])(?::(\d+))?(?:,[^/?]*)?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(url);
const vendor = m ? vendorOfScheme(m[1]!) : undefined;
if (!m || !vendor || vendor.driver === "sqlite") return null;
const dec = (s: string | undefined) => {
try {
return s ? decodeURIComponent(s) : "";
@@ -141,13 +199,45 @@ export function parseSourceUrl(raw: string): { source: Partial<DataSource>; pass
return s ?? "";
}
};
const driver = m[1]!.toLowerCase().startsWith("postgres") ? "postgres" : "mysql";
const params = new URLSearchParams(m[7] ?? "");
const user = dec(m[2]) || params.get("user") || "";
const password = m[3] !== undefined ? dec(m[3]) : params.get("password");
const port = m[5] ? parseInt(m[5], 10) : null;
const source: Partial<DataSource> = { driver, host: m[4]!, port, user, database: dec(m[6]) };
const ssl = params.get("sslmode")?.toLowerCase();
if (ssl === "disable" || ssl === "require" || ssl === "prefer") source.ssl = ssl;
if (port !== null && !(port > 0 && port < 65536)) return null;
let user = dec(m[2]);
let password = m[3] !== undefined ? dec(m[3]) : null;
const host = m[4]!.replace(/^\[(.*)\]$/, "$1");
const source: Partial<DataSource> = { driver: vendor.driver, host, port, user, database: dec(m[6]).replace(/\/$/, "") };
if (vendor.id !== vendor.driver) source.vendor = vendor.id;
const properties: DbProperty[] = [];
for (const [k, v] of new URLSearchParams(m[7] ?? "")) {
switch (k.toLowerCase()) {
case "user":
user ||= v;
break;
case "password":
password ??= v;
break;
case "sslmode":
case "ssl-mode": {
const mode = SSL_MODES[v.toLowerCase()];
if (mode) source.ssl = mode;
break;
}
case "sslrootcert":
case "ssl-ca":
source.sslCaFile = v;
break;
case "sslcert":
case "ssl-cert":
source.sslCertFile = v;
break;
case "sslkey":
case "ssl-key":
source.sslKeyFile = v;
break;
default:
properties.push({ name: k, value: v });
}
}
source.user = user;
if (properties.length) source.properties = properties;
return { source, password };
}
+40 -3
Переглянути файл
@@ -20,9 +20,9 @@ import {
type SqlStatement,
} from "@/platform/ipc";
import { editorsFor } from "@/editor/api";
import { editorService, notifications, showServiceView, toolWindowService } from "@/workbench/contributions";
import { editorService, notifications, showServiceView, toolWindowService, type FileDecoration } from "@/workbench/contributions";
import { ask, confirm } from "@/workbench/messages";
import { connectRemedy, databaseKey, newSourceId, splitTarget } from "./model";
import { colorVar, connectRemedy, databaseKey, idleTargets, newSourceId, splitTarget } from "./model";
import type { DbNode } from "./nodes";
import type { DbObjectRef } from "./objects";
import { formatted } from "./sql/format";
@@ -321,13 +321,25 @@ export async function resolveConnectError(e: unknown, source: DataSource): Promi
}
}
/** Target → time of its last use (Options › Auto-disconnect). */
const lastUsed = new Map<string, number>();
/** Options › Transaction control: a manual data source's consoles start in manual commit. */
function applyConnectDefaults(id: string) {
if (sourceById(id)?.options?.transaction === "manual" && !useDatabase.getState().manualCommit.has(id))
void setManualCommit(id, true).catch(() => {});
}
/** Runs `op`, asking for passwords / host key trust and retrying when the connection needs them. */
export async function withConnection<T>(id: string, op: () => Promise<T>): Promise<T> {
for (;;) {
try {
const r = await op();
if (!useDatabase.getState().connected.has(id))
lastUsed.set(id, Date.now());
if (!useDatabase.getState().connected.has(id)) {
useDatabase.setState((s) => ({ connected: new Set(s.connected).add(id) }));
applyConnectDefaults(id);
}
return r;
} catch (e) {
const source = sourceById(id);
@@ -360,6 +372,31 @@ export async function disconnect(id: string): Promise<void> {
});
}
/** Disconnects targets idle past their data source's Auto-disconnect; returns the stopper. */
export function watchIdleConnections(everyMs = 5000): () => void {
const timer = setInterval(() => {
const s = useDatabase.getState();
const running = new Set(Object.keys(s.running).map((path) => s.consoles[path]));
const busy = (t: string) => running.has(t) || s.pendingTx.has(t);
for (const t of idleTargets(s.connected, s.sources, lastUsed, busy, Date.now())) {
lastUsed.delete(t);
void disconnect(t).then(() => log("info", `${targetLabel(t)}: disconnected after being idle`));
}
}, everyMs);
return () => clearInterval(timer);
}
/** Console files of coloured data sources, tinted like IDEA's (file decorations). */
export function consoleTints(s: Pick<DatabaseState, "consoles" | "sources">): Map<string, FileDecoration> {
const out = new Map<string, FileDecoration>();
for (const [path, target] of Object.entries(s.consoles)) {
const id = splitTarget(target).id;
const tint = colorVar(s.sources.find((x) => x.id === id)?.color);
if (tint) out.set(path, { tint });
}
return out;
}
/** PostgreSQL data sources show their server's databases (IDEA's data source › database › schema). */
export function listsDatabases(source: DataSource | undefined): boolean {
return source?.driver === "postgres";
+4 -9
Переглянути файл
@@ -1,7 +1,7 @@
/** Menus of the Database tool window, laid out like IDEA's (New ▸, Copy/Paste ▸, SQL Scripts ▸ …). */
import type { DbDriver } from "@/platform/ipc";
import type { MenuItem } from "@/ui";
import { driverIcon, fileSourceName } from "./model";
import { fileSourceName } from "./model";
import { VENDORS, vendorPreset } from "./vendors";
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
import { SCRIPT_LABELS, canDrop, canRename, type ScriptKind } from "./objects";
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
@@ -21,18 +21,13 @@ async function sqliteFromFile() {
if (typeof file === "string") openSourceDialog(null, { driver: "sqlite", file, name: fileSourceName(file) });
}
/** New ▸ Data Source ▸ — the drivers the backend speaks. */
/** New ▸ Data Source ▸ — the drivers the backend speaks and wire-compatible products, A–Z like IDEA's. */
function dataSourceItems(): MenuItem[] {
const preset = (label: string, driver: DbDriver): MenuItem => ({
label,
icon: driverIcon(driver),
run: () => openSourceDialog(null, { driver }),
});
return [
{
label: "Data Source",
icon: "database",
submenu: [preset("MariaDB", "mysql"), preset("MySQL", "mysql"), preset("PostgreSQL", "postgres"), preset("SQLite", "sqlite")],
submenu: VENDORS.map((v) => ({ label: v.label, icon: v.icon, run: () => openSourceDialog(null, vendorPreset(v)) })),
},
{ label: "Data Source from File…", icon: "folder", run: () => void sqliteFromFile() },
{ label: "Data Source from URL…", icon: "external-link", run: () => openDialog({ kind: "sourceUrl" }) },
+86
Переглянути файл
@@ -0,0 +1,86 @@
/**
* Data source types of New ▸ Data Source: the three drivers the backend speaks,
* and products that speak their wire protocol (a preset of port, icon and URL scheme).
*/
import type { DataSource, DbDriver, DbSslMode } from "@/platform/ipc";
export interface DbVendor {
/** Stored in `DataSource.vendor`; a driver's own entry has the driver's id. */
id: string;
label: string;
driver: DbDriver;
port: number | null;
icon: string;
/** JDBC URL scheme (`jdbc:<scheme>://…`). */
scheme: string;
/** Managed services that only accept encrypted connections. */
ssl?: DbSslMode;
}
export const VENDORS: readonly DbVendor[] = [
{ id: "aurora-mysql", label: "Amazon Aurora MySQL", driver: "mysql", port: 3306, icon: "db-aurora", scheme: "mysql" },
{ id: "redshift", label: "Amazon Redshift", driver: "postgres", port: 5439, icon: "db-redshift", scheme: "redshift", ssl: "require" },
{ id: "cockroachdb", label: "CockroachDB", driver: "postgres", port: 26257, icon: "db-cockroach", scheme: "postgresql" },
{ id: "greenplum", label: "Greenplum", driver: "postgres", port: 5432, icon: "db-greenplum", scheme: "postgresql" },
{ id: "mariadb", label: "MariaDB", driver: "mysql", port: 3306, icon: "db-mariadb", scheme: "mariadb" },
{ id: "mysql", label: "MySQL", driver: "mysql", port: 3306, icon: "db-mysql", scheme: "mysql" },
{ id: "neon", label: "Neon", driver: "postgres", port: 5432, icon: "db-neon", scheme: "postgresql", ssl: "require" },
{ id: "oceanbase", label: "OceanBase (MySQL mode)", driver: "mysql", port: 2881, icon: "db-oceanbase", scheme: "oceanbase" },
{ id: "postgres", label: "PostgreSQL", driver: "postgres", port: 5432, icon: "db-postgres", scheme: "postgresql" },
{ id: "singlestore", label: "SingleStore", driver: "mysql", port: 3306, icon: "db-singlestore", scheme: "singlestore" },
{ id: "sqlite", label: "SQLite", driver: "sqlite", port: null, icon: "db-sqlite", scheme: "sqlite" },
{ id: "supabase", label: "Supabase", driver: "postgres", port: 5432, icon: "db-supabase", scheme: "postgresql", ssl: "require" },
{ id: "tidb", label: "TiDB", driver: "mysql", port: 4000, icon: "db-tidb", scheme: "mysql" },
{ id: "timescaledb", label: "TimescaleDB", driver: "postgres", port: 5432, icon: "db-timescale", scheme: "postgresql" },
{ id: "yugabytedb", label: "YugabyteDB", driver: "postgres", port: 5433, icon: "db-yugabyte", scheme: "postgresql" },
];
const BY_ID = new Map(VENDORS.map((v) => [v.id, v]));
/** The type of a data source: its preset, else its driver's own entry. */
export function vendorOf(s: Pick<DataSource, "vendor" | "driver">): DbVendor {
const v = s.vendor ? BY_ID.get(s.vendor) : undefined;
return v && v.driver === (s.driver ?? "sqlite") ? v : BY_ID.get(s.driver ?? "sqlite")!;
}
export const vendorById = (id: string): DbVendor | undefined => BY_ID.get(id);
/** The fields a new data source of type `v` starts with. */
export function vendorPreset(v: DbVendor): Partial<DataSource> {
const preset: Partial<DataSource> = { driver: v.driver, vendor: v.id === v.driver ? "" : v.id, port: v.port };
if (v.ssl) preset.ssl = v.ssl;
return preset;
}
/** URL schemes (besides the JDBC ones) that name a type. */
const SCHEMES: Record<string, string> = {
postgres: "postgres",
postgresql: "postgres",
pgsql: "postgres",
redshift: "redshift",
cockroachdb: "cockroachdb",
cockroach: "cockroachdb",
yugabytedb: "yugabytedb",
timescaledb: "timescaledb",
greenplum: "greenplum",
mysql: "mysql",
mariadb: "mariadb",
tidb: "tidb",
singlestore: "singlestore",
memsql: "singlestore",
oceanbase: "oceanbase",
};
export const vendorOfScheme = (scheme: string): DbVendor | undefined => BY_ID.get(SCHEMES[scheme.toLowerCase()] ?? "");
const bracketed = (host: string) => (host.includes(":") && !host.startsWith("[") ? `[${host}]` : host);
/** The JDBC URL of a data source's fields (the General tab's URL preview). */
export function sourceUrl(s: DataSource): string {
const v = vendorOf(s);
if (v.driver === "sqlite") return `jdbc:sqlite:${s.file ?? ""}`;
const host = bracketed(s.host?.trim() || "localhost");
const port = s.port ?? v.port;
const db = s.database ? `/${encodeURIComponent(s.database)}` : "";
return `jdbc:${v.scheme}://${host}${port ? `:${port}` : ""}${db}`;
}
+45 -6
Переглянути файл
@@ -5,10 +5,16 @@
* (`ide-http`).
*/
import { activeEditor, registerEditorContribution, registerLanguageMode } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { executeAction, getAction, registerActions } from "@/platform/actions";
import { call, commands, emitLocalIdeEvent, IpcError, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { editorService, notifications, registerServiceView, showServiceView } from "@/workbench/contributions";
import {
editorService,
notifications,
registerMenuGroup,
registerServiceView,
showServiceView,
} from "@/workbench/contributions";
import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } from "./editor";
import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView";
import { httpLanguageDescription } from "./language";
@@ -44,11 +50,31 @@ async function runAtCaret() {
await runRequest(filePath(e.ctx.path, e.ctx.virtual), e.view.state.doc.toString(), r.index);
}
/** New HTTP Request: a scratch `requests[_n].http` with an example request. */
async function newRequest() {
/** New › HTTP Request in `directory`: the name prompt of the file templates (features/templates), with the example body. */
function newRequestArg(directory: string) {
return {
directory,
template: {
id: "http-request",
name: "HTTP Request",
icon: "file-http",
extension: "http",
nameStyle: "asis",
kinds: [{ id: "request", label: "HTTP Request", body: NEW_REQUEST_TEMPLATE }],
},
};
}
function directoryArg(arg: unknown): string | null {
const dir = (arg as { directory?: unknown } | undefined)?.directory;
return typeof dir === "string" && dir ? dir : null;
}
/** New HTTP Request: `requests[_n].http` in `directory`, else a scratch, with an example request. */
async function newRequest(directory?: string) {
try {
const { appDataDir } = await import("@tauri-apps/api/path");
const dir = `${(await appDataDir()).replace(/\/+$/, "")}/scratches`;
const dir = directory ?? `${(await appDataDir()).replace(/\/+$/, "")}/scratches`;
try {
await call(commands.fsCreateDir(dir));
} catch (e) {
@@ -88,6 +114,13 @@ export function activate(): () => void {
component: HttpClientView,
headerActions: HttpClientHeaderActions,
}),
registerMenuGroup({
id: "http.new",
menus: ["new"],
order: 20,
items: (_menu, { paths: [directory] }) =>
directory ? [{ kind: "action", action: "http.newRequest", arg: { directory }, label: "HTTP Request" }] : [],
}),
registerActions([
{
id: "http.runRequest",
@@ -115,7 +148,13 @@ export function activate(): () => void {
icon: "file-http",
keywords: ["http client", "rest", "scratch", "curl"],
when: "projectOpen",
run: () => (isTauri() ? newRequest() : undefined),
// `{ directory }` (New › HTTP Request): ask for the name there, like IDEA.
run: async ({ arg }) => {
const directory = directoryArg(arg);
if (directory && getAction("templates.newFromTemplate"))
await executeAction("templates.newFromTemplate", newRequestArg(directory));
else if (isTauri()) await newRequest(directory ?? undefined);
},
},
{
id: "http.showToolWindow",
+6
Переглянути файл
@@ -12,6 +12,8 @@ import { activate as codeInsight } from "./code-insight";
import { activate as vcs } from "./vcs";
import { activate as terminal } from "./terminal";
import { activate as run } from "./run";
import { activate as runAnything } from "./run-anything";
import { activate as macros } from "./macros";
import { activate as debuggerFeature } from "./debugger";
import { activate as coverage } from "./coverage";
import { activate as profiler } from "./profiler";
@@ -25,6 +27,7 @@ import { activate as perf } from "./perf";
import { activate as plugins, activateSettingsPages as pluginsPages } from "./plugins";
import { activate as tools, activateSettingsPages as toolsPages } from "./tools";
import { activate as scratch } from "./scratch";
import { activate as libraries } from "./libraries";
import { activate as viewers } from "./viewers";
import { activate as web } from "./web";
import { activate as analysis } from "./analysis";
@@ -45,6 +48,8 @@ const features: Array<[string, Activate]> = [
["vcs", vcs],
["terminal", terminal],
["run", run],
["run-anything", runAnything],
["macros", macros],
["debugger", debuggerFeature],
["coverage", coverage],
["profiler", profiler],
@@ -58,6 +63,7 @@ const features: Array<[string, Activate]> = [
["plugins", plugins],
["tools", tools],
["scratch", scratch],
["libraries", libraries],
["viewers", viewers],
["web", web],
["analysis", analysis],
+151
Переглянути файл
@@ -0,0 +1,151 @@
/**
* External Libraries: the project's SDK / library source folders (the Python
* interpreter's standard library and site-packages, the JDK, node_modules
* packages, Cargo crates and the Rust standard library, GOROOT and Go modules,
* Maven / Gradle dependency jars) as a Project view root. Their files open
* read-only, like IDEA's library sources. Archives (the JDK's `src.zip`, jars)
* are browsed through the backend's `<archive>!/<entry>` paths.
*/
import { commands, type LibraryGroup } from "@/bindings";
import { setLibraryRoots } from "@/editor/api";
import { call, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { registerProjectViewRoot, type ProjectViewRootContribution } from "@/workbench/contributions";
export const EXTERNAL_LIBRARIES_LABEL = "External Libraries";
const GROUP_ICONS: Record<string, string> = {
python: "file-python",
jdk: "file-java",
nodeCore: "file-js",
nodeModules: "file-npm",
rust: "file-rust",
cargo: "file-cargo",
go: "file-go",
goModules: "file-gomod",
maven: "maven-project",
gradle: "file-gradle",
};
/** An archive's root (`/jdk/lib/src.zip!`): its entries live below `<archive>!/`. */
const isArchiveRoot = (path: string) => path.endsWith("!");
/** Files whose change can change the libraries (the backend re-detects only the affected ecosystem). */
const MANIFESTS = new Set([
"pyvenv.cfg",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
"package.json",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"bun.lockb",
"Cargo.toml",
"Cargo.lock",
"rust-toolchain",
"rust-toolchain.toml",
"go.mod",
"go.sum",
"go.work",
]);
/** Whether an `fsChanged` event touches a library manifest. */
export function touchesManifest(paths: readonly string[]): boolean {
return paths.some(
(p) =>
!/[\\/]node_modules[\\/]/.test(p) && MANIFESTS.has(p.slice(Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) + 1)),
);
}
/** The "External Libraries" node for `groups` (`< Python 3.12 (.venv) >` › `python3.12`, `site-packages`). */
export function librariesRoot(groups: readonly LibraryGroup[]): ProjectViewRootContribution | null {
if (!groups.length) return null;
return {
id: "externalLibraries",
label: EXTERNAL_LIBRARIES_LABEL,
icon: "library",
// After the project, before Scratches and Consoles (IDEA's order).
order: 5,
readOnly: true,
children: groups.map((g) => ({
label: `< ${g.name} >`,
icon: GROUP_ICONS[g.kind] ?? "library",
children: g.roots.map((r) => ({
label: r.name,
icon: isArchiveRoot(r.path) ? "file-archive" : "folder-library",
path: r.path,
detail: "library root",
})),
})),
};
}
/** Settings that change which SDKs the project uses. */
const sdkKey = (s: ReturnType<typeof useProject.getState>) => {
const l = s.settings?.languages;
return JSON.stringify([
s.project?.root ?? null,
l?.python?.interpreter,
l?.java?.jdk,
l?.node?.interpreter,
l?.rust?.toolchain,
l?.rust?.stdlib,
l?.go?.goroot,
l?.go?.gopath,
]);
};
const MANIFEST_REFRESH_DELAY = 500;
export function activate(): () => void {
let disposeRoot = () => {};
let disposeReadOnly = () => {};
let seq = 0;
let shown = "";
const clear = () => {
disposeRoot();
disposeReadOnly();
disposeRoot = disposeReadOnly = () => {};
shown = "";
};
const refresh = () => {
const id = ++seq;
if (!useProject.getState().project || !isTauri()) return clear();
call(commands.sdkProjectLibraries())
.then((groups) => {
if (id !== seq) return;
const key = JSON.stringify(groups);
if (key === shown) return;
clear();
shown = key;
const root = librariesRoot(groups);
if (!root) return;
disposeReadOnly = setLibraryRoots(
"externalLibraries",
groups.flatMap((g) => g.roots.map((r) => r.path)),
);
disposeRoot = registerProjectViewRoot(root);
})
.catch((e: unknown) => console.warn("external libraries: cannot list SDK roots", e));
};
refresh();
const unsub = useProject.subscribe((s, prev) => {
if (sdkKey(s) !== sdkKey(prev)) refresh();
});
let timer: ReturnType<typeof setTimeout> | undefined;
const unsubFs = onIdeEvent("fsChanged", (e) => {
if (!touchesManifest([...e.created, ...e.modified, ...e.removed])) return;
clearTimeout(timer);
timer = setTimeout(refresh, MANIFEST_REFRESH_DELAY);
});
return () => {
seq++;
clearTimeout(timer);
unsub();
unsubFs();
clear();
};
}
+84
Переглянути файл
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it } from "vitest";
import { isLibraryPath, setLibraryRoots } from "@/editor/api";
import { librariesRoot, touchesManifest } from "./index";
describe("External Libraries", () => {
let dispose = () => {};
afterEach(() => dispose());
it("groups each SDK's folders under one read-only root", () => {
expect(librariesRoot([])).toBeNull();
const root = librariesRoot([
{
name: "Python 3.12 (.venv)",
kind: "python",
roots: [
{ name: "python3.12", path: "/py/lib/python3.12" },
{ name: "site-packages", path: "/p/.venv/lib/python3.12/site-packages" },
],
},
])!;
expect(root).toMatchObject({ id: "externalLibraries", label: "External Libraries", readOnly: true });
expect(root.children?.[0]).toMatchObject({ label: "< Python 3.12 (.venv) >", icon: "file-python" });
expect(root.children?.[0]?.children?.map((c) => [c.label, c.path, c.detail])).toEqual([
["python3.12", "/py/lib/python3.12", "library root"],
["site-packages", "/p/.venv/lib/python3.12/site-packages", "library root"],
]);
});
it("gives each ecosystem's groups their icon", () => {
const root = librariesRoot(
["jdk", "nodeCore", "nodeModules", "rust", "cargo", "go", "goModules", "maven", "gradle", "other"].map((kind) => ({
name: kind,
kind,
roots: [{ name: "r", path: `/libs/${kind}` }],
})),
)!;
expect(root.children?.map((g) => g.icon)).toEqual([
"file-java",
"file-js",
"file-npm",
"file-rust",
"file-cargo",
"file-go",
"file-gomod",
"maven-project",
"file-gradle",
"library",
]);
});
it("lists archives (src.zip, dependency jars) as browsable roots", () => {
const root = librariesRoot([
{
name: "temurin-21",
kind: "jdk",
roots: [
{ name: "temurin-21", path: "/jdk" },
{ name: "src.zip", path: "/jdk/lib/src.zip!" },
],
},
{ name: "Maven: g:a:1", kind: "maven", roots: [{ name: "a-1-sources.jar", path: "/m2/g/a/1/a-1-sources.jar!" }] },
])!;
expect(root.children?.[0]?.children?.map((c) => c.icon)).toEqual(["folder-library", "file-archive"]);
expect(root.children?.[1]).toMatchObject({ label: "< Maven: g:a:1 >", icon: "maven-project" });
dispose = setLibraryRoots("test", ["/jdk/lib/src.zip!"]);
expect(isLibraryPath("/jdk/lib/src.zip!/java.base/java/lang/String.java")).toBe(true);
});
it("refreshes on manifest changes outside node_modules", () => {
expect(touchesManifest(["/p/src/main.rs", "/p/Cargo.toml"])).toBe(true);
expect(touchesManifest(["C:\\p\\go.mod"])).toBe(true);
expect(touchesManifest(["/p/.venv/pyvenv.cfg"])).toBe(true);
expect(touchesManifest(["/p/node_modules/react/package.json"])).toBe(false);
expect(touchesManifest(["/p/src/package.json.bak", "/p/README.md"])).toBe(false);
});
it("marks files below library roots for read-only editors", () => {
dispose = setLibraryRoots("test", ["/py/lib/python3.12"]);
expect(isLibraryPath("/py/lib/python3.12/os.py")).toBe(true);
expect(isLibraryPath("/py/lib/python3.120/x.py")).toBe(false);
dispose();
expect(isLibraryPath("/py/lib/python3.12/os.py")).toBe(false);
});
});
+111
Переглянути файл
@@ -0,0 +1,111 @@
/** The "Macro recording" status bar item and the Edit Macros dialog. */
import { useState } from "react";
import { create } from "zustand";
import { executeAction, getAction } from "@/platform/actions";
import { t } from "@/platform/i18n";
import { BarButton, Button, Dialog, Icon, List, T } from "@/ui";
import { confirm } from "@/workbench/messages";
import { promptInput } from "@/workbench/inputDialogs";
import { deleteMacro, describeStep, renameMacro, useMacros } from "./model";
import styles from "./macros.module.css";
const useEditOpen = create<{ open: boolean }>(() => ({ open: false }));
export const showEditMacros = () => useEditOpen.setState({ open: true });
const actionTitle = (id: string) => getAction(id)?.title ?? id;
/** Shown while recording (IDEA: "Macro recording started" in the status bar); click stops. */
export function MacroStatus() {
const steps = useMacros((s) => s.recording?.length);
if (steps === undefined) return null;
return (
<BarButton
className={styles.status}
tooltip="Stop Macro Recording"
aria-label="Stop Macro Recording"
onClick={() => void executeAction("macro.stopRecording")}
>
<span className={styles.dot} aria-hidden="true" />
<T>Recording macro…</T>
<Icon name="stop" size={14} />
</BarButton>
);
}
export function MacrosDialogHost() {
const open = useEditOpen((s) => s.open);
return open ? <EditMacrosDialog onClose={() => useEditOpen.setState({ open: false })} /> : null;
}
function EditMacrosDialog({ onClose }: { onClose: () => void }) {
const saved = useMacros((s) => s.saved);
const [active, setActive] = useState(0);
const macro = saved[Math.min(active, saved.length - 1)];
const rename = async () => {
if (!macro) return;
const name = await promptInput({
title: "Rename Macro",
label: "New name:",
initial: macro.name,
okLabel: "Rename",
validate: (v) =>
!v.trim()
? "Enter a macro name"
: v.trim() !== macro.name && saved.some((m) => m.name === v.trim())
? "A macro with this name exists"
: null,
});
if (name) renameMacro(macro.name, name);
};
const remove = async () => {
if (!macro) return;
if (await confirm({ title: "Delete Macro", message: `Delete the macro '${macro.name}'?`, ok: "Delete", icon: "warning" }))
deleteMacro(macro.name);
};
return (
<Dialog
open
title="Edit Macros"
onClose={onClose}
width={620}
buttons={[{ label: t("common.close"), role: "cancel" }]}
>
<div className={styles.edit}>
<div className={styles.column}>
<List
items={saved}
getKey={(m) => m.name}
activeIndex={Math.min(active, saved.length - 1)}
onActiveChange={setActive}
rowHeight={24}
className={styles.list}
aria-label="Macros"
empty={
<div className={styles.empty}>
<T>No saved macros</T>
</div>
}
renderItem={(m) => <div className={styles.row}>{m.name}</div>}
/>
<div className={styles.buttons}>
<Button disabled={!macro} onClick={() => void rename()}>
<T>Rename…</T>
</Button>
<Button disabled={!macro} onClick={() => void remove()}>
<T>Delete</T>
</Button>
</div>
</div>
<ol className={styles.steps} aria-label="Recorded steps">
{macro?.steps.map((step, i) => (
<li key={i} className={styles.step}>
{describeStep(step, actionTitle)}
</li>
))}
</ol>
</div>
</Dialog>
);
}
+89
Переглянути файл
@@ -0,0 +1,89 @@
/**
* Editing and caret keys a macro records in the editor (keys the IDE keymap
* does not bind run CodeMirror commands directly), and their replay.
*/
import {
cursorCharLeft,
cursorCharRight,
cursorDocEnd,
cursorDocStart,
cursorGroupLeft,
cursorGroupRight,
cursorLineBoundaryBackward,
cursorLineBoundaryForward,
cursorLineDown,
cursorLineUp,
cursorPageDown,
cursorPageUp,
deleteCharBackward,
deleteCharForward,
deleteGroupBackward,
deleteGroupForward,
indentLess,
insertNewlineAndIndent,
insertTab,
selectCharLeft,
selectCharRight,
selectDocEnd,
selectDocStart,
selectGroupLeft,
selectGroupRight,
selectLineBoundaryBackward,
selectLineBoundaryForward,
selectLineDown,
selectLineUp,
selectPageDown,
selectPageUp,
} from "@codemirror/commands";
import type { Command } from "@codemirror/view";
/** Recorded key name (`Shift+ArrowLeft`, `Alt+Backspace`) → command. */
export const EDITOR_KEY_COMMANDS: Record<string, Command> = {
Enter: insertNewlineAndIndent,
Backspace: deleteCharBackward,
Delete: deleteCharForward,
"Alt+Backspace": deleteGroupBackward,
"Alt+Delete": deleteGroupForward,
Tab: insertTab,
"Shift+Tab": indentLess,
ArrowLeft: cursorCharLeft,
ArrowRight: cursorCharRight,
ArrowUp: cursorLineUp,
ArrowDown: cursorLineDown,
"Shift+ArrowLeft": selectCharLeft,
"Shift+ArrowRight": selectCharRight,
"Shift+ArrowUp": selectLineUp,
"Shift+ArrowDown": selectLineDown,
"Alt+ArrowLeft": cursorGroupLeft,
"Alt+ArrowRight": cursorGroupRight,
"Alt+Shift+ArrowLeft": selectGroupLeft,
"Alt+Shift+ArrowRight": selectGroupRight,
Home: cursorLineBoundaryBackward,
End: cursorLineBoundaryForward,
"Shift+Home": selectLineBoundaryBackward,
"Shift+End": selectLineBoundaryForward,
PageUp: cursorPageUp,
PageDown: cursorPageDown,
"Shift+PageUp": selectPageUp,
"Shift+PageDown": selectPageDown,
"Meta+ArrowLeft": cursorLineBoundaryBackward,
"Meta+ArrowRight": cursorLineBoundaryForward,
"Meta+Shift+ArrowLeft": selectLineBoundaryBackward,
"Meta+Shift+ArrowRight": selectLineBoundaryForward,
"Meta+ArrowUp": cursorDocStart,
"Meta+ArrowDown": cursorDocEnd,
"Ctrl+Home": cursorDocStart,
"Ctrl+End": cursorDocEnd,
"Ctrl+Shift+Home": selectDocStart,
"Ctrl+Shift+End": selectDocEnd,
"Ctrl+ArrowLeft": cursorGroupLeft,
"Ctrl+ArrowRight": cursorGroupRight,
};
/** The recorded name of a key event, or null when it is not an editor key a macro records. */
export function editorKeyName(e: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">): string | null {
const name = [e.metaKey && "Meta", e.ctrlKey && "Ctrl", e.altKey && "Alt", e.shiftKey && "Shift", e.key]
.filter(Boolean)
.join("+");
return name in EDITOR_KEY_COMMANDS ? name : null;
}
+200
Переглянути файл
@@ -0,0 +1,200 @@
/**
* Edit › Macros: Start / Stop Macro Recording, Play Back Last Macro, Play
* Saved Macros… and Edit Macros…. Every saved macro is also an action
* (`macros.play.<name>`), so it can get a shortcut in the keymap.
*/
import { EditorView } from "@codemirror/view";
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { executeAction, onActionExecuted, registerActions, type Action } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { notifications, registerOverlay, registerStatusItem } from "@/workbench/contributions";
import { chooseItem, promptInput } from "@/workbench/inputDialogs";
import { EDITOR_KEY_COMMANDS, editorKeyName } from "./editorKeys";
import { MacroStatus, MacrosDialogHost, showEditMacros } from "./MacrosDialog";
import {
playSteps,
recordStep,
saveMacro,
startRecording,
stopRecording,
storableArg,
useMacros,
type MacroStep,
} from "./model";
/** Records typing and editor keys of every text editor while a macro records. */
const recorder = [
EditorView.inputHandler.of((_view, _from, _to, text) => {
if (useMacros.getState().recording && !useMacros.getState().playing) recordStep({ kind: "text", text });
return false;
}),
EditorView.domEventHandlers({
keydown(e) {
// Keys the IDE keymap ran as actions were recorded as those actions.
if (e.defaultPrevented || e.isComposing || !useMacros.getState().recording) return false;
const key = editorKeyName(e);
if (key) recordStep({ kind: "key", key });
return false;
},
}),
];
function typeText(text: string): boolean {
const view = activeEditor()?.view;
if (!view) return false;
const { from, to } = view.state.selection.main;
const insert = () => view.state.update(view.state.replaceSelection(text), { userEvent: "input.type" });
// Input handlers (auto-closing brackets, completion triggers) see it as typed.
const handled = view.state.facet(EditorView.inputHandler).some((h) => h(view, from, to, text, insert));
if (!handled) view.dispatch(insert());
return true;
}
function pressKey(key: string): boolean {
const view = activeEditor()?.view;
const command = EDITOR_KEY_COMMANDS[key];
if (!view || !command) return false;
command(view);
return true;
}
export async function playMacro(steps: readonly MacroStep[], name?: string) {
const ok = await playSteps(steps, { action: (id, arg) => executeAction(id, arg), text: typeText, key: pressKey });
if (!ok)
notifications().notify({
level: "warning",
title: name ? `Macro '${name}' stopped` : "Macro playback stopped",
body: "A recorded step could not run here.",
key: "macro.playback",
});
}
async function stop() {
const steps = stopRecording();
if (!steps.length) {
notifications().notify({ level: "info", title: "Nothing was recorded", key: "macro.recording" });
return;
}
const name = await promptInput({
title: "Enter Macro Name",
label: "Name (leave empty to keep it only as the last macro):",
okLabel: "OK",
validate: () => null,
});
if (name) saveMacro(name, steps);
}
const playActionId = (name: string) => `macros.play.${name}`;
/** One action per saved macro, kept in step with the saved list. */
function registerSavedMacroActions(): () => void {
let dispose = () => {};
let names = "";
const sync = () => {
const saved = useMacros.getState().saved;
const key = saved.map((m) => m.name).join("\n");
if (key === names) return;
names = key;
dispose();
dispose = registerActions(
saved.map(
(m): Action => ({
id: playActionId(m.name),
title: m.name,
category: "Macros",
keywords: ["macro", "play"],
when: "!macroRecording",
run: () => {
const macro = useMacros.getState().saved.find((x) => x.name === m.name);
if (macro) return playMacro(macro.steps, macro.name);
},
}),
),
);
};
sync();
const unsub = useMacros.subscribe(sync);
return () => {
unsub();
dispose();
};
}
export function activate(): () => void {
const publish = () => {
const s = useMacros.getState();
setContext("macroRecording", s.recording !== null);
setContext("macroLastAvailable", !!s.last?.length);
setContext("macrosSaved", s.saved.length > 0);
};
publish();
const disposers = [
useMacros.subscribe(publish),
onActionExecuted((id, arg) => {
const s = useMacros.getState();
if (!s.recording || s.playing || id.startsWith("macro.") || id.startsWith("macros.")) return;
const stored = storableArg(arg);
// Actions run with an argument that cannot be stored (a menu's context) are left out.
if (stored === null) return;
recordStep(stored === undefined ? { kind: "action", id } : { kind: "action", id, arg: stored });
}),
registerEditorContribution({ id: "macros.recorder", order: 0, extensions: () => recorder }),
registerOverlay({ id: "macros.dialogs", component: MacrosDialogHost }),
registerStatusItem({ id: "macros.recording", align: "left", priority: 4, component: MacroStatus }),
registerSavedMacroActions(),
registerActions([
{
id: "macro.startRecording",
title: "Start Macro Recording",
category: "Edit",
keywords: ["macro", "record"],
when: "!macroRecording",
run: startRecording,
},
{
id: "macro.stopRecording",
title: "Stop Macro Recording",
category: "Edit",
icon: "stop",
keywords: ["macro", "record"],
when: "macroRecording",
run: stop,
},
{
id: "macro.playBack",
title: "Play Back Last Macro",
category: "Edit",
keywords: ["macro", "replay"],
when: "!macroRecording && macroLastAvailable",
run: () => {
const last = useMacros.getState().last;
if (last) return playMacro(last);
},
},
{
id: "macro.playSaved",
title: "Play Saved Macros…",
category: "Edit",
keywords: ["macro", "replay"],
when: "!macroRecording && macrosSaved",
run: async () => {
const name = await chooseItem({ title: "Play Saved Macro", items: useMacros.getState().saved.map((m) => m.name) });
const macro = useMacros.getState().saved.find((m) => m.name === name);
if (macro) await playMacro(macro.steps, macro.name);
},
},
{
id: "macro.edit",
title: "Edit Macros…",
category: "Edit",
keywords: ["macro", "rename", "delete"],
when: "!macroRecording && macrosSaved",
run: showEditMacros,
},
]),
];
return () => {
if (useMacros.getState().recording) stopRecording();
disposers.reverse().forEach((d) => d());
};
}
+58
Переглянути файл
@@ -0,0 +1,58 @@
.status {
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
}
.edit {
display: grid;
grid-template-columns: 220px 1fr;
gap: 12px;
height: 300px;
padding: 14px;
}
.column {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.list {
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: var(--radius-control);
}
.row {
padding: 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.buttons {
display: flex;
gap: 6px;
}
.empty {
padding: 6px 8px;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.steps {
margin: 0;
padding: 6px 8px 6px 32px;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-control);
font-family: var(--font-mono);
font-size: var(--fs-code);
color: var(--fg);
}
.step {
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
+112
Переглянути файл
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it } from "vitest";
import { editorKeyName } from "./editorKeys";
import {
_reloadMacros,
deleteMacro,
describeStep,
playSteps,
recordStep,
renameMacro,
saveMacro,
startRecording,
stopRecording,
storableArg,
STORAGE_KEY,
useMacros,
} from "./model";
beforeEach(() => {
localStorage.clear();
_reloadMacros();
});
describe("macros", () => {
it("records actions, merged typing and keys only while recording", () => {
recordStep({ kind: "text", text: "ignored" });
startRecording();
recordStep({ kind: "text", text: "he" });
recordStep({ kind: "text", text: "llo" });
recordStep({ kind: "key", key: "Enter" });
recordStep({ kind: "action", id: "editor.duplicateLine" });
const steps = stopRecording();
expect(steps).toEqual([
{ kind: "text", text: "hello" },
{ kind: "key", key: "Enter" },
{ kind: "action", id: "editor.duplicateLine" },
]);
expect(useMacros.getState().last).toEqual(steps);
// An empty recording keeps the last macro.
startRecording();
stopRecording();
expect(useMacros.getState().last).toEqual(steps);
});
it("persists saved macros; renames and deletes them", () => {
saveMacro("Dup", [{ kind: "action", id: "editor.duplicateLine" }]);
saveMacro("Two", []);
expect(renameMacro("Dup", "Two")).toBe(false);
expect(renameMacro("Dup", "Duplicate")).toBe(true);
_reloadMacros();
expect(useMacros.getState().saved.map((m) => m.name)).toEqual(["Duplicate", "Two"]);
deleteMacro("Two");
_reloadMacros();
expect(useMacros.getState().saved.map((m) => m.name)).toEqual(["Duplicate"]);
});
it("drops malformed stored steps", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ last: [{ kind: "text", text: "a" }, { kind: "action" }, { kind: "action", id: "x", arg: {} }], saved: [{ name: "" }] }),
);
_reloadMacros();
expect(useMacros.getState().last).toEqual([{ kind: "text", text: "a" }]);
expect(useMacros.getState().saved).toEqual([]);
});
it("plays steps in order and stops at the first that cannot run; nothing is recorded meanwhile", async () => {
const log: string[] = [];
startRecording();
const ok = await playSteps(
[
{ kind: "action", id: "a", arg: 1 },
{ kind: "text", text: "t" },
{ kind: "key", key: "Tab" },
{ kind: "action", id: "b" },
],
{
action: async (id, arg) => {
recordStep({ kind: "action", id });
log.push(`${id}${arg ?? ""}`);
return id !== "b";
},
text: (t) => (log.push(t), true),
key: (k) => (log.push(k), true),
},
);
expect(ok).toBe(false);
expect(log).toEqual(["a1", "t", "Tab", "b"]);
expect(useMacros.getState().recording).toEqual([]);
expect(useMacros.getState().playing).toBe(false);
});
it("stores plain action arguments only and describes steps", () => {
expect(storableArg("x")).toBe("x");
expect(storableArg(2)).toBe(2);
expect(storableArg(undefined)).toBeUndefined();
expect(storableArg({ path: "/a" })).toBeNull();
expect(storableArg(Number.NaN)).toBeNull();
const title = (id: string) => (id === "a" ? "Action A" : id);
expect(describeStep({ kind: "action", id: "a", arg: "x" }, title)).toBe("Action A (x)");
expect(describeStep({ kind: "text", text: "a\nb" }, title)).toBe('Type "a↵b"');
expect(describeStep({ kind: "key", key: "Enter" }, title)).toBe("Press Enter");
});
it("names the editor keys a macro records", () => {
const k = (key: string, mods: Partial<Record<"altKey" | "ctrlKey" | "metaKey" | "shiftKey", boolean>> = {}) =>
editorKeyName({ key, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...mods });
expect(k("Enter")).toBe("Enter");
expect(k("ArrowLeft", { shiftKey: true, altKey: true })).toBe("Alt+Shift+ArrowLeft");
expect(k("a")).toBeNull();
expect(k("s", { metaKey: true })).toBeNull();
});
});
+159
Переглянути файл
@@ -0,0 +1,159 @@
/**
* Edit › Macros (IDEA's ActionMacroManager): a macro is the list of actions
* the user ran plus what they typed and the editing / caret keys they pressed
* in the editor. The last recording and the named macros persist in
* localStorage (per user, like live templates).
*/
import { create } from "zustand";
/** An editor key replayed as a CodeMirror command (see `editorKeys.ts`). */
export type MacroStep =
| { kind: "action"; id: string; arg?: string | number | boolean }
| { kind: "text"; text: string }
| { kind: "key"; key: string };
export interface Macro {
name: string;
steps: MacroStep[];
}
interface MacroState {
/** Steps recorded so far, while recording. */
recording: MacroStep[] | null;
/** A macro is being played back. */
playing: boolean;
/** The last recorded macro (Play Back Last Macro). */
last: MacroStep[] | null;
saved: Macro[];
}
export const STORAGE_KEY = "openidea.macros.v1";
function isStep(v: unknown): v is MacroStep {
if (!v || typeof v !== "object") return false;
const s = v as Record<string, unknown>;
if (s.kind === "action") return typeof s.id === "string" && ["undefined", "string", "number", "boolean"].includes(typeof s.arg);
if (s.kind === "text") return typeof s.text === "string";
return s.kind === "key" && typeof s.key === "string";
}
const steps = (v: unknown): MacroStep[] | null => (Array.isArray(v) ? v.filter(isStep) : null);
function read(): Pick<MacroState, "last" | "saved"> {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null") as { last?: unknown; saved?: unknown } | null;
const saved = Array.isArray(raw?.saved)
? raw.saved
.filter((m): m is { name: string; steps: unknown } => !!m && typeof m.name === "string" && !!m.name.trim())
.map((m) => ({ name: m.name, steps: steps(m.steps) ?? [] }))
: [];
return { last: steps(raw?.last), saved };
} catch {
return { last: null, saved: [] };
}
}
export const useMacros = create<MacroState>(() => ({ recording: null, playing: false, ...read() }));
function persist() {
const { last, saved } = useMacros.getState();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ last, saved }));
} catch {
/* storage unavailable */
}
}
/** Test helper: re-reads storage. */
export function _reloadMacros() {
useMacros.setState({ recording: null, playing: false, ...read() });
}
/** Appends a step to the recording; consecutive typing is merged into one text step. */
export function recordStep(step: MacroStep) {
const rec = useMacros.getState().recording;
if (!rec || useMacros.getState().playing) return;
const prev = rec[rec.length - 1];
const next =
step.kind === "text" && prev?.kind === "text"
? [...rec.slice(0, -1), { kind: "text" as const, text: prev.text + step.text }]
: [...rec, step];
useMacros.setState({ recording: next });
}
/** An action's argument when it can be stored (plain values only). */
export function storableArg(arg: unknown): string | number | boolean | undefined | null {
if (arg === undefined) return undefined;
if (typeof arg === "string" || typeof arg === "boolean" || (typeof arg === "number" && Number.isFinite(arg))) return arg;
return null;
}
export function startRecording() {
useMacros.setState({ recording: [] });
}
/** Ends the recording; returns its steps (also the new "last macro" when not empty). */
export function stopRecording(): MacroStep[] {
const rec = useMacros.getState().recording ?? [];
useMacros.setState({ recording: null, ...(rec.length ? { last: rec } : {}) });
persist();
return rec;
}
/** Saves `steps` as `name`, replacing a macro of that name. */
export function saveMacro(name: string, macroSteps: MacroStep[]) {
const n = name.trim();
if (!n) return;
const saved = useMacros.getState().saved;
const i = saved.findIndex((m) => m.name === n);
const entry = { name: n, steps: macroSteps };
useMacros.setState({ saved: i >= 0 ? saved.map((m, j) => (j === i ? entry : m)) : [...saved, entry] });
persist();
}
export function renameMacro(from: string, to: string): boolean {
const n = to.trim();
const saved = useMacros.getState().saved;
if (!n || (n !== from && saved.some((m) => m.name === n))) return false;
useMacros.setState({ saved: saved.map((m) => (m.name === from ? { ...m, name: n } : m)) });
persist();
return true;
}
export function deleteMacro(name: string) {
useMacros.setState((s) => ({ saved: s.saved.filter((m) => m.name !== name) }));
persist();
}
/** One line per step, for Edit Macros. */
export function describeStep(step: MacroStep, title: (id: string) => string): string {
switch (step.kind) {
case "action":
return step.arg === undefined ? title(step.id) : `${title(step.id)} (${String(step.arg)})`;
case "text":
return `Type "${step.text.replace(/\n/g, "↵")}"`;
case "key":
return `Press ${step.key}`;
}
}
/** Runs `macroSteps` one after another; stops at the first step that cannot run. */
export async function playSteps(
macroSteps: readonly MacroStep[],
run: { action(id: string, arg?: unknown): Promise<boolean>; text(text: string): boolean; key(key: string): boolean },
): Promise<boolean> {
if (useMacros.getState().playing) return false;
useMacros.setState({ playing: true });
try {
for (const step of macroSteps) {
const ok =
step.kind === "action" ? await run.action(step.id, step.arg) : step.kind === "text" ? run.text(step.text) : run.key(step.key);
if (!ok) return false;
// Let the UI settle (popups open, editors mount) before the next step.
await new Promise((r) => setTimeout(r, 0));
}
return true;
} finally {
useMacros.setState({ playing: false });
}
}
+10 -1
Переглянути файл
@@ -1,6 +1,15 @@
import { shortcutLabel } from "@/platform/keymap";
import { IconButton, type MenuItem } from "@/ui";
import { setViewOption, useProjectTree, type ViewOptions } from "./store";
import { VIEW_MODES, setViewMode, setViewOption, useProjectTree, type ViewOptions } from "./store";
/** The title's "Project ▾" pane switcher (IDEA: Project / Project Files / Open Files / …). */
export function useProjectTitleMenu(): { label: string; items: MenuItem[] } {
const mode = useProjectTree((s) => s.mode);
return {
label: VIEW_MODES.find(([id]) => id === mode)?.[1] ?? "Project",
items: VIEW_MODES.map(([id, label]) => ({ id, label, checked: id === mode, run: () => setViewMode(id) })),
};
}
export const OPTION_LABELS: Array<[keyof ViewOptions, string]> = [
["showHidden", "Show Hidden Files"],
+69
Переглянути файл
@@ -0,0 +1,69 @@
/* IDEA's New popup: search field over a list of file kinds. */
.popup {
width: 280px;
overflow: hidden;
display: flex;
flex-direction: column;
padding-bottom: 4px;
}
.search {
display: flex;
align-items: center;
gap: 6px;
height: var(--h-row);
padding: 0 10px;
border-bottom: 1px solid var(--border-popup);
margin-bottom: 4px;
}
.searchIcon {
flex: none;
color: var(--fg-muted);
}
.input {
flex: 1;
min-width: 0;
}
.list {
max-height: 420px;
height: auto;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.icon {
width: 16px;
flex: none;
display: inline-flex;
justify-content: center;
}
.label {
flex: 1;
min-width: 0;
color: var(--fg-emphasis);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.active .label {
color: var(--fg-strong);
}
.shortcut {
flex: none;
margin-left: 16px;
font-family: var(--font-keys);
color: var(--fg-muted);
}
.separator {
flex: 1;
height: 1px;
background: var(--border-popup);
}
.empty {
padding: 6px 10px;
color: var(--fg-faint);
font-size: var(--fs-small);
}
+47
Переглянути файл
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { NewPopupHost } from "./NewPopup";
import { closeNewPopup, openNewPopup, useNewPopup } from "./newPopupModel";
afterEach(() => act(() => closeNewPopup()));
describe("New popup", () => {
it("filters file kinds as you type and runs the selection with Enter", () => {
const python = vi.fn();
const css = vi.fn();
render(<NewPopupHost />);
act(() =>
openNewPopup({ x: 10, y: 10 }, [
{ id: "file", label: "File", icon: "file", run: () => undefined },
{ kind: "separator" },
{ id: "py", label: "Python File", icon: "file-python", run: python },
{ id: "css", label: "Stylesheet", icon: "file-css", run: css },
]),
);
const search = screen.getByRole("textbox", { name: "Search for file type" });
expect(search).toHaveAttribute("placeholder", "Search for file type");
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual(["File", "Python File", "Stylesheet"]);
fireEvent.change(search, { target: { value: "sty" } });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual(["Stylesheet"]);
fireEvent.keyDown(search, { key: "Enter" });
expect(css).toHaveBeenCalledOnce();
expect(useNewPopup.getState().request).toBeNull();
});
it("moves the selection with the arrow keys, skipping separators", () => {
const python = vi.fn();
render(<NewPopupHost />);
act(() =>
openNewPopup({ x: 10, y: 10 }, [
{ id: "file", label: "File", run: () => undefined },
{ kind: "separator" },
{ id: "py", label: "Python File", run: python },
]),
);
const search = screen.getByRole("textbox", { name: "Search for file type" });
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" });
expect(python).toHaveBeenCalledOnce();
});
});
+102
Переглянути файл
@@ -0,0 +1,102 @@
import { useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { HighlightedText, Icon, Input, List, Popup, T, firstSelectable, listNavigate, type MenuItem } from "@/ui";
import { closeNewPopup, newPopupRows, useNewPopup, type NewPopupRow } from "./newPopupModel";
import styles from "./NewPopup.module.css";
const isSeparator = (r: NewPopupRow) => !!r.separator;
/** Overlay: IDEA's "New" popup — file kinds with a type-to-filter field. */
export function NewPopupHost() {
const request = useNewPopup((s) => s.request);
if (!request) return null;
return <NewPopup key={request.seq} at={request.at} items={request.items} />;
}
function NewPopup({ at, items }: { at: { x: number; y: number }; items: MenuItem[] }) {
const [query, setQuery] = useState("");
const [active, setActive] = useState(-1);
const rows = useMemo(() => newPopupRows(items, query), [items, query]);
const current =
active >= 0 && active < rows.length && !rows[active]!.separator ? active : firstSelectable(rows, isSeparator);
const pick = (row: NewPopupRow | undefined) => {
if (!row?.run) return;
closeNewPopup();
row.run();
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const next = listNavigate(e.key === "ArrowUp" || e.key === "ArrowDown" ? e.key : "", {
items: rows,
active: current,
isSection: isSeparator,
wrap: true,
});
if (next !== null) {
e.preventDefault();
setActive(next);
} else if (e.key === "Enter") {
e.preventDefault();
pick(rows[current]);
}
};
return (
<Popup
open
anchor={at}
placement="bottom-start"
onClose={closeNewPopup}
role="dialog"
aria-label="New"
className={styles.popup}
>
<div className={styles.search}>
<Icon name="search" size={14} className={styles.searchIcon} />
<Input
variant="bare"
data-autofocus
value={query}
placeholder="Search for file type"
aria-label="Search for file type"
className={styles.input}
onChange={(e) => {
setQuery(e.target.value);
setActive(-1);
}}
onKeyDown={onKeyDown}
/>
</div>
<List
items={rows}
getKey={(r) => r.key}
isSection={isSeparator}
renderSection={() => <div className={styles.separator} />}
sectionHeight={9}
activeIndex={current}
onActiveChange={setActive}
onActivate={(r) => pick(r)}
focusable={false}
hoverSelects
rowHeight={24}
className={styles.list}
aria-label="File types"
empty={
<div className={styles.empty}>
<T>Nothing found</T>
</div>
}
renderItem={(r, state) => (
<div className={clsx(styles.row, state.active && styles.active)}>
<span className={styles.icon}>{r.icon && <Icon name={r.icon} size={16} />}</span>
<span className={styles.label}>
<HighlightedText text={r.label} indices={r.matches ?? undefined} />
</span>
{r.shortcut && <span className={styles.shortcut}>{r.shortcut}</span>}
</div>
)}
/>
</Popup>
);
}
+5
Переглянути файл
@@ -115,3 +115,8 @@
pointer-events: none;
white-space: nowrap;
}
.empty {
padding: 8px 12px;
color: var(--fg-subtle);
}
+20 -6
Переглянути файл
@@ -21,6 +21,7 @@ import clsx from "clsx";
import { useEditorRegistry } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { useTxTemplate } from "@/platform/i18n";
import { isTauri } from "@/platform/ipc";
import type { RootFolder } from "@/platform/languageSettings";
import { useEffectiveRoots } from "@/platform/projectRoots";
@@ -39,9 +40,10 @@ import {
type TreeRowState,
} from "@/ui";
import { contextMenuItems, menuGroupContext, newMenuItems } from "./contextMenu";
import { openNewPopup } from "./newPopupModel";
import { folderKind } from "./folderKind";
import { useHomeDir } from "./home";
import { nodeKeyFor, visibleInfo, type ProjectNode } from "./model";
import { keyLike, nodeKeyFor, visibleInfo, type ProjectNode } from "./model";
import { lastFileRefactoring, undoFileRefactoring } from "./fileRefactor";
import {
cancelEditing,
@@ -61,6 +63,8 @@ import {
} from "./operations";
import { isInside, nameOf, parentOf, splitExtension, tildify } from "./paths";
import {
VIEW_MODES,
autoExpand,
clearRevealRequest,
loadDir,
revealPath,
@@ -89,7 +93,7 @@ export function showNewPopup(): boolean {
const row = lead ? document.querySelector<HTMLElement>(`[data-project-key="${CSS.escape(lead)}"]`) : null;
const rect = (row ?? document.querySelector<HTMLElement>('[role="tree"][aria-label="Project"]'))?.getBoundingClientRect();
if (!rect) return false;
showContextMenu({ x: rect.left + 24, y: rect.bottom }, newMenuItems(targetFolder()));
openNewPopup({ x: rect.left + 24, y: rect.bottom }, newMenuItems(targetFolder()));
return true;
}
@@ -100,7 +104,8 @@ const loadChildren = (n: ProjectNode) => loadDir(n.path);
function iconFor(node: ProjectNode, roots: readonly RootFolder[], root: string | null): string {
if (node.placeholder) return node.placeholder === "dir" ? "folder" : "file";
if (node.root) return node.icon ?? "module";
if (node.icon) return node.icon;
if (node.root) return "module";
if (node.dir) return folderIconName(folderKind(node, roots, root));
return fileIconName(node.label);
}
@@ -310,6 +315,7 @@ function folderAt(container: HTMLElement | null, x: number, y: number): { key: s
// ---------------------------------------------------------------------------
export function ProjectView() {
const txTemplate = useTxTemplate();
const root = useProjectTree((s) => s.root);
const model = useProjectTree(selectModel);
const expanded = useProjectTree((s) => s.expanded);
@@ -326,6 +332,10 @@ export function ProjectView() {
const revealing = useRef(false);
const info = useMemo(() => visibleInfo(model, expanded), [model, expanded]);
const mode = useProjectTree((s) => s.mode);
// Open Files / All Changed Files: show new files' folders expanded.
useEffect(() => autoExpand(model), [model]);
// Load folders that are expanded (restored state, reveal, chains) but not read yet.
useEffect(() => {
@@ -363,7 +373,7 @@ export function ProjectView() {
const onExpandedChange = useCallback((next: Set<string>, node: ProjectNode, open: boolean) => {
// Expanding a compacted chain opens every folder in it, so it stays open if the chain breaks.
if (open) for (const p of node.chain) next.add(p);
if (open) for (const p of node.chain) next.add(keyLike(node.key, p));
setExpanded(next);
}, []);
@@ -389,6 +399,7 @@ export function ProjectView() {
}
const nodes = [...sel].map((k) => m.byKey.get(k)).filter((n): n is ProjectNode => !!n && !n.placeholder);
const target = nodes.length ? nodes : [node];
if (target.every((n) => n.group)) return;
// Contributed groups may read the file first (bounded wait).
const at = { x: e.clientX, y: e.clientY };
void prepareMenuGroups("projectView", menuGroupContext(target)).then(() => showContextMenu(at, contextMenuItems(target)));
@@ -407,7 +418,7 @@ export function ProjectView() {
// Pointer-driven drag & drop (HTML5 DnD is unreliable inside the Tauri webview).
const onRowPointerDown = useCallback((node: ProjectNode, e: ReactPointerEvent) => {
if (e.button !== 0 || node.root || node.placeholder || useProjectTree.getState().editing) return;
if (e.button !== 0 || node.root || node.placeholder || node.readOnly || useProjectTree.getState().editing) return;
const before = useProjectTree.getState().selected;
const lead = useProjectTree.getState().lead;
const startX = e.clientX,
@@ -553,7 +564,7 @@ export function ProjectView() {
editing={editing}
cut={cutPaths.has(node.path)}
dropTarget={dropTarget === node.key}
detail={node.root && !node.icon ? rootDetail : null}
detail={node.root && !node.icon ? rootDetail : (node.detail ?? null)}
onEditDone={focusTree}
onClick={onRowClick}
onPointerDown={onRowPointerDown}
@@ -576,6 +587,9 @@ export function ProjectView() {
onFocus={(e) => setContext("projectViewFocus", (e.target as HTMLElement).getAttribute("role") === "tree")}
onBlur={() => setContext("projectViewFocus", false)}
>
{!model.roots.length && (
<div className={styles.empty}>{txTemplate(`Nothing to show in ${VIEW_MODES.find(([id]) => id === mode)?.[1] ?? "this view"}`)}</div>
)}
<Tree<ProjectNode>
aria-label="Project"
handleRef={setTreeHandle}
+63 -4
Переглянути файл
@@ -1,10 +1,12 @@
/** Actions of the project view (Find Action, keymap, other features via `executeAction`). */
import { activeEditor } from "@/editor/api";
import { getAction, registerActions, type Action } from "@/platform/actions";
import { dirname, useProject } from "@/platform/project";
import { showContextMenu, type MenuItem } from "@/ui";
import { maybeEditorService, menuGroupItems, toolWindowService } from "@/workbench/contributions";
import { OPTION_LABELS } from "./HeaderActions";
import { revealLabel } from "./contextMenu";
import { newMenuItems, revealLabel } from "./contextMenu";
import { openNewPopup } from "./newPopupModel";
import type { ProjectNode } from "./model";
import {
beginNew,
@@ -28,7 +30,18 @@ import {
import { nameOf, parentOf } from "./paths";
import { showNewPopup, treeHandle } from "./ProjectView";
import { lastFileRefactoring, undoFileRefactoring } from "./fileRefactor";
import { collapseAll, currentModel, expandAll, reloadAll, revealPath, setViewOption, useProjectTree } from "./store";
import {
VIEW_MODES,
collapseAll,
currentModel,
expandAll,
modeShows,
reloadAll,
revealPath,
setViewMode,
setViewOption,
useProjectTree,
} from "./store";
const FOCUS = "projectViewFocus";
const OPEN = "projectOpen";
@@ -75,6 +88,7 @@ function showProjectView(focus: boolean) {
export function selectInProjectView(path?: string | null, focus = true) {
const target = path ?? maybeEditorService()?.activePath() ?? null;
if (!target) return false;
if (!modeShows(target)) setViewMode("project");
showProjectView(focus);
return revealPath(target, focus);
}
@@ -99,6 +113,23 @@ export function selectInTargets(path: string): MenuItem[] {
];
}
/**
* The New popup for the active editor's directory, at the caret (centred near
* the top without one); `orRoot` falls back to the project root without a file.
*/
export function showNewPopupForEditor(orRoot = false): boolean {
const editor = activeEditor();
const path = editor && !editor.ctx.virtual ? editor.ctx.path : null;
const directory = path ? dirname(path) : orRoot ? (useProject.getState().project?.root ?? null) : null;
if (!directory) return false;
const coords = path ? editor!.view.coordsAtPos(editor!.view.state.selection.main.head) : null;
const at = coords
? { x: coords.left, y: coords.bottom + 2 }
: { x: window.innerWidth / 2 - 140, y: window.innerHeight / 5 };
openNewPopup(at, newMenuItems(directory, { outsideTree: true }));
return true;
}
function showSelectIn(path: string) {
// At the caret, like IDEA's popup; centred near the top otherwise.
const view = activeEditor()?.view;
@@ -125,6 +156,18 @@ export function registerProjectActions(): () => void {
run: () => setViewOption(key, !useProjectTree.getState().options[key]),
}));
const modes: Action[] = VIEW_MODES.map(([mode, label]) => ({
id: `project.viewMode.${mode}`,
title: `Project View: Show ${label}`,
category: "View",
when: OPEN,
keywords: ["project", "pane", "scope", "view as"],
run: () => {
setViewMode(mode);
showProjectView(false);
},
}));
return registerActions([
{
// A path argument (links, breadcrumbs, Open In › Project View) selects it in the project view.
@@ -165,13 +208,28 @@ export function registerProjectActions(): () => void {
when: OPEN,
keywords: ["create"],
run: ({ arg, ctx }) => {
// ⌘N inside the project view opens the New popup (File, Directory, templates).
// ⌘N inside the project view opens the New popup (File, Directory, templates);
// in an editor (where Generate does not apply) it opens for the file's directory.
if (arg === undefined && ctx[FOCUS] && showNewPopup()) return;
if (arg === undefined && ctx.editorFocus && showNewPopupForEditor()) return;
// No focus request: the inline editor takes focus itself.
showProjectView(false);
return beginNew("file", folderFrom(arg));
},
},
{
// IDEA's NewElementSamePlace: the New popup for the current file's directory, wherever the focus is.
id: "project.newInThisDirectory",
title: "New in This Directory…",
category: "File",
icon: "plus",
when: OPEN,
keywords: ["create", "file", "same place", "current directory"],
run: ({ ctx }) => {
if (ctx[FOCUS] && showNewPopup()) return;
showNewPopupForEditor(true);
},
},
{
// File › New (native menu, VS Code preset): same as New File at the selection.
id: "file.new",
@@ -380,7 +438,7 @@ export function registerProjectActions(): () => void {
keyWhen: FOCUS,
run: () => {
const lead = leadNode();
return expandAll(lead?.dir && !lead.root ? lead.path : undefined);
return expandAll(lead?.dir && !lead.root ? lead.key : undefined);
},
},
{
@@ -405,5 +463,6 @@ export function registerProjectActions(): () => void {
run: reloadAll,
},
...toggles,
...modes,
]);
}
+65 -16
Переглянути файл
@@ -1,10 +1,10 @@
/** Context menu and "New" popup of the project view. */
import { allActions, getAction } from "@/platform/actions";
import { executeAction, getAction } from "@/platform/actions";
import { formatBinding, hostBinding, isMacHost, shortcutLabel } from "@/platform/keymap";
import { relativePath, useProject } from "@/platform/project";
import { currentDetectedRoots, currentRoots } from "@/platform/projectRoots";
import type { MenuItem } from "@/ui";
import { menuGroupItems, type MenuGroupContext } from "@/workbench/contributions";
import { menuGroupItems, toolWindowService, type MenuGroupContext } from "@/workbench/contributions";
import type { FolderRole } from "@/platform/languageSettings";
import { directoryRole, markDirectory } from "./markDirectory";
import type { ProjectNode } from "./model";
@@ -44,16 +44,37 @@ export const LOCAL_KEYS: Record<string, string> = {
export const keyLabel = (id: string) =>
shortcutLabel(id) ?? (LOCAL_KEYS[id] ? formatBinding(hostBinding(LOCAL_KEYS[id])) : undefined);
export function newMenuItems(parent: string | null): MenuItem[] {
/**
* New › items for `parent`. `outsideTree` (⌘N in the editor): File asks for the
* name in a dialog, and the inline Directory / Package editors bring up the project view.
*/
export function newMenuItems(parent: string | null, { outsideTree = false } = {}): MenuItem[] {
const root = useProject.getState().project?.root;
const rel = parent && root ? (parent === root ? "" : relativePath(root, parent)) : null;
const pkg = rel === null ? null : packageKindAt(rel, currentRoots(), currentDetectedRoots());
const dir: MenuItem = { id: "new.dir", label: "Directory", icon: "folder", run: () => void beginNew("dir", parent) };
const inline = (start: () => Promise<void>) => () => {
if (outsideTree) showProjectView();
void start();
};
const emptyFile = outsideTree && parent && getAction("templates.new.file");
const dir: MenuItem = {
id: "new.dir",
label: "Directory",
icon: "folder",
run: inline(() => beginNew("dir", parent)),
};
return [
{ id: "new.file", label: "File", icon: "file", run: () => void beginNew("file", parent) },
emptyFile
? {
id: "new.file",
label: "File",
icon: "file",
run: () => void executeAction(emptyFile.id, { directory: parent }),
}
: { id: "new.file", label: "File", icon: "file", run: inline(() => beginNew("file", parent)) },
// IDEA offers "Package" instead of "Directory" inside a Java / Kotlin source root.
pkg === "jvm"
? { id: "new.package", label: "Package", icon: "package", run: () => void beginNewPackage("jvm", parent) }
? { id: "new.package", label: "Package", icon: "package", run: inline(() => beginNewPackage("jvm", parent)) }
: dir,
...(pkg === "python"
? [
@@ -61,22 +82,25 @@ export function newMenuItems(parent: string | null): MenuItem[] {
id: "new.pythonPackage",
label: "Python Package",
icon: "package",
run: () => void beginNewPackage("python", parent),
run: inline(() => beginNewPackage("python", parent)),
},
]
: []),
...templateItems(parent),
...contributedNewItems(parent),
];
}
/** File templates contributed by features/templates (`templates.new.*`). */
function templateItems(parent: string | null): MenuItem[] {
const arg = parent ? { directory: parent } : undefined;
const items: MenuItem[] = allActions()
.filter((a) => a.id.startsWith("templates.new.") && a.id !== "templates.new.file")
.map((a) => ({ kind: "action", action: a.id, arg, label: a.title.replace(/^New /, "").replace(/…$/, "") }));
const more = getAction("templates.newFromTemplate");
if (more) items.push({ kind: "separator" }, { kind: "action", action: more.id, arg, label: "From Template…" });
function showProjectView() {
try {
toolWindowService().show("project", false);
} catch {
/* tool window service not available (tests) */
}
}
/** File kinds contributed to the `new` menu group (templates, HTTP Request, Scratch File…). */
function contributedNewItems(parent: string | null): MenuItem[] {
const items = menuGroupItems("new", parent ? { paths: [parent], dirs: [true] } : { paths: [] });
return items.length ? [{ kind: "separator" }, ...items] : [];
}
@@ -107,6 +131,7 @@ function submenu(id: string, label: string, entries: Entry[], icon?: string): Me
* clipboard group), so the project root and Scratches get a shorter menu.
*/
export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
if (nodes.some((n) => n.readOnly)) return libraryMenuItems(nodes.filter((n) => !n.group));
const lead = nodes[0] ?? null;
const single = nodes.length === 1 ? lead : null;
const nonRoot = nodes.filter((n) => !n.root);
@@ -338,6 +363,30 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
return items;
}
/** Library sources (External Libraries) are read-only: copy, find and reveal only. */
function libraryMenuItems(nodes: ProjectNode[]): MenuItem[] {
if (!nodes.length) return [];
const single = nodes.length === 1 ? nodes[0]! : null;
const folder = single?.dir ? single.path : null;
const items: Entry[] = [
{ id: "copy", label: "Copy", icon: "copy", shortcut: keyLabel("project.copy"), run: () => copySelection(false) },
{
id: "copyPath",
label: "Copy Path/Reference…",
submenu: [
{ id: "abs", label: "Absolute Path", shortcut: keyLabel("project.copyAbsolutePath"), run: () => void copyPaths("absolute", nodes) },
{ id: "name", label: "File Name", run: () => void copyPaths("name", nodes) },
],
},
{ kind: "separator" },
!!folder && delegated("find.inPath", { directory: folder }, "Find in Files…", { icon: "search" }),
!!folder && { kind: "separator" },
submenu("openIn", "Open In", [{ id: "finder", label: revealLabel(), icon: "external-link", run: () => void revealInFinder(nodes) }]),
single && !single.dir && delegated("diff.compareWith", single.path, "Compare With…", { icon: "diff", shortcut: keyLabel("project.compareWith") }),
];
return items.filter((i): i is MenuItem => !!i);
}
export function revealLabel() {
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
if (/Win/i.test(ua)) return "Explorer";
+71 -5
Переглянути файл
@@ -1,16 +1,26 @@
/** Project view: the "project" tool window, its file operations and actions. */
import { onEditorEvent, openEditorPaths } from "@/editor/api";
import { onIdeEvent } from "@/platform/ipc";
import type { RootFolder } from "@/platform/languageSettings";
import { validateProjectStructure, type RootFolder } from "@/platform/languageSettings";
import { useProject } from "@/platform/project";
import { initProjectRoots, useProjectRoots } from "@/platform/projectRoots";
import { registerOverlay, registerToolWindow, useProjectViewRoots } from "@/workbench/contributions";
import { folderIconName } from "@/ui";
import {
localChangedPaths,
registerOverlay,
registerProjectViewRoot,
registerToolWindow,
useLocalChanges,
useProjectViewRoots,
} from "@/workbench/contributions";
import { registerProjectActions } from "./actions";
import { FileRefactorDialogHost } from "./FileRefactorDialogs";
import { NewPopupHost } from "./NewPopup";
import { RefactoringPreviewHost } from "./RefactoringPreview";
import { ProjectHeaderActions, useProjectHeaderMenu } from "./HeaderActions";
import { ProjectHeaderActions, useProjectHeaderMenu, useProjectTitleMenu } from "./HeaderActions";
import { loadHomeDir } from "./home";
import { ProjectView } from "./ProjectView";
import { applyFsChange, initProjectTree, reloadAll, useProjectTree } from "./store";
import { applyFsChange, initProjectTree, reloadAll, setChangedFiles, setOpenFiles, useProjectTree } from "./store";
export function activate(): () => void {
loadHomeDir();
@@ -27,7 +37,12 @@ export function activate(): () => void {
// Excluded folders / exclude globs change which entries are `ignored`.
const unsubExcludes = useProject.subscribe((s, prev) => {
const ex = (x: typeof s.settings) =>
JSON.stringify([x?.files?.exclude, x?.projectStructure?.excluded, x?.projectStructure?.unmarked]);
JSON.stringify([
x?.files?.exclude,
x?.projectStructure?.excluded,
x?.projectStructure?.unmarked,
x?.projectStructure?.excludeFiles,
]);
if (s.settings !== prev.settings && prev.settings && ex(s.settings) !== ex(prev.settings)) void reloadAll();
});
// …and so do excluded roots detected from build files (`target`, `node_modules`, …).
@@ -38,15 +53,64 @@ export function activate(): () => void {
if (s.root && s.root === prev.root && detectedExcluded(s) !== detectedExcluded(prev)) void reloadAll();
});
// Project Structure content roots besides the project folder, listed after it.
let contentRoots: Array<() => void> = [];
let shownRoots = "";
const syncContentRoots = () => {
const { project, settings } = useProject.getState();
const paths = project ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
const key = JSON.stringify(paths);
if (key === shownRoots) return;
shownRoots = key;
contentRoots.forEach((d) => d());
contentRoots = paths.map((path, i) =>
registerProjectViewRoot({
id: `contentRoot:${path}`,
label: path.slice(path.lastIndexOf("/") + 1) || path,
detail: path,
icon: folderIconName("module"),
path,
order: -100 + i,
}),
);
};
syncContentRoots();
const unsubContentRoots = useProject.subscribe((s, prev) => {
if (s.settings !== prev.settings || s.project !== prev.project) syncContentRoots();
});
// Contributed roots (Scratches) are part of the derived model: re-render.
const unsubExtraRoots = useProjectViewRoots.subscribe(() =>
useProjectTree.setState((s) => ({ listings: new Map(s.listings) })),
);
// Open Files / All Changed Files panes: editor tabs and VCS changes, via the editor API and decorations.
let openTimer: ReturnType<typeof setTimeout> | undefined;
const syncOpenFiles = () => {
clearTimeout(openTimer);
// Tabs update after the document events.
openTimer = setTimeout(() => setOpenFiles(openEditorPaths()), 0);
};
syncOpenFiles();
const unsubEditor = onEditorEvent((e) => {
if (e.type === "docOpened" || e.type === "docClosed" || e.type === "activeEditorChanged") syncOpenFiles();
});
// "All Changed Files": change-list files still on disk (IDEA leaves out unversioned, ignored and deleted ones).
const syncChanged = () => setChangedFiles(localChangedPaths(useLocalChanges.getState().lists, null, true));
syncChanged();
const unsubDecorations = useLocalChanges.subscribe((s, prev) => {
if (s.lists !== prev.lists) syncChanged();
});
const disposers = [
() => clearTimeout(openTimer),
unsubEditor,
unsubDecorations,
unsubProject,
unsubExtraRoots,
unsubExcludes,
unsubContentRoots,
() => contentRoots.forEach((d) => d()),
disposeRoots,
unsubRoots,
registerToolWindow({
@@ -58,9 +122,11 @@ export function activate(): () => void {
component: ProjectView,
headerActions: ProjectHeaderActions,
headerMenu: useProjectHeaderMenu,
titleMenu: useProjectTitleMenu,
}),
registerOverlay({ id: "project.fileRefactor", component: FileRefactorDialogHost }),
registerOverlay({ id: "project.refactoringPreview", component: RefactoringPreviewHost }),
registerOverlay({ id: "project.newPopup", component: NewPopupHost }),
registerProjectActions(),
onIdeEvent("fsChanged", (e) => void applyFsChange(e)),
];
+190 -12
Переглянути файл
@@ -5,7 +5,7 @@
*/
import type { FileEntry } from "@/bindings";
import { isIgnoredName } from "@/platform/ignoredNames";
import { isSameOrInside, nameOf, parentOf, relativeTo } from "./paths";
import { ancestorsWithin, isInside, isSameOrInside, nameOf, parentOf, relativeTo } from "./paths";
export interface TreeOptions {
/** Show ignored names (`files.ignoredNames`: `.git`, `.DS_Store`, …). Dotfiles are always shown. */
@@ -43,6 +43,12 @@ export interface ProjectNode {
placeholder?: "file" | "dir";
/** Root of a contributed folder outside the project ("Scratches"): its icon. */
icon?: string;
/** Grey text after the label ("library root"). */
detail?: string;
/** A node grouping other nodes, without a folder of its own ("External Libraries"). */
group?: boolean;
/** Library sources: no new / rename / move / delete. */
readOnly?: boolean;
}
export interface TreeModel {
@@ -210,13 +216,53 @@ export function buildModel(
return { roots: [rootNode], children, byKey, keyOfPath };
}
/** A contributed root (scratches, External Libraries) or one of its child entries. */
export interface ExtraRoot {
id?: string;
label: string;
icon: string;
/** Folder listed by the node; omitted for a group node of `children`. */
path?: string;
children?: readonly ExtraRoot[];
detail?: string;
readOnly?: boolean;
}
/** Key prefix of group nodes that show no folder ("External Libraries", an SDK). */
export const GROUP_PREFIX = "\u0000group:";
export const isGroupKey = (key: string) => key.startsWith(GROUP_PREFIX);
/**
* Adds contributed roots (folders outside the project, e.g. scratches) after
* the project root. Each lists its folder like the project root does.
* Key prefix of nodes in a library folder below a group (`\0lib:<group>\0<path>`):
* their own namespace, so a folder inside the project (a `.venv`'s
* site-packages, `node_modules/<dep>`) shows both there and under External Libraries.
*/
export const LIB_PREFIX = "\u0000lib:";
const LIB_SEP = "\u0000";
export const isLibKey = (key: string) => key.startsWith(LIB_PREFIX);
export const libKey = (group: string, path: string) => `${LIB_PREFIX}${group}${LIB_SEP}${path}`;
/** The folder / file a node key stands for (keys outside library subtrees are paths). */
export function pathOfKey(key: string): string {
return isLibKey(key) ? key.slice(key.indexOf(LIB_SEP, LIB_PREFIX.length) + 1) : key;
}
/** The key of `path` in the same subtree as the node keyed `like`. */
export function keyLike(like: string, path: string): string {
return isLibKey(like) ? like.slice(0, like.indexOf(LIB_SEP, LIB_PREFIX.length) + 1) + path : path;
}
/**
* Adds contributed roots (folders outside the project, e.g. scratches, and
* groups of them like External Libraries) after the project root. Each folder
* lists its contents like the project root does.
*/
export function withExtraRoots(
model: TreeModel,
extras: ReadonlyArray<{ path: string; label: string; icon: string }>,
extras: readonly ExtraRoot[],
listings: Listings,
opts: TreeOptions,
placeholder?: Placeholder | null,
@@ -228,19 +274,151 @@ export function withExtraRoots(
byKey: new Map(model.byKey),
keyOfPath: new Map(model.keyOfPath),
};
const add = (extra: ExtraRoot, parentKey: string | null, readOnly: boolean): ProjectNode | null => {
const ro = readOnly || !!extra.readOnly;
if (extra.path !== undefined && !extra.children) {
const path = extra.path;
// Library folders of a group are keyed in their own namespace; other
// folders (scratches) by path, so ones inside the project stay under it.
const ns = ro && parentKey ? parentKey.slice(GROUP_PREFIX.length) : null;
const rekey = (k: string) => (ns === null ? k : libKey(ns, k));
if (out.byKey.has(rekey(path))) return null;
if (ns === null && model.roots.some((r) => !r.group && isSameOrInside(path, r.path))) return null;
const sub = buildModel({ path, name: extra.label }, listings, opts, ro ? null : placeholder);
const subRoot = sub.roots[0]!;
const rootNode: ProjectNode = { ...subRoot, key: rekey(subRoot.key), root: !parentKey, icon: extra.icon, detail: extra.detail };
if (ro) rootNode.readOnly = true;
const nodes = new Map<string, ProjectNode>();
for (const [k, v] of sub.byKey)
nodes.set(k, k === subRoot.key ? rootNode : ns !== null || ro ? { ...v, key: rekey(k), readOnly: ro || v.readOnly } : v);
for (const n of nodes.values()) out.byKey.set(n.key, n);
for (const [k, v] of sub.children) out.children.set(rekey(k), v.map((n) => nodes.get(n.key) ?? n));
for (const [p, k] of sub.keyOfPath) if (!out.keyOfPath.has(p)) out.keyOfPath.set(p, rekey(k));
return rootNode;
}
const key = `${parentKey ? `${parentKey}/` : GROUP_PREFIX}${extra.id ?? extra.label}`;
if (out.byKey.has(key)) return null;
const node: ProjectNode = {
key,
path: key,
chain: [],
label: extra.label,
dir: true,
root: !parentKey,
entry: null,
ignored: false,
excluded: false,
symlink: false,
icon: extra.icon,
detail: extra.detail,
group: true,
readOnly: true,
};
out.byKey.set(key, node);
const kids = (extra.children ?? []).map((c) => add(c, key, ro)).filter((n): n is ProjectNode => !!n);
out.children.set(key, kids);
return node;
};
for (const extra of extras) {
if (out.byKey.has(extra.path)) continue;
const sub = buildModel({ path: extra.path, name: extra.label }, listings, opts, placeholder);
const rootNode = { ...sub.roots[0]!, icon: extra.icon };
sub.byKey.set(rootNode.key, rootNode);
out.roots.push(rootNode);
for (const [k, v] of sub.children) out.children.set(k, v);
for (const [k, v] of sub.byKey) out.byKey.set(k, v);
for (const [k, v] of sub.keyOfPath) out.keyOfPath.set(k, v);
const node = add(extra, null, false);
if (node) out.roots.push(node);
}
return out;
}
/**
* Marks project nodes inside read-only library `folders` (a `.venv`'s
* site-packages listed under External Libraries) read-only too.
*/
export function withReadOnlyFolders(model: TreeModel, folders: readonly string[]): TreeModel {
if (!folders.length) return model;
const byKey = new Map(model.byKey);
let changed = false;
for (const [k, n] of model.byKey)
if (!n.readOnly && !n.group && !isLibKey(k) && !n.root && folders.some((f) => isSameOrInside(n.path, f))) {
byKey.set(k, { ...n, readOnly: true });
changed = true;
}
if (!changed) return model;
const children = new Map<string, ProjectNode[]>();
for (const [k, kids] of model.children) children.set(k, kids.map((n) => byKey.get(n.key) ?? n));
return { roots: model.roots.map((n) => byKey.get(n.key) ?? n), children, byKey, keyOfPath: model.keyOfPath };
}
/** Folder roots of contributed entries (groups flattened), in display order. */
export function extraFolders(extras: readonly ExtraRoot[], readOnly = false): ExtraRoot[] {
return extras.flatMap((e) =>
e.path !== undefined && !e.children
? [{ ...e, readOnly: readOnly || !!e.readOnly }]
: extraFolders(e.children ?? [], readOnly || !!e.readOnly),
);
}
/**
* A tree of just `paths` (IDEA's "Open Files" / "All Changed Files" views):
* each file below the innermost of `roots` containing it, through its folders.
* Files outside every root are grouped under their folder. Roots without
* files are left out.
*/
export function buildPathsModel(roots: readonly ExtraRoot[], paths: readonly string[], foldersFirst = true): TreeModel {
const children = new Map<string, ProjectNode[]>();
const byKey = new Map<string, ProjectNode>();
const keyOfPath = new Map<string, string>();
const rootNodes: Array<[number, ProjectNode]> = [];
const node = (path: string, label: string, dir: boolean, extra: Partial<ProjectNode> = {}): ProjectNode => {
const n: ProjectNode = {
key: path,
path,
chain: [path],
label,
dir,
root: false,
entry: null,
ignored: false,
excluded: false,
symlink: false,
...extra,
};
byKey.set(path, n);
keyOfPath.set(path, path);
if (dir) children.set(path, []);
return n;
};
const ensureRoot = (r: ExtraRoot & { path: string }, rank: number) => {
const existing = byKey.get(r.path);
if (existing) return existing;
const n = node(r.path, r.label, true, { root: true, icon: r.icon || undefined, detail: r.detail, readOnly: r.readOnly });
rootNodes.push([rank, n]);
return n;
};
const folders = roots.filter((r): r is ExtraRoot & { path: string } => r.path !== undefined);
for (const path of new Set(paths)) {
if (byKey.has(path)) continue;
let rank = -1;
for (let i = 0; i < folders.length; i++)
if (isInside(path, folders[i]!.path) && (rank < 0 || folders[i]!.path.length > folders[rank]!.path.length)) rank = i;
const root =
rank >= 0
? ensureRoot(folders[rank]!, rank)
: ensureRoot({ path: parentOf(path), label: parentOf(path), icon: "folder" }, folders.length);
let parent = root;
for (const dir of ancestorsWithin(root.path, path).slice(1)) {
let d = byKey.get(dir);
if (!d) {
d = node(dir, nameOf(dir), true, { readOnly: root.readOnly });
children.get(parent.key)!.push(d);
}
parent = d;
}
children.get(parent.key)!.push(node(path, nameOf(path), false, { readOnly: root.readOnly }));
}
const order = (a: ProjectNode, b: ProjectNode) =>
(foldersFirst ? Number(b.dir) - Number(a.dir) : 0) || collator.compare(a.label, b.label);
for (const kids of children.values()) kids.sort(order);
rootNodes.sort((a, b) => a[0] - b[0] || collator.compare(a[1].label, b[1].label));
return { roots: rootNodes.map(([, n]) => n), children, byKey, keyOfPath };
}
export interface VisibleInfo {
/** Node keys currently rendered (ancestors expanded). */
visible: Set<string>;
+107
Переглянути файл
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executeAction, registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import type { MenuItem } from "@/ui";
import { registerMenuGroup } from "@/workbench/contributions";
import { newMenuItems } from "./contextMenu";
import { closeNewPopup, newPopupRows, openNewPopup, useNewPopup } from "./newPopupModel";
import { useProjectTree } from "./store";
vi.mock("@/platform/actions", async (importOriginal) => {
const mod = await importOriginal<typeof import("@/platform/actions")>();
return { ...mod, executeAction: vi.fn(mod.executeAction) };
});
const ROOT = "/p";
const labels = (items: MenuItem[], q = "") => newPopupRows(items, q).map((r) => (r.separator ? "-" : r.label));
let disposers: Array<() => void> = [];
beforeEach(() => {
useProjectTree.setState({ root: ROOT });
useProject.setState({
project: { name: "p", root: ROOT, isGit: false, settings: {} } as never,
settings: {} as never,
});
disposers = [
registerActions([
{ id: "templates.new.file", title: "New Empty File…", run: () => undefined },
{ id: "templates.new.python", title: "New Python File…", run: () => undefined },
{
id: "templates.new.stylesheet",
title: "New Stylesheet…",
keywords: ["new", "css", "scss", "less"],
run: () => undefined,
},
{ id: "http.newRequest", title: "New HTTP Request", run: () => undefined },
]),
registerMenuGroup({
id: "test.templates",
menus: ["new"],
order: 10,
items: (_menu, ctx) => [
{ kind: "action", action: "templates.new.python", arg: { directory: ctx.paths[0] }, label: "Python File" },
{ kind: "action", action: "templates.new.stylesheet", arg: { directory: ctx.paths[0] }, label: "Stylesheet" },
],
}),
registerMenuGroup({
id: "test.http",
menus: ["new"],
order: 20,
items: (_menu, { paths: [directory] }) =>
directory ? [{ kind: "action", action: "http.newRequest", arg: { directory }, label: "HTTP Request" }] : [],
}),
];
});
afterEach(() => {
disposers.forEach((d) => d());
closeNewPopup();
vi.mocked(executeAction).mockClear();
useProject.setState({ project: null, settings: null });
useProjectTree.setState({ root: null });
});
describe("New popup", () => {
it("lists File, Directory and the contributed file kinds", () => {
expect(labels(newMenuItems(`${ROOT}/src`))).toEqual([
"File",
"Directory",
"-",
"Python File",
"Stylesheet",
"-",
"HTTP Request",
]);
});
it("passes the target directory to contributed kinds", () => {
const http = newMenuItems(`${ROOT}/api`).find((i) => i.kind === "action" && i.action === "http.newRequest");
expect(http).toMatchObject({ arg: { directory: `${ROOT}/api` } });
});
it("filters by label, then by keyword, without separators", () => {
const items = newMenuItems(ROOT);
expect(labels(items, "py")).toEqual(["Python File"]);
expect(labels(items, "css")).toEqual(["Stylesheet"]);
expect(labels(items, "http")).toEqual(["HTTP Request"]);
expect(labels(items, "zzz")).toEqual([]);
expect(newPopupRows(items, "py")[0]!.matches).toEqual([0, 1]);
});
it("asks for the file name in a dialog outside the project view", () => {
const file = newMenuItems(`${ROOT}/src`, { outsideTree: true })[0] as { label: string; run: () => void };
expect(file.label).toBe("File");
file.run();
expect(executeAction).toHaveBeenCalledWith("templates.new.file", { directory: `${ROOT}/src` });
});
it("opens with a fresh request each time", () => {
openNewPopup({ x: 1, y: 2 }, []);
const first = useNewPopup.getState().request!.seq;
openNewPopup({ x: 1, y: 2 }, []);
expect(useNewPopup.getState().request!.seq).toBe(first + 1);
closeNewPopup();
expect(useNewPopup.getState().request).toBeNull();
});
});
Loaded 100 of 426 files, more files were not shown because too many files have changed in this diff. Show more