дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat: Third pass aligning core experience with IntelliJ IDEA
Project & files - Project view modes (Project Files / Open Files / All Changed Files / Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go, Maven/Gradle jars; read-only jar/zip browsing - ⌘N New popup with type search and more file kinds; New Project, Rename Project, Reload All from Disk, Export/Import Settings, Power Save Mode - File type associations, extra content roots and exclude patterns (indexed and watched without reopening the project) Editor & navigation - Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline, history; Find in Files: Module scope, custom pattern scopes, multiline - Search Everywhere filters, Assign Shortcut, settings results; named bookmark lists, file/folder bookmarks; Structure filters and Show Inherited; Recent Locations timestamps and breadcrumbs; Code Vision author - Completion options, editor tab placement/layout, inline completion (LSP 3.18) - Spell checking (Typo inspection, ide-spell) and Inspections settings with custom profiles; Code Style schemes, formatter markers, Do not format Run, VCS, services, database - Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker, Compound), Modify options, Before launch, env dialog, Current File, Emulate terminal (PTY) - Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees - Generic Services host (Docker connections/registries, run sessions); TODO scope/changes tabs with changelists; Coverage report import - Data sources: wire-compatible vendors, URL import, SSL certs, Options, Schemas and Advanced tabs, color and comment Workbench - Run Anything, Customize Main Toolbar, named tool window layouts, menu completeness, Code Cleanup, macros, "Don't Show Again", editor banners - Chinese translations for the new UI Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
Цей коміт міститься в:
1 джерело
294198a727
коміт
1e00ea5ee2
426 files changed
+85241
-2886
No files matched your search
+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 {
|
||||
|
||||
@@ -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 ---------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(" ");
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: () => {} }],
|
||||
});
|
||||
}
|
||||
@@ -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[]) =>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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…",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ?? ""}'`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? ""]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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" }) },
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -115,3 +115,8 @@
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 8px 12px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
Reference in new issue
Заблокувати користувача