complition
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
target
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Instructions for AI coding agents working on this repository. Read this file
|
||||||
|
before changing code. The rules below are architectural requirements, not
|
||||||
|
suggestions.
|
||||||
|
|
||||||
|
## Project shape
|
||||||
|
|
||||||
|
| Path | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/api.rs` | `AppApi` / `PanelApi` — the single control surface of the application |
|
||||||
|
| `src/script.rs` | Lua engine; publishes `app`, `include`, and `require` |
|
||||||
|
| `src/keymap.rs` | Key binding parsing (`KeyBinding`) and dispatch (`KeyMap`) |
|
||||||
|
| `src/app.rs` | GPUI view, rendering, event wiring |
|
||||||
|
| `src/editor.rs` | `Workspace`, documents, tabs |
|
||||||
|
| `src/panel.rs` | `PanelState` for left/right/bottom panels |
|
||||||
|
| `src/lsp.rs` | LSP server manager |
|
||||||
|
| `src/constants.rs` | CLI arguments and script directory |
|
||||||
|
| `init.lua` | Example startup script (also a usage reference) |
|
||||||
|
| `DOCS.md` | User-facing Lua API documentation |
|
||||||
|
|
||||||
|
## 1. `AppApi` is the center of the application
|
||||||
|
|
||||||
|
- `AppApi` (in `src/api.rs`) is the one object through which the application is
|
||||||
|
controlled. All operations on documents, tabs, panels, the window, syntax
|
||||||
|
highlighting, key bindings, and LSP go through it.
|
||||||
|
- Shared state is held as `Rc<RefCell<_>>` handles (workspace, keymap, panels,
|
||||||
|
LSP manager). The UI and `AppApi` must observe the *same* instances; never
|
||||||
|
create a second copy of the state.
|
||||||
|
- Rust code that needs to act on the application should call
|
||||||
|
`AppApi::global()` (or an `AppApi` instance passed in) instead of mutating
|
||||||
|
`Workspace`, `PanelState`, `KeyMap`, or `LspManager` directly.
|
||||||
|
- Methods that are implementation details of `AppApi` stay private or are
|
||||||
|
marked `#[lua(skip)]`. Do not add a second, parallel control path.
|
||||||
|
|
||||||
|
## 2. Every `AppApi` capability is available from Lua
|
||||||
|
|
||||||
|
- Everything in the `impl AppApi` block annotated with
|
||||||
|
`#[mlua::userdata_impl]` is automatically exposed as `app:method(...)`.
|
||||||
|
- When adding functionality, add it to `AppApi` and expose it to Lua at the same
|
||||||
|
time. An operation that a user can trigger in the UI must also be callable
|
||||||
|
from a Lua script.
|
||||||
|
- Use `#[lua(infallible)]` where the method cannot fail, and return
|
||||||
|
`mlua::Result` / convert errors to `mlua::Error::RuntimeError` otherwise.
|
||||||
|
- Generic Rust methods are not exported. Add a thin non-generic Lua wrapper,
|
||||||
|
following the existing pattern: `save` / `lua_save`, `open` / `lua_open`,
|
||||||
|
`set_active_text` / `lua_set_active_text`, `set_language` /
|
||||||
|
`lua_set_language`.
|
||||||
|
- Use `#[lua(name = "...")]` when the Lua name should differ from the Rust
|
||||||
|
name (for example `active_tab_index` → `active_tab`).
|
||||||
|
- Lua API naming: lower `snake_case`, tabs and indices are zero-based, panels
|
||||||
|
are accessed as `app.left_panel`, `app.right_panel`, `app.bottom_panel`.
|
||||||
|
|
||||||
|
## 3. All UI actions and keyboard shortcuts go through the API
|
||||||
|
|
||||||
|
### UI actions
|
||||||
|
|
||||||
|
- UI event handlers and rendered controls must invoke `AppApi` methods, not
|
||||||
|
manipulate state directly. Examples:
|
||||||
|
- window close button → `app:close()` (i.e. `AppApi::close`)
|
||||||
|
- minimize button → `app:minimize()`
|
||||||
|
- maximize / restore → `app:toggle_maximize()` or
|
||||||
|
`app:set_maximized(bool)`; read the current state with `app:is_maximized()`
|
||||||
|
- tab close button → `app:close_tab(index)`
|
||||||
|
- "new tab" button → `app:open_tab()`
|
||||||
|
- panel toggles → `app.right_panel:toggle()` and friends
|
||||||
|
- Rendering code may *read* state (for example `workspace.borrow()` to draw the
|
||||||
|
active document), but must not mutate it. Mutations belong to `AppApi`.
|
||||||
|
- Window operations are queued as `WindowCommand`s and applied by
|
||||||
|
`apply_window_commands`; keep this mechanism as the only way the UI and Lua
|
||||||
|
request window changes.
|
||||||
|
- If an external component (for example `gpui_kit::component::TitleBar`)
|
||||||
|
performs an action internally, do not leave it as a separate control path:
|
||||||
|
intercept the action or replace the component so that it calls `AppApi`.
|
||||||
|
|
||||||
|
### Keyboard shortcuts
|
||||||
|
|
||||||
|
- Every key binding must be registered through `AppApi::map_key` /
|
||||||
|
`KeyMap`. There must be no hard-coded shortcuts that scripts cannot inspect,
|
||||||
|
override, or remove.
|
||||||
|
- Existing hard-coded checks such as `is_save_shortcut` and
|
||||||
|
`is_completion_shortcut` in `src/app.rs` are technical debt: when touching
|
||||||
|
this area, move those defaults into the `KeyMap` mechanism so they can be
|
||||||
|
rebound from Lua.
|
||||||
|
- Default bindings should be installed through the same mechanism that scripts
|
||||||
|
use (or a documented equivalent), so that `app:map_key(...)` can replace them.
|
||||||
|
Rebinding the same chord must replace the previous handler.
|
||||||
|
- Dispatch order matters: the key interceptor runs before key bindings, and
|
||||||
|
`dispatch_keymap` is used for both key down and key up (`<...>` vs `!key`).
|
||||||
|
Preserve this behavior.
|
||||||
|
- Any `KeyMap` change must keep the documented chord syntax working:
|
||||||
|
`a`, `1`, `!a`, `<S-c>`, `<C-z>`, `<A-!r>`, named keys (case-insensitive).
|
||||||
|
- Editor text input itself is not a shortcut, but all app-level commands
|
||||||
|
(save, close, panels, completion popup navigation, etc.) must be remappable.
|
||||||
|
|
||||||
|
## 4. Keep `DOCS.md` in sync
|
||||||
|
|
||||||
|
`DOCS.md` is the source of truth for the Lua API. In the **same change** that
|
||||||
|
modifies the public surface, update the docs:
|
||||||
|
|
||||||
|
- added/removed/renamed `AppApi` or `PanelApi` methods;
|
||||||
|
- changed arguments, return values, or error behavior;
|
||||||
|
- new supported languages, LSP configuration fields, CLI flags, or
|
||||||
|
`include`/`require` behavior;
|
||||||
|
- changed default shortcuts or their syntax.
|
||||||
|
|
||||||
|
Also update the usage comments in `init.lua` when they describe behavior that
|
||||||
|
changed. Documentation must describe the code as it actually behaves — no
|
||||||
|
aspirational APIs.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Keep changes focused and consistent with the existing style; comments and
|
||||||
|
user-facing documentation are written in Russian.
|
||||||
|
- Rust API docs use `///`; write them for every new public method.
|
||||||
|
- Validate with `cargo fmt` and `cargo test`, then `cargo build` if behavior
|
||||||
|
changed. Do not commit unless explicitly asked.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
Before finishing a change, verify:
|
||||||
|
|
||||||
|
- [ ] The new behavior is implemented in `AppApi`, not in a UI handler.
|
||||||
|
- [ ] The operation is callable from Lua and has a documented signature.
|
||||||
|
- [ ] No new hard-coded keyboard shortcut bypasses `KeyMap`.
|
||||||
|
- [ ] UI controls call the corresponding `AppApi` method.
|
||||||
|
- [ ] `DOCS.md` (and `init.lua` comments if needed) are updated.
|
||||||
|
- [ ] `cargo fmt` and `cargo test` pass (or explain why they could not run).
|
||||||
Generated
+6
@@ -2181,8 +2181,14 @@ dependencies = [
|
|||||||
name = "gpui-editor-test"
|
name = "gpui-editor-test"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"globset",
|
||||||
"gpui-kit",
|
"gpui-kit",
|
||||||
|
"lsp-types",
|
||||||
"mlua",
|
"mlua",
|
||||||
|
"notify",
|
||||||
|
"parking_lot",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ gpui-kit = { version = "0.8.1", features = [
|
|||||||
] }
|
] }
|
||||||
mlua = { version = "0.12", features = ["lua54", "vendored", "macros"] }
|
mlua = { version = "0.12", features = ["lua54", "vendored", "macros"] }
|
||||||
|
|
||||||
|
# LSP-клиент: JSON-RPC поверх stdio, отслеживание файлов и типы протокола.
|
||||||
|
anyhow = "1"
|
||||||
|
lsp-types = "0.97"
|
||||||
|
notify = "8.2"
|
||||||
|
globset = "0.4"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
gpui-kit = { version = "0.8.1", features = ["test-support"] }
|
gpui-kit = { version = "0.8.1", features = ["test-support"] }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# Lua API
|
||||||
|
|
||||||
|
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект `app`
|
||||||
|
для управления окном, вкладками, подсветкой и LSP-серверами.
|
||||||
|
|
||||||
|
## Запуск и каталог сценариев
|
||||||
|
|
||||||
|
Каталог поиска `init.lua` задаётся константой `SCRIPT_DIR` в `src/constants.rs`
|
||||||
|
(по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gpui-editor-test --config /path/to/project main.go
|
||||||
|
cargo run -- --config ./config
|
||||||
|
cargo run -- main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
- В каталоге `--config` должны лежать `init.lua` и подключаемые им модули.
|
||||||
|
- Первый позиционный аргумент открывается после выполнения `init.lua`
|
||||||
|
через `app:open(path)` — как `vim main.go` или `nano main.go`.
|
||||||
|
|
||||||
|
## Объект `app`
|
||||||
|
|
||||||
|
Номера вкладок во всех методах нумеруются с нуля.
|
||||||
|
|
||||||
|
### Вкладки
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `app:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs" }, ...}` |
|
||||||
|
| `app:active_tab()` | Индекс активной вкладки |
|
||||||
|
| `app:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует |
|
||||||
|
| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
|
||||||
|
| `app:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс |
|
||||||
|
| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс |
|
||||||
|
| `app:open(path)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем |
|
||||||
|
| `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
|
||||||
|
|
||||||
|
### Текст
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `app:active_text()` | Текст активной вкладки |
|
||||||
|
| `app:set_active_text(text)` | Заменить текст активной вкладки |
|
||||||
|
|
||||||
|
### Подсветка синтаксиса
|
||||||
|
|
||||||
|
Подсветка по умолчанию выключена. Поддерживаемые языки: `rust`, `go`, `js`, `json`,
|
||||||
|
`python`.
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `app:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается |
|
||||||
|
| `app:language(index)` | Язык подсветки вкладки или `nil` |
|
||||||
|
| `app:clear_language(index)` | Выключить подсветку для вкладки |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
app:set_language(0, "rust")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сохранение
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `app:save()` | Сохранить активную вкладку в её файл |
|
||||||
|
|
||||||
|
### Привязка клавиш
|
||||||
|
|
||||||
|
`app:map_key(keys, fn)` назначает обработчик на нажатие или отпускание клавиши
|
||||||
|
(синтаксис в духе nvim):
|
||||||
|
|
||||||
|
| Запись | Значение |
|
||||||
|
| --- | --- |
|
||||||
|
| `"a"` | нажатие A |
|
||||||
|
| `"1"` | нажатие 1 |
|
||||||
|
| `"!a"` | отпускание A |
|
||||||
|
| `"<S-c>"` | C при зажатом Shift |
|
||||||
|
| `"<C-z>"` | Z при зажатом Ctrl |
|
||||||
|
| `"<A-!r>"` | отпускание R при зажатом Alt |
|
||||||
|
|
||||||
|
Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux
|
||||||
|
и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает
|
||||||
|
и на русской раскладке.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
app:map_key("<C-s>", function() app:save() end)
|
||||||
|
app:map_key("<!Esc>", function() app:close() end)
|
||||||
|
app:map_key("<C-q>", function() app:close() end)
|
||||||
|
app:map_key("<C-e>", function() app.right_panel:toggle() end)
|
||||||
|
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Окно
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `app:close()` | Закрыть окно |
|
||||||
|
| `app:toggle_maximize()` | Развернуть окно / вернуть прежний размер |
|
||||||
|
| `app:set_maximized(true/false)` | Развернуть (`true`) или восстановить (`false`) окно |
|
||||||
|
| `app:minimize()` | Свернуть окно |
|
||||||
|
| `app:is_maximized()` | Развёрнуто ли окно прямо сейчас |
|
||||||
|
|
||||||
|
## LSP-серверы
|
||||||
|
|
||||||
|
Запуск серверов целиком настраивается Lua-сценарием.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
app:run_lsp({
|
||||||
|
name = "gopls", -- условное имя для stop/restart
|
||||||
|
cmd = "gopls", -- путь к бинарнику или имя в PATH
|
||||||
|
args = { "serve" }, -- аргументы процесса
|
||||||
|
env = { GOFLAGS = "-mod=mod" }, -- переменные окружения
|
||||||
|
languages = { "go" }, -- какие languageId обслуживает
|
||||||
|
watcher = "**/*.go", -- шаблон файлового вотчера
|
||||||
|
root = ".", -- корень workspace (по умолчанию cwd)
|
||||||
|
initialization_options = {}, -- initializationOptions сервера
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `app:run_lsp(config)` | Запустить сервер по описанию |
|
||||||
|
| `app:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` |
|
||||||
|
| `app:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен |
|
||||||
|
| `app:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками |
|
||||||
|
|
||||||
|
Поле `watcher` принимает строку или таблицу:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
watcher = "**/*.go"
|
||||||
|
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Автодополнение включается само при вводе идентификатора; `Ctrl+Space` открывает
|
||||||
|
список вручную. Пока список открыт, `↑`/`↓` и `PageUp`/`PageDown` перемещают
|
||||||
|
выделение (курсор при этом стоит на месте), `Enter` или `Tab` вставляют выбранный
|
||||||
|
элемент, `Esc` закрывает список. Горизонтальные стрелки, `Home` и `End` закрывают
|
||||||
|
список и работают с курсором как обычно. Документация выбранного элемента
|
||||||
|
показывается второй всплывающей панелью справа от списка.
|
||||||
|
|
||||||
|
Примеры запуска:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
app:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" },
|
||||||
|
languages = { "go" }, watcher = "**/*.go" })
|
||||||
|
|
||||||
|
app:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer",
|
||||||
|
languages = { "rust" }, watcher = "**/*.rs" })
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример из `init.lua` с защитой от ошибки запуска:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local gopls_ok, gopls_err = pcall(function()
|
||||||
|
app:run_lsp({
|
||||||
|
name = "gopls",
|
||||||
|
cmd = "gopls",
|
||||||
|
args = { "serve" },
|
||||||
|
root = ".",
|
||||||
|
languages = { "go" },
|
||||||
|
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } },
|
||||||
|
initialization_options = {
|
||||||
|
usePlaceholders = true,
|
||||||
|
completeUnimported = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
if not gopls_ok then
|
||||||
|
print("[lsp] gopls не запущен: " .. tostring(gopls_err))
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
> `--config` меняет только каталог `init.lua`, поэтому `root = "."` указывает
|
||||||
|
> на текущий каталог запуска — проект должен быть текущим рабочим каталогом.
|
||||||
|
|
||||||
|
## Панели окна
|
||||||
|
|
||||||
|
По умолчанию панели закрыты, размер меняется перетаскиванием.
|
||||||
|
Для `app.left_panel`, `app.right_panel` и `app.bottom_panel` доступны одинаковые методы:
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `app.left_panel:open()` | Открыть панель |
|
||||||
|
| `app.left_panel:close()` | Закрыть панель |
|
||||||
|
| `app.left_panel:toggle()` | Переключить панель; вернуть `true`, если панель открыта |
|
||||||
|
| `app.left_panel:is_open()` | Открыта ли панель |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
app.left_panel:open()
|
||||||
|
app.right_panel:toggle()
|
||||||
|
app.bottom_panel:close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Подключение других Lua-файлов
|
||||||
|
|
||||||
|
Файлы подключаются из каталога сценариев:
|
||||||
|
|
||||||
|
| Способ | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `include("tools.lua")` | Выполнить файл сразу |
|
||||||
|
| `require("tools")` | Выполнить один раз (как обычный Lua-модуль) |
|
||||||
|
|
||||||
|
## Добавление новых методов
|
||||||
|
|
||||||
|
Методы добавляются в `src/api.rs`: всё, что есть в `impl AppApi`, помеченном
|
||||||
|
`#[mlua::userdata_impl]`, автоматически доступно из Lua.
|
||||||
|
Чтобы не экспортировать метод, его помечают `#[lua(skip)]`.
|
||||||
|
|
||||||
|
## Полный пример `init.lua`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
print("[lua] init.lua выполнен")
|
||||||
|
|
||||||
|
app:set_language(0, "rust")
|
||||||
|
app:next_tab()
|
||||||
|
|
||||||
|
app:map_key("<C-q>", function() app:close() end)
|
||||||
|
app:map_key("<C-e>", function() app.right_panel:toggle() end)
|
||||||
|
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
|
||||||
|
|
||||||
|
app:open_tab()
|
||||||
|
```
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
-- Стартовый сценарий приложения.
|
-- Стартовый сценарий приложения.
|
||||||
--
|
--
|
||||||
-- Приложение выполняет этот файл при запуске. Каталог поиска задаётся
|
-- Приложение выполняет этот файл при запуске. Каталог поиска задаётся
|
||||||
-- константой SCRIPT_DIR в src/constants.rs (по умолчанию — текущий каталог).
|
-- константой SCRIPT_DIR в src/constants.rs (по умолчанию — текущий каталог),
|
||||||
|
-- но его можно переопределить аргументом командной строки:
|
||||||
|
--
|
||||||
|
-- gpui-editor-test --config /path/to/project main.go
|
||||||
|
-- cargo run -- --config ./config
|
||||||
|
-- cargo run -- main.go
|
||||||
|
--
|
||||||
|
-- В каталоге --config должны лежать init.lua и подключаемые им модули.
|
||||||
|
-- Первый позиционный аргумент открывается после выполнения этого файла
|
||||||
|
-- через app:open(path) — как `vim main.go` или `nano main.go`.
|
||||||
|
|
||||||
print("[lua] init.lua выполнен")
|
print("[lua] init.lua выполнен")
|
||||||
|
|
||||||
@@ -12,6 +21,8 @@ print("[lua] init.lua выполнен")
|
|||||||
-- app:next_tab() -- переключить на следующую, вернуть индекс
|
-- app:next_tab() -- переключить на следующую, вернуть индекс
|
||||||
-- app:previous_tab() -- переключить на предыдущую, вернуть индекс
|
-- app:previous_tab() -- переключить на предыдущую, вернуть индекс
|
||||||
-- app:open_tab() -- открыть пустую вкладку, вернуть индекс
|
-- app:open_tab() -- открыть пустую вкладку, вернуть индекс
|
||||||
|
-- app:open(path) -- открыть файл с диска, вернуть индекс вкладки;
|
||||||
|
-- -- если файла нет — пустой буфер с таким именем
|
||||||
-- app:close_tab(index) -- true, если вкладка закрыта
|
-- app:close_tab(index) -- true, если вкладка закрыта
|
||||||
-- app:active_text() -- текст активной вкладки
|
-- app:active_text() -- текст активной вкладки
|
||||||
-- app:set_active_text(text) -- заменить текст активной вкладки
|
-- app:set_active_text(text) -- заменить текст активной вкладки
|
||||||
@@ -37,6 +48,36 @@ print("[lua] init.lua выполнен")
|
|||||||
-- app:minimize()
|
-- app:minimize()
|
||||||
-- app:is_maximized()
|
-- app:is_maximized()
|
||||||
--
|
--
|
||||||
|
-- LSP-серверы (запуск целиком настраивается этим сценарием):
|
||||||
|
-- app:run_lsp({
|
||||||
|
-- name = "gopls", -- условное имя для stop/restart
|
||||||
|
-- cmd = "gopls", -- путь к бинарнику или имя в PATH
|
||||||
|
-- args = { "serve" }, -- аргументы процесса
|
||||||
|
-- env = { GOFLAGS = "-mod=mod" }, -- переменные окружения
|
||||||
|
-- languages = { "go" }, -- какие languageId обслуживает
|
||||||
|
-- watcher = "**/*.go", -- шаблон файлового вотчера
|
||||||
|
-- root = ".", -- корень workspace (по умолчанию cwd)
|
||||||
|
-- initialization_options = {}, -- initializationOptions сервера
|
||||||
|
-- })
|
||||||
|
-- app:lsp_servers() -- { "gopls", ... }
|
||||||
|
-- app:stop_lsp("gopls") -- true, если сервер был запущен
|
||||||
|
-- app:restart_lsp("gopls") -- перезапуск с прежними настройками
|
||||||
|
--
|
||||||
|
-- Поле watcher принимает строку или таблицу:
|
||||||
|
-- watcher = "**/*.go"
|
||||||
|
-- watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
|
||||||
|
--
|
||||||
|
-- Автодополнение включается само при вводе идентификатора; Ctrl+Space
|
||||||
|
-- открывает список вручную. Пока список открыт, ↑/↓ и PageUp/PageDown
|
||||||
|
-- перемещают выделение, Enter или Tab вставляют элемент, Esc закрывает
|
||||||
|
-- список; горизонтальные стрелки закрывают его и двигают курсор.
|
||||||
|
-- Документация выбранного элемента показывается второй всплывающей панелью
|
||||||
|
-- справа от списка. Примеры запуска:
|
||||||
|
-- app:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" },
|
||||||
|
-- languages = { "go" }, watcher = "**/*.go" })
|
||||||
|
-- app:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer",
|
||||||
|
-- languages = { "rust" }, watcher = "**/*.rs" })
|
||||||
|
--
|
||||||
-- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием):
|
-- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием):
|
||||||
-- app.left_panel:open()
|
-- app.left_panel:open()
|
||||||
-- app.left_panel:close()
|
-- app.left_panel:close()
|
||||||
@@ -64,6 +105,33 @@ print("[lua] init.lua выполнен")
|
|||||||
app:set_language(0, "rust")
|
app:set_language(0, "rust")
|
||||||
app:next_tab()
|
app:next_tab()
|
||||||
|
|
||||||
|
-- gopls запускается автоматически при старте редактора.
|
||||||
|
-- Сервер обслуживает только документы с languageId "go" и получает изменения
|
||||||
|
-- всех .go-файлов проекта (кроме vendor/) через файловый вотчер.
|
||||||
|
local gopls_ok, gopls_err = pcall(function()
|
||||||
|
app:run_lsp({
|
||||||
|
name = "gopls",
|
||||||
|
cmd = "gopls",
|
||||||
|
args = { "serve" },
|
||||||
|
-- Рабочий каталог gopls. --config меняет только каталог init.lua,
|
||||||
|
-- поэтому проект должен быть текущим каталогом запуска.
|
||||||
|
root = ".",
|
||||||
|
languages = { "go" },
|
||||||
|
watcher = {
|
||||||
|
pattern = "**/*.go",
|
||||||
|
ignore = { "vendor/**" },
|
||||||
|
},
|
||||||
|
-- gopls читает эти параметры из initializationOptions.
|
||||||
|
initialization_options = {
|
||||||
|
usePlaceholders = true,
|
||||||
|
completeUnimported = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
if not gopls_ok then
|
||||||
|
print("[lsp] gopls не запущен: " .. tostring(gopls_err))
|
||||||
|
end
|
||||||
|
|
||||||
-- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
|
-- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
|
||||||
-- app:map_key("<C-s>", function() app:save() end)
|
-- app:map_key("<C-s>", function() app:save() end)
|
||||||
-- app:map_key("<!Esc>", function() app:close() end)
|
-- app:map_key("<!Esc>", function() app:close() end)
|
||||||
@@ -72,3 +140,5 @@ app:map_key("<C-q>", function() app:close() end)
|
|||||||
|
|
||||||
app:map_key("<C-e>", function() app.right_panel:toggle() end)
|
app:map_key("<C-e>", function() app.right_panel:toggle() end)
|
||||||
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
|
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
|
||||||
|
|
||||||
|
app:open_tab()
|
||||||
|
|||||||
+277
-3
@@ -10,11 +10,16 @@
|
|||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde_json::Number;
|
||||||
|
|
||||||
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
|
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
|
||||||
use crate::editor::{Language, Workspace};
|
use crate::editor::{Language, Workspace};
|
||||||
use crate::keymap::{KeyBinding, KeyMap};
|
use crate::keymap::{KeyBinding, KeyMap};
|
||||||
|
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
|
||||||
use crate::panel::{PanelHandle, PanelState};
|
use crate::panel::{PanelHandle, PanelState};
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
@@ -86,6 +91,8 @@ pub struct AppApi {
|
|||||||
right_panel: PanelHandle,
|
right_panel: PanelHandle,
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
bottom_panel: PanelHandle,
|
bottom_panel: PanelHandle,
|
||||||
|
#[lua(skip)]
|
||||||
|
lsp: Arc<LspManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API одной панели окна.
|
/// API одной панели окна.
|
||||||
@@ -145,6 +152,7 @@ impl AppApi {
|
|||||||
left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))),
|
left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))),
|
||||||
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
|
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
|
||||||
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
|
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
|
||||||
|
lsp: Arc::new(LspManager::new()),
|
||||||
};
|
};
|
||||||
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
||||||
api
|
api
|
||||||
@@ -181,6 +189,12 @@ impl AppApi {
|
|||||||
self.bottom_panel.clone()
|
self.bottom_panel.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Менеджер LSP-серверов (для вида и тестов).
|
||||||
|
#[lua(skip)]
|
||||||
|
pub fn lsp_handle(&self) -> Arc<LspManager> {
|
||||||
|
self.lsp.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Все открытые вкладки в порядке слева направо.
|
/// Все открытые вкладки в порядке слева направо.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||||||
@@ -236,10 +250,16 @@ impl AppApi {
|
|||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn close_tab(&self, index: usize) -> bool {
|
pub fn close_tab(&self, index: usize) -> bool {
|
||||||
let mut workspace = self.workspace.borrow_mut();
|
let mut workspace = self.workspace.borrow_mut();
|
||||||
if index >= workspace.documents().len() {
|
let Some(document) = workspace.document(index) else {
|
||||||
return false;
|
return false;
|
||||||
}
|
};
|
||||||
|
let uri = document_uri(&document.name);
|
||||||
workspace.close(index);
|
workspace.close(index);
|
||||||
|
drop(workspace);
|
||||||
|
|
||||||
|
if let Some(uri) = uri {
|
||||||
|
self.lsp.close_document(&uri);
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +274,10 @@ impl AppApi {
|
|||||||
let doc = workspace.active();
|
let doc = workspace.active();
|
||||||
(doc.name.clone(), doc.content.clone())
|
(doc.name.clone(), doc.content.clone())
|
||||||
};
|
};
|
||||||
std::fs::write(&path, content)?;
|
std::fs::write(&path, &content)?;
|
||||||
|
if let Some(uri) = document_uri(&path) {
|
||||||
|
self.lsp.did_save(&uri, &content);
|
||||||
|
}
|
||||||
self.workspace.borrow_mut().mark_active_saved();
|
self.workspace.borrow_mut().mark_active_saved();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -311,6 +334,34 @@ impl AppApi {
|
|||||||
workspace.active_index()
|
workspace.active_index()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Открывает файл с диска на редактирование (Rust-версия `app:open`).
|
||||||
|
///
|
||||||
|
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
|
||||||
|
/// Язык подсветки определяется по расширению, если для него есть грамматика.
|
||||||
|
#[lua(skip)]
|
||||||
|
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let content = match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = path.to_string_lossy().into_owned();
|
||||||
|
let language = Language::from_path(&name);
|
||||||
|
let mut workspace = self.workspace.borrow_mut();
|
||||||
|
Ok(workspace.open_file(name, content, language))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lua-версия [`AppApi::open`]: `app:open("main.go")`.
|
||||||
|
///
|
||||||
|
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
|
||||||
|
#[lua(name = "open")]
|
||||||
|
fn lua_open(&self, path: String) -> mlua::Result<usize> {
|
||||||
|
self.open(&path)
|
||||||
|
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
|
||||||
|
}
|
||||||
|
|
||||||
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
|
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
|
||||||
///
|
///
|
||||||
/// Возвращает `false`, если вкладки с таким номером нет.
|
/// Возвращает `false`, если вкладки с таким номером нет.
|
||||||
@@ -423,4 +474,227 @@ impl AppApi {
|
|||||||
pub fn is_maximized(&self) -> bool {
|
pub fn is_maximized(&self) -> bool {
|
||||||
WINDOW_MAXIMIZED.with(Cell::get)
|
WINDOW_MAXIMIZED.with(Cell::get)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Запускает LSP-сервер по описанию (Rust-версия Lua-метода).
|
||||||
|
#[lua(skip)]
|
||||||
|
pub fn run_lsp(&self, config: LspConfig) -> Result<(), String> {
|
||||||
|
self.lsp.start(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запускает LSP-сервер.
|
||||||
|
///
|
||||||
|
/// Пример из Lua:
|
||||||
|
///
|
||||||
|
/// ```lua
|
||||||
|
/// app:run_lsp({
|
||||||
|
/// name = "gopls",
|
||||||
|
/// cmd = "/usr/local/bin/gopls",
|
||||||
|
/// args = { "serve" },
|
||||||
|
/// env = { GOFLAGS = "-mod=mod" },
|
||||||
|
/// languages = { "go" },
|
||||||
|
/// watcher = "**/*.go",
|
||||||
|
/// initialization_options = { usePlaceholders = true },
|
||||||
|
/// })
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Возвращает `true`, если сервер запущен. Ошибка запуска или таймаут
|
||||||
|
/// `initialize` превращается в ошибку Lua.
|
||||||
|
#[lua(name = "run_lsp")]
|
||||||
|
fn lua_run_lsp(&self, config: mlua::Table) -> mlua::Result<bool> {
|
||||||
|
let config = parse_lsp_config(&config)?;
|
||||||
|
self.run_lsp(config)
|
||||||
|
.map(|()| true)
|
||||||
|
.map_err(mlua::Error::RuntimeError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имена всех запущенных LSP-серверов.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn lsp_servers(&self) -> Vec<String> {
|
||||||
|
self.lsp.server_names()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Останавливает LSP-сервер по имени.
|
||||||
|
///
|
||||||
|
/// Возвращает `false`, если сервера с таким именем не было.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn stop_lsp(&self, name: String) -> bool {
|
||||||
|
self.lsp.stop(&name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Перезапускает LSP-сервер с прежними настройками.
|
||||||
|
///
|
||||||
|
/// Возвращает `false`, если сервера не было или новый процесс не поднялся.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn restart_lsp(&self, name: String) -> bool {
|
||||||
|
self.lsp.restart(&name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает Lua-таблицу, переданную в `app:run_lsp`.
|
||||||
|
fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
|
||||||
|
let name: String = table
|
||||||
|
.get("name")
|
||||||
|
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?;
|
||||||
|
|
||||||
|
let command = ["cmd", "command", "path", "binary"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| table.get::<String>(*key).ok())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
mlua::Error::RuntimeError(
|
||||||
|
"в run_lsp нужен путь к серверу: cmd, command, path или binary".to_owned(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let args = match table.get::<mlua::Table>("args") {
|
||||||
|
Ok(args) => args
|
||||||
|
.sequence_values::<String>()
|
||||||
|
.collect::<mlua::Result<Vec<_>>>()?,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let env = match table.get::<mlua::Table>("env") {
|
||||||
|
Ok(env) => env
|
||||||
|
.pairs::<String, String>()
|
||||||
|
.collect::<mlua::Result<Vec<_>>>()?,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let root = table
|
||||||
|
.get::<String>("root")
|
||||||
|
.ok()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||||
|
let root = if root.is_absolute() {
|
||||||
|
root
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join(root)
|
||||||
|
};
|
||||||
|
let root = std::fs::canonicalize(&root).unwrap_or(root);
|
||||||
|
|
||||||
|
let languages = match table.get::<mlua::Value>("languages") {
|
||||||
|
Ok(value) => lua_strings(value)?,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let initialization_options = ["initialization_options", "init_options"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| match table.get::<mlua::Value>(*key) {
|
||||||
|
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||||
|
Ok(value) => Some(lua_to_json(value)),
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
Ok(LspConfig {
|
||||||
|
name,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
root,
|
||||||
|
languages,
|
||||||
|
watcher: parse_watcher(table)?,
|
||||||
|
initialization_options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
|
||||||
|
let value = ["watcher", "file_watcher", "watch"].iter().find_map(|key| {
|
||||||
|
match table.get::<mlua::Value>(*key) {
|
||||||
|
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||||
|
Ok(value) => Some(value),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
match value {
|
||||||
|
mlua::Value::String(pattern) => Ok(Some(WatcherConfig {
|
||||||
|
patterns: vec![pattern.to_string_lossy()],
|
||||||
|
ignore: Vec::new(),
|
||||||
|
})),
|
||||||
|
mlua::Value::Boolean(true) => Ok(Some(WatcherConfig {
|
||||||
|
patterns: vec!["**/*".to_owned()],
|
||||||
|
ignore: Vec::new(),
|
||||||
|
})),
|
||||||
|
mlua::Value::Boolean(false) | mlua::Value::Nil => Ok(None),
|
||||||
|
mlua::Value::Table(config) => {
|
||||||
|
let patterns = ["pattern", "patterns"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| match config.get::<mlua::Value>(*key) {
|
||||||
|
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||||
|
Ok(value) => Some(lua_strings(value)),
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let ignore = ["ignore", "ignored"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| match config.get::<mlua::Value>(*key) {
|
||||||
|
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||||
|
Ok(value) => Some(lua_strings(value)),
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if patterns.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(WatcherConfig { patterns, ignore }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Err(mlua::Error::RuntimeError(
|
||||||
|
"watcher должен быть строкой, таблицей или boolean".to_owned(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lua_strings(value: mlua::Value) -> mlua::Result<Vec<String>> {
|
||||||
|
match value {
|
||||||
|
mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]),
|
||||||
|
mlua::Value::Table(table) => table
|
||||||
|
.sequence_values::<String>()
|
||||||
|
.collect::<mlua::Result<Vec<_>>>(),
|
||||||
|
mlua::Value::Nil => Ok(Vec::new()),
|
||||||
|
_ => Err(mlua::Error::RuntimeError(
|
||||||
|
"ожидалась строка или массив строк".to_owned(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lua_to_json(value: mlua::Value) -> mlua::Result<serde_json::Value> {
|
||||||
|
match value {
|
||||||
|
mlua::Value::Nil => Ok(serde_json::Value::Null),
|
||||||
|
mlua::Value::Boolean(value) => Ok(serde_json::Value::Bool(value)),
|
||||||
|
mlua::Value::Integer(value) => Ok(serde_json::Value::Number(value.into())),
|
||||||
|
mlua::Value::Number(value) => Ok(Number::from_f64(value)
|
||||||
|
.map(serde_json::Value::Number)
|
||||||
|
.unwrap_or(serde_json::Value::Null)),
|
||||||
|
mlua::Value::String(value) => Ok(serde_json::Value::String(value.to_string_lossy())),
|
||||||
|
mlua::Value::Table(table) => {
|
||||||
|
let length = table.raw_len();
|
||||||
|
if length > 0 {
|
||||||
|
let mut items = Vec::with_capacity(length);
|
||||||
|
for value in table.sequence_values::<mlua::Value>() {
|
||||||
|
items.push(lua_to_json(value?)?);
|
||||||
|
}
|
||||||
|
Ok(serde_json::Value::Array(items))
|
||||||
|
} else {
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
for pair in table.pairs::<mlua::Value, mlua::Value>() {
|
||||||
|
let (key, value) = pair?;
|
||||||
|
let key = match key {
|
||||||
|
mlua::Value::String(key) => key.to_string_lossy(),
|
||||||
|
mlua::Value::Integer(key) => key.to_string(),
|
||||||
|
mlua::Value::Number(key) => key.to_string(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
map.insert(key, lua_to_json(value)?);
|
||||||
|
}
|
||||||
|
Ok(serde_json::Value::Object(map))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Ok(serde_json::Value::Null),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+923
-10
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
|||||||
|
//! Элементы интерфейса автодополнения.
|
||||||
|
//!
|
||||||
|
//! Здесь живёт всплывающий список, который привязывается к карете редактора
|
||||||
|
//! через свежую геометрию prepaint. Логика запросов к LSP и состояние выбора
|
||||||
|
//! находятся в [`MyApp`](crate::app::MyApp): этот модуль занимается только
|
||||||
|
//! отображением и небольшими преобразованиями элементов `CompletionItem`.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use gpui_kit::component::input::{EditorState, PrepaintCaretGeometry};
|
||||||
|
use gpui_kit::{
|
||||||
|
AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, Hsla,
|
||||||
|
InspectorElementId, IntoElement, LayoutId, Pixels, Point, Size, Style, WeakEntity, Window, px,
|
||||||
|
};
|
||||||
|
use lsp_types::{
|
||||||
|
CompletionItem, CompletionItemKind, CompletionResponse, CompletionTextEdit, Documentation,
|
||||||
|
InsertTextFormat,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::lsp::{offset_to_position, position_to_offset};
|
||||||
|
|
||||||
|
/// Функция, которая строит содержимое поповера в его `prepaint`.
|
||||||
|
///
|
||||||
|
/// Второй аргумент — признак того, что поповер будет нарисован над строкой
|
||||||
|
/// с каретой: в этом случае содержимое выравнивается по нижнему краю.
|
||||||
|
type PopupBuilder = Box<dyn Fn(Point<Pixels>, bool, &mut Window, &mut App) -> AnyElement>;
|
||||||
|
|
||||||
|
/// Отступ от краёв окна, в который поповер не заходит.
|
||||||
|
pub(crate) const POPUP_MARGIN: f32 = 8.;
|
||||||
|
|
||||||
|
/// Зазор между каретой и краем поповера.
|
||||||
|
const POPUP_GAP: f32 = 4.;
|
||||||
|
|
||||||
|
/// Всплывающий список, привязанный к карете редактора.
|
||||||
|
///
|
||||||
|
/// Как и встроенные поповеры gpui-component, элемент разрешает позицию в
|
||||||
|
/// `prepaint`, то есть по геометрии текущего кадра, а не предыдущего.
|
||||||
|
///
|
||||||
|
/// Если снизу от кареты нет места, поповер раскрывается вверх; если он не
|
||||||
|
/// помещается справа, он сдвигается влево, оставаясь внутри окна.
|
||||||
|
pub struct CaretPopup {
|
||||||
|
editor: WeakEntity<EditorState>,
|
||||||
|
build: PopupBuilder,
|
||||||
|
content: Option<AnyElement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaretPopup {
|
||||||
|
pub fn new(
|
||||||
|
editor: WeakEntity<EditorState>,
|
||||||
|
build: impl Fn(Point<Pixels>, bool, &mut Window, &mut App) -> AnyElement + 'static,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
editor,
|
||||||
|
build: Box::new(build),
|
||||||
|
content: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for CaretPopup {
|
||||||
|
type RequestLayoutState = ();
|
||||||
|
type PrepaintState = ();
|
||||||
|
|
||||||
|
fn id(&self) -> Option<ElementId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_layout(
|
||||||
|
&mut self,
|
||||||
|
_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&InspectorElementId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> (LayoutId, Self::RequestLayoutState) {
|
||||||
|
let layout = window.request_layout(Style::default(), [], cx);
|
||||||
|
(layout, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepaint(
|
||||||
|
&mut self,
|
||||||
|
_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&InspectorElementId>,
|
||||||
|
_bounds: Bounds<Pixels>,
|
||||||
|
_request_layout: &mut Self::RequestLayoutState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let Some(editor) = self.editor.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(geometry) = editor.read(cx).prepaint_caret_geometry() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let anchor = caret_anchor(geometry);
|
||||||
|
let viewport = window.viewport_size();
|
||||||
|
let available = Size {
|
||||||
|
width: AvailableSpace::Definite(viewport.width),
|
||||||
|
height: AvailableSpace::Definite(viewport.height),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сначала меряем содержимое, чтобы решить, помещается ли оно снизу.
|
||||||
|
let mut content = (self.build)(anchor, false, window, cx);
|
||||||
|
let size = content.layout_as_root(available, window, cx);
|
||||||
|
let placement = popup_origin(anchor, caret_top(geometry), size, viewport);
|
||||||
|
|
||||||
|
// Над строкой поповеры выравниваются по нижнему краю. Это свойство
|
||||||
|
// их общего контейнера, поэтому для верхнего варианта собираем его
|
||||||
|
// заново с другим выравниванием.
|
||||||
|
if placement.above {
|
||||||
|
content = (self.build)(anchor, true, window, cx);
|
||||||
|
content.layout_as_root(available, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.prepaint_at(placement.origin, window, cx);
|
||||||
|
self.content = Some(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(
|
||||||
|
&mut self,
|
||||||
|
_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&InspectorElementId>,
|
||||||
|
_bounds: Bounds<Pixels>,
|
||||||
|
_request_layout: &mut Self::RequestLayoutState,
|
||||||
|
_prepaint: &mut Self::PrepaintState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
if let Some(content) = self.content.as_mut() {
|
||||||
|
content.paint(window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoElement for CaretPopup {
|
||||||
|
type Element = Self;
|
||||||
|
|
||||||
|
fn into_element(self) -> Self::Element {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn caret_anchor(geometry: PrepaintCaretGeometry) -> Point<Pixels> {
|
||||||
|
Point::new(
|
||||||
|
geometry.cursor_bounds().origin.x - px(POPUP_GAP),
|
||||||
|
caret_top(geometry) + geometry.line_height() + px(POPUP_GAP),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Верхняя граница строки с каретой в координатах окна.
|
||||||
|
fn caret_top(geometry: PrepaintCaretGeometry) -> Pixels {
|
||||||
|
geometry.cursor_bounds().origin.y + geometry.scroll_offset().y
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Положение поповера и то, нарисован ли он над строкой с каретой.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
struct PopupPlacement {
|
||||||
|
origin: Point<Pixels>,
|
||||||
|
above: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбирает положение поповера так, чтобы он целиком помещался в окне.
|
||||||
|
///
|
||||||
|
/// По умолчанию поповер стоит под каретой. Если снизу места мало, он
|
||||||
|
/// переносится над строкой; если не помещается справа, сдвигается влево.
|
||||||
|
fn popup_origin(
|
||||||
|
anchor: Point<Pixels>,
|
||||||
|
caret_top: Pixels,
|
||||||
|
size: Size<Pixels>,
|
||||||
|
viewport: Size<Pixels>,
|
||||||
|
) -> PopupPlacement {
|
||||||
|
let margin = px(POPUP_MARGIN);
|
||||||
|
let mut origin = anchor;
|
||||||
|
let mut above = false;
|
||||||
|
|
||||||
|
if origin.y + size.height > viewport.height - margin {
|
||||||
|
above = true;
|
||||||
|
let above_y = caret_top - size.height - px(POPUP_GAP);
|
||||||
|
origin.y = if above_y >= margin {
|
||||||
|
above_y
|
||||||
|
} else {
|
||||||
|
(viewport.height - margin - size.height).max(margin)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
origin.x = origin.x.max(margin);
|
||||||
|
if origin.x + size.width > viewport.width - margin {
|
||||||
|
origin.x = (viewport.width - margin - size.width).max(margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupPlacement { origin, above }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Начало слова под каретой (для замены префикса при вставке).
|
||||||
|
pub fn word_start(text: &str, cursor: usize) -> usize {
|
||||||
|
let cursor = clamp_offset(text, cursor);
|
||||||
|
let mut start = cursor;
|
||||||
|
for (index, ch) in text[..cursor].char_indices().rev() {
|
||||||
|
if ch.is_alphanumeric() || ch == '_' {
|
||||||
|
start = index;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает ответ `textDocument/completion`.
|
||||||
|
pub fn parse_completion_response(value: serde_json::Value) -> Vec<CompletionItem> {
|
||||||
|
if value.is_null() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
serde_json::from_value::<CompletionResponse>(value)
|
||||||
|
.map(|response| match response {
|
||||||
|
CompletionResponse::Array(items) => items,
|
||||||
|
CompletionResponse::List(list) => list.items,
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Текст документации элемента в виде Markdown.
|
||||||
|
pub fn documentation_text(documentation: &Documentation) -> String {
|
||||||
|
match documentation {
|
||||||
|
Documentation::String(text) => text.clone(),
|
||||||
|
Documentation::MarkupContent(content) => content.value.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запасной текст, если сервер не прислал документацию.
|
||||||
|
pub fn fallback_documentation(item: &CompletionItem) -> String {
|
||||||
|
let kind = completion_kind_name(item.kind);
|
||||||
|
match item.detail.as_ref() {
|
||||||
|
Some(detail) if !detail.is_empty() => format!("`{detail}`\n\n{kind}"),
|
||||||
|
_ => format!("**{}**\n\n{kind}", item.label),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ключ кэша документации: разные элементы с одинаковым именем в разных
|
||||||
|
/// контекстах (например, `len` у разных типов) не должны перепутаться.
|
||||||
|
pub fn item_key(item: &CompletionItem) -> String {
|
||||||
|
format!(
|
||||||
|
"{}\u{1}{}\u{1}{}",
|
||||||
|
item.label,
|
||||||
|
item.detail.as_deref().unwrap_or_default(),
|
||||||
|
item.kind
|
||||||
|
.map(|kind| format!("{kind:?}"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Человекочитаемый тип элемента.
|
||||||
|
pub fn completion_kind_name(kind: Option<CompletionItemKind>) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
Some(CompletionItemKind::METHOD) => "метод",
|
||||||
|
Some(CompletionItemKind::FUNCTION) => "функция",
|
||||||
|
Some(CompletionItemKind::CONSTRUCTOR) => "конструктор",
|
||||||
|
Some(CompletionItemKind::FIELD) => "поле",
|
||||||
|
Some(CompletionItemKind::VARIABLE) => "переменная",
|
||||||
|
Some(CompletionItemKind::CONSTANT) => "константа",
|
||||||
|
Some(CompletionItemKind::STRUCT) => "структура",
|
||||||
|
Some(CompletionItemKind::CLASS) => "класс",
|
||||||
|
Some(CompletionItemKind::INTERFACE) => "интерфейс",
|
||||||
|
Some(CompletionItemKind::ENUM) => "перечисление",
|
||||||
|
Some(CompletionItemKind::ENUM_MEMBER) => "элемент перечисления",
|
||||||
|
Some(CompletionItemKind::MODULE) => "модуль",
|
||||||
|
Some(CompletionItemKind::PROPERTY) => "свойство",
|
||||||
|
Some(CompletionItemKind::KEYWORD) => "ключевое слово",
|
||||||
|
Some(CompletionItemKind::SNIPPET) => "шаблон",
|
||||||
|
Some(CompletionItemKind::TYPE_PARAMETER) => "параметр типа",
|
||||||
|
Some(CompletionItemKind::OPERATOR) => "оператор",
|
||||||
|
Some(CompletionItemKind::EVENT) => "событие",
|
||||||
|
Some(CompletionItemKind::FILE) => "файл",
|
||||||
|
Some(CompletionItemKind::FOLDER) => "каталог",
|
||||||
|
Some(CompletionItemKind::TEXT) => "текст",
|
||||||
|
Some(_) => "значение",
|
||||||
|
None => "элемент",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Иконка для типа элемента автодополнения.
|
||||||
|
///
|
||||||
|
/// Используется полный каталог иконок gpui-kit-assets, а не компактный набор
|
||||||
|
/// компонента: в нём есть `square-function`, `variable`, `braces` и другие
|
||||||
|
/// привычные символы IDE.
|
||||||
|
pub fn completion_icon(kind: Option<CompletionItemKind>) -> gpui_kit::assets::IconName {
|
||||||
|
use gpui_kit::assets::IconName as Icon;
|
||||||
|
|
||||||
|
match kind {
|
||||||
|
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => Icon::SquareFunction,
|
||||||
|
Some(
|
||||||
|
CompletionItemKind::STRUCT
|
||||||
|
| CompletionItemKind::CLASS
|
||||||
|
| CompletionItemKind::INTERFACE
|
||||||
|
| CompletionItemKind::ENUM
|
||||||
|
| CompletionItemKind::CONSTRUCTOR,
|
||||||
|
) => Icon::Box,
|
||||||
|
Some(
|
||||||
|
CompletionItemKind::VARIABLE
|
||||||
|
| CompletionItemKind::FIELD
|
||||||
|
| CompletionItemKind::PROPERTY
|
||||||
|
| CompletionItemKind::CONSTANT
|
||||||
|
| CompletionItemKind::ENUM_MEMBER,
|
||||||
|
) => Icon::Variable,
|
||||||
|
Some(CompletionItemKind::KEYWORD) => Icon::Type,
|
||||||
|
Some(CompletionItemKind::MODULE) => Icon::Braces,
|
||||||
|
Some(CompletionItemKind::FILE) => Icon::FileText,
|
||||||
|
Some(CompletionItemKind::FOLDER) => Icon::Folder,
|
||||||
|
Some(CompletionItemKind::SNIPPET) => Icon::Component,
|
||||||
|
Some(CompletionItemKind::EVENT) => Icon::Activity,
|
||||||
|
Some(CompletionItemKind::TYPE_PARAMETER) => Icon::TypeOutline,
|
||||||
|
Some(CompletionItemKind::OPERATOR) => Icon::CaseSensitive,
|
||||||
|
Some(_) => Icon::Circle,
|
||||||
|
None => Icon::Sparkles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Цвет иконки для типа элемента.
|
||||||
|
pub fn completion_icon_color(
|
||||||
|
kind: Option<CompletionItemKind>,
|
||||||
|
theme: &gpui_kit::component::Theme,
|
||||||
|
) -> Hsla {
|
||||||
|
match kind {
|
||||||
|
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => theme.blue,
|
||||||
|
Some(
|
||||||
|
CompletionItemKind::STRUCT
|
||||||
|
| CompletionItemKind::CLASS
|
||||||
|
| CompletionItemKind::INTERFACE
|
||||||
|
| CompletionItemKind::ENUM,
|
||||||
|
) => theme.yellow,
|
||||||
|
Some(
|
||||||
|
CompletionItemKind::VARIABLE
|
||||||
|
| CompletionItemKind::FIELD
|
||||||
|
| CompletionItemKind::PROPERTY
|
||||||
|
| CompletionItemKind::CONSTANT
|
||||||
|
| CompletionItemKind::ENUM_MEMBER,
|
||||||
|
) => theme.green,
|
||||||
|
Some(CompletionItemKind::KEYWORD) => theme.magenta,
|
||||||
|
Some(CompletionItemKind::MODULE) => theme.cyan,
|
||||||
|
_ => theme.muted_foreground,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Диапазон подсветки совпадения в подписи элемента.
|
||||||
|
pub fn highlight_range(query: &str, label: &str) -> Option<Range<usize>> {
|
||||||
|
if query.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let query = query.to_lowercase();
|
||||||
|
let lowercase = label.to_lowercase();
|
||||||
|
let start = lowercase.find(&query)?;
|
||||||
|
|
||||||
|
// Нижний регистр иногда меняет длину строки, поэтому диапазон
|
||||||
|
// проверяем по исходной метке и не отдаём разрезанный символ.
|
||||||
|
let end = (start + query.len()).min(label.len());
|
||||||
|
if start < end && label.is_char_boundary(start) && label.is_char_boundary(end) {
|
||||||
|
Some(start..end)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Подготавливает замену текста при выборе элемента.
|
||||||
|
///
|
||||||
|
/// Возвращает байтовый диапазон в документе и вставляемый текст. Если сервер
|
||||||
|
/// прислал `textEdit`, используются его границы; иначе заменяется слово под
|
||||||
|
/// каретой. Сниппеты раскрываются в плоский текст: табов-заглушек в нашем
|
||||||
|
/// редакторе пока нет.
|
||||||
|
pub fn completion_edit(
|
||||||
|
item: &CompletionItem,
|
||||||
|
text: &str,
|
||||||
|
start: usize,
|
||||||
|
cursor: usize,
|
||||||
|
) -> (Range<usize>, String) {
|
||||||
|
let mut range = clamp_offset(text, start)..clamp_offset(text, cursor);
|
||||||
|
let mut new_text = item
|
||||||
|
.insert_text
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| item.label.clone());
|
||||||
|
|
||||||
|
if let Some(edit) = &item.text_edit {
|
||||||
|
match edit {
|
||||||
|
CompletionTextEdit::Edit(edit) => {
|
||||||
|
range = position_to_offset(text, edit.range.start)
|
||||||
|
..position_to_offset(text, edit.range.end);
|
||||||
|
new_text.clone_from(&edit.new_text);
|
||||||
|
}
|
||||||
|
CompletionTextEdit::InsertAndReplace(edit) => {
|
||||||
|
range = position_to_offset(text, edit.replace.start)
|
||||||
|
..position_to_offset(text, edit.replace.end);
|
||||||
|
new_text.clone_from(&edit.new_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.insert_text_format == Some(InsertTextFormat::SNIPPET) {
|
||||||
|
new_text = strip_snippet(&new_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = clamp_offset(text, range.start).min(text.len());
|
||||||
|
let end = clamp_offset(text, range.end).max(start).min(text.len());
|
||||||
|
(start..end, new_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разворачивает LSP-сниппет в обычный текст.
|
||||||
|
pub fn strip_snippet(snippet: &str) -> String {
|
||||||
|
let chars: Vec<char> = snippet.chars().collect();
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut index = 0;
|
||||||
|
|
||||||
|
while index < chars.len() {
|
||||||
|
if chars[index] == '$' {
|
||||||
|
if index + 1 < chars.len() && chars[index + 1] == '{' {
|
||||||
|
if let Some(end) = (index + 2..chars.len()).find(|&pos| chars[pos] == '}') {
|
||||||
|
let inner: String = chars[index + 2..end].iter().collect();
|
||||||
|
if let Some((_, default)) = inner.split_once(':') {
|
||||||
|
output.push_str(default);
|
||||||
|
}
|
||||||
|
index = end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if index + 1 < chars.len() && chars[index + 1].is_ascii_digit() {
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
} else if index + 1 < chars.len() && chars[index + 1] == '$' {
|
||||||
|
output.push('$');
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(chars[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_offset(text: &str, offset: usize) -> usize {
|
||||||
|
let mut offset = offset.min(text.len());
|
||||||
|
while offset > 0 && !text.is_char_boundary(offset) {
|
||||||
|
offset -= 1;
|
||||||
|
}
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Позиция LSP для диапазона замены (удобно для `apply_lsp_edits`).
|
||||||
|
pub fn range_for_lsp(text: &str, range: &Range<usize>) -> lsp_types::Range {
|
||||||
|
lsp_types::Range {
|
||||||
|
start: offset_to_position(text, range.start),
|
||||||
|
end: offset_to_position(text, range.end),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn item(label: &str) -> CompletionItem {
|
||||||
|
CompletionItem {
|
||||||
|
label: label.to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn word_start_stops_at_non_identifier_characters() {
|
||||||
|
assert_eq!(word_start("let value", 9), 4);
|
||||||
|
assert_eq!(word_start("obj.field", 9), 4);
|
||||||
|
assert_eq!(word_start("obj.", 4), 4);
|
||||||
|
assert_eq!(word_start("привет", 6), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snippets_lose_placeholders() {
|
||||||
|
assert_eq!(strip_snippet("println!(${1:value})"), "println!(value)");
|
||||||
|
assert_eq!(strip_snippet("foo($1, $0)"), "foo(, )");
|
||||||
|
assert_eq!(strip_snippet("cost $$5"), "cost $5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn popup_drops_below_the_caret_when_there_is_room() {
|
||||||
|
let viewport = Size {
|
||||||
|
width: px(800.),
|
||||||
|
height: px(600.),
|
||||||
|
};
|
||||||
|
let anchor = Point::new(px(100.), px(200.));
|
||||||
|
let size = Size {
|
||||||
|
width: px(300.),
|
||||||
|
height: px(200.),
|
||||||
|
};
|
||||||
|
|
||||||
|
let placement = popup_origin(anchor, px(180.), size, viewport);
|
||||||
|
assert_eq!(placement.origin, Point::new(px(100.), px(200.)));
|
||||||
|
assert!(!placement.above);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn popup_flips_above_when_bottom_space_is_short() {
|
||||||
|
let viewport = Size {
|
||||||
|
width: px(800.),
|
||||||
|
height: px(600.),
|
||||||
|
};
|
||||||
|
let caret_top = px(480.);
|
||||||
|
let anchor = Point::new(px(100.), px(500.));
|
||||||
|
let size = Size {
|
||||||
|
width: px(300.),
|
||||||
|
height: px(200.),
|
||||||
|
};
|
||||||
|
|
||||||
|
let placement = popup_origin(anchor, caret_top, size, viewport);
|
||||||
|
assert_eq!(placement.origin.y, caret_top - px(200.) - px(POPUP_GAP));
|
||||||
|
assert_eq!(placement.origin.x, px(100.));
|
||||||
|
assert!(placement.above);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn popup_shifts_left_when_it_does_not_fit_on_the_right() {
|
||||||
|
let viewport = Size {
|
||||||
|
width: px(800.),
|
||||||
|
height: px(600.),
|
||||||
|
};
|
||||||
|
let anchor = Point::new(px(700.), px(100.));
|
||||||
|
let size = Size {
|
||||||
|
width: px(300.),
|
||||||
|
height: px(200.),
|
||||||
|
};
|
||||||
|
|
||||||
|
let placement = popup_origin(anchor, px(80.), size, viewport);
|
||||||
|
assert_eq!(placement.origin.x, px(800.) - px(POPUP_MARGIN) - px(300.));
|
||||||
|
assert_eq!(placement.origin.y, px(100.));
|
||||||
|
assert!(!placement.above);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_icons_exist_in_the_bundled_asset_catalog() {
|
||||||
|
use gpui_kit::AssetSource;
|
||||||
|
use gpui_kit::assets::AllAssets;
|
||||||
|
|
||||||
|
// `Assets` — компактный набор компонента, и иконок автодополнения в
|
||||||
|
// нём нет. Приложение подключает `AllAssets`; тест следит за тем,
|
||||||
|
// чтобы каждая иконка списка действительно там была.
|
||||||
|
let kinds = [
|
||||||
|
None,
|
||||||
|
Some(CompletionItemKind::FUNCTION),
|
||||||
|
Some(CompletionItemKind::METHOD),
|
||||||
|
Some(CompletionItemKind::STRUCT),
|
||||||
|
Some(CompletionItemKind::CLASS),
|
||||||
|
Some(CompletionItemKind::INTERFACE),
|
||||||
|
Some(CompletionItemKind::ENUM),
|
||||||
|
Some(CompletionItemKind::CONSTRUCTOR),
|
||||||
|
Some(CompletionItemKind::VARIABLE),
|
||||||
|
Some(CompletionItemKind::FIELD),
|
||||||
|
Some(CompletionItemKind::PROPERTY),
|
||||||
|
Some(CompletionItemKind::CONSTANT),
|
||||||
|
Some(CompletionItemKind::ENUM_MEMBER),
|
||||||
|
Some(CompletionItemKind::KEYWORD),
|
||||||
|
Some(CompletionItemKind::MODULE),
|
||||||
|
Some(CompletionItemKind::FILE),
|
||||||
|
Some(CompletionItemKind::FOLDER),
|
||||||
|
Some(CompletionItemKind::SNIPPET),
|
||||||
|
Some(CompletionItemKind::EVENT),
|
||||||
|
Some(CompletionItemKind::TYPE_PARAMETER),
|
||||||
|
Some(CompletionItemKind::OPERATOR),
|
||||||
|
Some(CompletionItemKind::TEXT),
|
||||||
|
];
|
||||||
|
|
||||||
|
for kind in kinds {
|
||||||
|
let path = completion_icon(kind).path();
|
||||||
|
assert!(
|
||||||
|
AllAssets
|
||||||
|
.load(&path)
|
||||||
|
.expect("ошибка чтения ассетов")
|
||||||
|
.is_some(),
|
||||||
|
"в бандле нет иконки {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_edit_uses_the_lsp_range() {
|
||||||
|
let text = "let value";
|
||||||
|
let mut item = item("value");
|
||||||
|
item.text_edit = Some(CompletionTextEdit::Edit(lsp_types::TextEdit {
|
||||||
|
range: lsp_types::Range {
|
||||||
|
start: lsp_types::Position::new(0, 4),
|
||||||
|
end: lsp_types::Position::new(0, 9),
|
||||||
|
},
|
||||||
|
new_text: "answer".to_owned(),
|
||||||
|
}));
|
||||||
|
let (range, replacement) = completion_edit(&item, text, 4, 9);
|
||||||
|
assert_eq!(range, 4..9);
|
||||||
|
assert_eq!(replacement, "answer");
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
-3
@@ -1,5 +1,9 @@
|
|||||||
//! Константы оформления окна и сценариев.
|
//! Константы оформления окна и сценариев.
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// Ширина окна при запуске.
|
/// Ширина окна при запуске.
|
||||||
pub const WINDOW_WIDTH: f32 = 900.0;
|
pub const WINDOW_WIDTH: f32 = 900.0;
|
||||||
|
|
||||||
@@ -33,11 +37,183 @@ pub const MIN_MAIN_AREA_WIDTH: f32 = 240.0;
|
|||||||
/// Высота нижней панели при открытии.
|
/// Высота нижней панели при открытии.
|
||||||
pub const BOTTOM_PANEL_HEIGHT: f32 = 160.0;
|
pub const BOTTOM_PANEL_HEIGHT: f32 = 160.0;
|
||||||
|
|
||||||
/// Каталог, в котором приложение ищет Lua-сценарии.
|
/// Каталог, в котором приложение ищет Lua-сценарии по умолчанию.
|
||||||
///
|
///
|
||||||
/// По умолчанию это текущий рабочий каталог процесса — именно там приложение
|
/// Это текущий рабочий каталог процесса — именно там приложение ожидает
|
||||||
/// ожидает `init.lua`.
|
/// `init.lua`, если не передан аргумент `--config`. Конкретный каталог всегда
|
||||||
|
/// возвращает [`script_dir`].
|
||||||
pub const SCRIPT_DIR: &str = ".";
|
pub const SCRIPT_DIR: &str = ".";
|
||||||
|
|
||||||
|
/// Каталог сценариев, заданный через `--config`.
|
||||||
|
static SCRIPT_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Файл, который нужно открыть при старте (первый позиционный аргумент).
|
||||||
|
static STARTUP_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Разобранные аргументы командной строки.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct CommandLine {
|
||||||
|
/// Каталог, из которого брать `init.lua`.
|
||||||
|
pub config_dir: Option<PathBuf>,
|
||||||
|
/// Файл, открываемый при старте как `vim file.go`.
|
||||||
|
pub file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Каталог, из которого загружаются `init.lua`, `include` и `require`.
|
||||||
|
///
|
||||||
|
/// Возвращает значение `--config`, если оно было задано, иначе текущий
|
||||||
|
/// рабочий каталог.
|
||||||
|
pub fn script_dir() -> &'static Path {
|
||||||
|
SCRIPT_DIR_OVERRIDE
|
||||||
|
.get()
|
||||||
|
.map_or_else(|| Path::new(SCRIPT_DIR), PathBuf::as_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Файл, который надо открыть при старте редактора.
|
||||||
|
pub fn startup_file() -> Option<&'static Path> {
|
||||||
|
STARTUP_FILE.get().map(PathBuf::as_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запоминает каталог сценариев из аргумента `--config`.
|
||||||
|
///
|
||||||
|
/// Путь канонизируется, поэтому относительный `--config ./scripts` работает
|
||||||
|
/// относительно каталога запуска, а `include`/`require` видят абсолютные пути.
|
||||||
|
pub fn set_script_dir(path: PathBuf) -> Result<(), String> {
|
||||||
|
let canonical = std::fs::canonicalize(&path).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"не удалось открыть каталог конфигурации {}: {err}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !canonical.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"--config: {} не является каталогом",
|
||||||
|
canonical.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
SCRIPT_DIR_OVERRIDE
|
||||||
|
.set(canonical)
|
||||||
|
.map_err(|_| "каталог конфигурации уже задан".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запоминает файл, открываемый при старте.
|
||||||
|
pub fn set_startup_file(path: PathBuf) -> Result<(), String> {
|
||||||
|
STARTUP_FILE
|
||||||
|
.set(path)
|
||||||
|
.map_err(|_| "файл для открытия уже задан".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает аргументы командной строки.
|
||||||
|
///
|
||||||
|
/// Поддерживаются:
|
||||||
|
/// * `файл` — первый позиционный аргумент, как в `vim file.go`;
|
||||||
|
/// * `--config <каталог>` и `--config=<каталог>`;
|
||||||
|
/// * `--` — всё после него считается позиционным (для имён, начинающихся с `-`).
|
||||||
|
///
|
||||||
|
/// Неизвестные флаги игнорируются, чтобы запуск не ломался от лишних
|
||||||
|
/// аргументов окружения/обёрток.
|
||||||
|
pub fn parse_command_line<I>(args: I) -> Result<CommandLine, String>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = OsString>,
|
||||||
|
{
|
||||||
|
let mut command_line = CommandLine::default();
|
||||||
|
let mut args = args.into_iter();
|
||||||
|
let mut positional_only = false;
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
let text = arg.to_string_lossy();
|
||||||
|
|
||||||
|
if positional_only {
|
||||||
|
if command_line.file.is_none() {
|
||||||
|
command_line.file = Some(PathBuf::from(arg));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if text == "--" {
|
||||||
|
positional_only = true;
|
||||||
|
} else if text == "--config" {
|
||||||
|
let value = args
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "--config требует путь к каталогу".to_owned())?;
|
||||||
|
command_line.config_dir = Some(PathBuf::from(value));
|
||||||
|
} else if let Some(value) = text.strip_prefix("--config=") {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err("--config требует путь к каталогу".to_owned());
|
||||||
|
}
|
||||||
|
command_line.config_dir = Some(PathBuf::from(value));
|
||||||
|
} else if !text.starts_with('-') && command_line.file.is_none() {
|
||||||
|
command_line.file = Some(PathBuf::from(arg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(command_line)
|
||||||
|
}
|
||||||
|
|
||||||
/// Имя стартового сценария, который выполняется при запуске приложения.
|
/// Имя стартового сценария, который выполняется при запуске приложения.
|
||||||
pub const STARTUP_SCRIPT: &str = "init.lua";
|
pub const STARTUP_SCRIPT: &str = "init.lua";
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn args(values: &[&str]) -> Vec<OsString> {
|
||||||
|
values.iter().map(OsString::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_dir_is_read_from_both_argument_forms() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command_line(args(&["--config", "/tmp/project"]))
|
||||||
|
.unwrap()
|
||||||
|
.config_dir,
|
||||||
|
Some(PathBuf::from("/tmp/project"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_command_line(args(&["--config=/tmp/project"]))
|
||||||
|
.unwrap()
|
||||||
|
.config_dir,
|
||||||
|
Some(PathBuf::from("/tmp/project"))
|
||||||
|
);
|
||||||
|
assert!(parse_command_line(args(&[])).unwrap().config_dir.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_dir_requires_a_value() {
|
||||||
|
assert!(parse_command_line(args(&["--config"])).is_err());
|
||||||
|
assert!(parse_command_line(args(&["--config="])).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_arguments_do_not_hide_the_config_dir() {
|
||||||
|
let parsed = parse_command_line(args(&["--verbose", "--config", "cfg"])).unwrap();
|
||||||
|
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_positional_argument_is_the_file_to_open() {
|
||||||
|
let parsed = parse_command_line(args(&["main.go"])).unwrap();
|
||||||
|
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
|
||||||
|
|
||||||
|
let parsed = parse_command_line(args(&["--config", "cfg", "main.go"])).unwrap();
|
||||||
|
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
|
||||||
|
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
|
||||||
|
|
||||||
|
let parsed = parse_command_line(args(&["main.go", "--config=cfg"])).unwrap();
|
||||||
|
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
|
||||||
|
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_dash_allows_file_names_starting_with_dash() {
|
||||||
|
let parsed = parse_command_line(args(&["--", "-weird.go"])).unwrap();
|
||||||
|
assert_eq!(parsed.file, Some(PathBuf::from("-weird.go")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_first_positional_argument_is_opened() {
|
||||||
|
let parsed = parse_command_line(args(&["first.go", "second.go"])).unwrap();
|
||||||
|
assert_eq!(parsed.file, Some(PathBuf::from("first.go")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+108
@@ -75,6 +75,67 @@ impl Language {
|
|||||||
Self::Python => "python",
|
Self::Python => "python",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Идентификатор языка для LSP (`languageId` в `textDocument/didOpen`).
|
||||||
|
///
|
||||||
|
/// Отличается от [`Self::name`] тем же, чем `highlight_name`: LSP ждёт
|
||||||
|
/// `javascript`, а не `js`.
|
||||||
|
pub fn lsp_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Rust => "rust",
|
||||||
|
Self::Go => "go",
|
||||||
|
Self::JavaScript => "javascript",
|
||||||
|
Self::Json => "json",
|
||||||
|
Self::Python => "python",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Определяет язык подсветки по имени файла.
|
||||||
|
///
|
||||||
|
/// Используется при открытии файла с диска (`app:open(path)`), чтобы
|
||||||
|
/// подсветка включалась автоматически там, где для неё есть грамматика.
|
||||||
|
pub fn from_path(path: &str) -> Option<Self> {
|
||||||
|
let extension = path.rsplit_once('.')?.1.to_ascii_lowercase();
|
||||||
|
match extension.as_str() {
|
||||||
|
"rs" => Some(Self::Rust),
|
||||||
|
"go" => Some(Self::Go),
|
||||||
|
"js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript),
|
||||||
|
"json" => Some(Self::Json),
|
||||||
|
"py" => Some(Self::Python),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Язык LSP по расширению имени файла.
|
||||||
|
///
|
||||||
|
/// Нужен для документов, у которых подсветка ещё не включена: LSP-сервер
|
||||||
|
/// выбирается и получает `languageId` независимо от tree-sitter.
|
||||||
|
pub fn language_id_from_path(name: &str) -> &'static str {
|
||||||
|
let extension = name
|
||||||
|
.rsplit_once('.')
|
||||||
|
.map(|(_, extension)| extension.to_ascii_lowercase());
|
||||||
|
|
||||||
|
match extension.as_deref() {
|
||||||
|
Some("rs") => "rust",
|
||||||
|
Some("go") => "go",
|
||||||
|
Some("js" | "mjs" | "cjs") => "javascript",
|
||||||
|
Some("jsx") => "javascriptreact",
|
||||||
|
Some("ts") => "typescript",
|
||||||
|
Some("tsx") => "typescriptreact",
|
||||||
|
Some("json") => "json",
|
||||||
|
Some("py") => "python",
|
||||||
|
Some("md" | "markdown") => "markdown",
|
||||||
|
Some("toml") => "toml",
|
||||||
|
Some("yaml" | "yml") => "yaml",
|
||||||
|
Some("lua") => "lua",
|
||||||
|
Some("c" | "h") => "c",
|
||||||
|
Some("cc" | "cpp" | "cxx" | "hpp" | "hh") => "cpp",
|
||||||
|
Some("sh" | "bash") => "shellscript",
|
||||||
|
Some("html" | "htm") => "html",
|
||||||
|
Some("css") => "css",
|
||||||
|
_ => "plaintext",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Открытый в редакторе документ.
|
/// Открытый в редакторе документ.
|
||||||
@@ -115,6 +176,17 @@ impl Document {
|
|||||||
self.content != self.saved_content
|
self.content != self.saved_content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Идентификатор языка для LSP.
|
||||||
|
///
|
||||||
|
/// Если подсветка уже включена, берётся её язык. Иначе язык угадывается
|
||||||
|
/// по расширению имени вкладки — так LSP работает и без `set_language`.
|
||||||
|
pub fn lsp_language_id(&self) -> String {
|
||||||
|
if let Some(language) = self.language {
|
||||||
|
return language.lsp_name().to_owned();
|
||||||
|
}
|
||||||
|
language_id_from_path(&self.name).to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
/// Помечает текущее содержимое сохранённым.
|
/// Помечает текущее содержимое сохранённым.
|
||||||
pub fn mark_saved(&mut self) {
|
pub fn mark_saved(&mut self) {
|
||||||
self.saved_content = self.content.clone();
|
self.saved_content = self.content.clone();
|
||||||
@@ -207,6 +279,42 @@ impl Workspace {
|
|||||||
self.bump();
|
self.bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Открывает файл с диска как вкладку и делает её активной.
|
||||||
|
///
|
||||||
|
/// Если вкладка с таким именем уже открыта и не содержит несохранённых
|
||||||
|
/// изменений, её содержимое обновляется из файла — повторный `app:open`
|
||||||
|
/// не создаёт дубликат. Грязная вкладка не трогается: открывается новая.
|
||||||
|
pub fn open_file(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
content: impl Into<String>,
|
||||||
|
language: Option<Language>,
|
||||||
|
) -> usize {
|
||||||
|
let name = name.into();
|
||||||
|
let content = content.into();
|
||||||
|
|
||||||
|
if let Some(index) = self
|
||||||
|
.documents
|
||||||
|
.iter()
|
||||||
|
.position(|document| document.name == name && !document.is_dirty())
|
||||||
|
{
|
||||||
|
let document = &mut self.documents[index];
|
||||||
|
document.content = content;
|
||||||
|
document.mark_saved();
|
||||||
|
document.language = language;
|
||||||
|
self.active = index;
|
||||||
|
self.bump();
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut document = Document::new(name, content);
|
||||||
|
document.language = language;
|
||||||
|
self.documents.push(document);
|
||||||
|
self.active = self.documents.len() - 1;
|
||||||
|
self.bump();
|
||||||
|
self.active
|
||||||
|
}
|
||||||
|
|
||||||
/// Закрывает вкладку и выбирает соседнюю.
|
/// Закрывает вкладку и выбирает соседнюю.
|
||||||
///
|
///
|
||||||
/// Если закрывается последняя вкладка, вместо неё открывается пустой документ.
|
/// Если закрывается последняя вкладка, вместо неё открывается пустой документ.
|
||||||
|
|||||||
+1227
File diff suppressed because it is too large
Load Diff
+27
-1
@@ -7,9 +7,11 @@
|
|||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod completion;
|
||||||
mod constants;
|
mod constants;
|
||||||
mod editor;
|
mod editor;
|
||||||
mod keymap;
|
mod keymap;
|
||||||
|
mod lsp;
|
||||||
mod panel;
|
mod panel;
|
||||||
mod script;
|
mod script;
|
||||||
|
|
||||||
@@ -23,7 +25,15 @@ use app::MyApp;
|
|||||||
use constants::{MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH};
|
use constants::{MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
application().with_assets(assets::Assets).run(|cx| {
|
if let Err(err) = apply_command_line() {
|
||||||
|
eprintln!("{err}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `AllAssets` содержит полный каталог Lucide: автодополнение использует
|
||||||
|
// иконки (`square-function`, `variable`, `braces` и т. п.), которых нет
|
||||||
|
// в компактном наборе `Assets`.
|
||||||
|
application().with_assets(assets::AllAssets).run(|cx| {
|
||||||
// Инициализация темы, шрифтов и компонентов обязательна до создания окон.
|
// Инициализация темы, шрифтов и компонентов обязательна до создания окон.
|
||||||
init(cx);
|
init(cx);
|
||||||
|
|
||||||
@@ -34,6 +44,22 @@ fn main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Применяет аргументы запуска до создания окна.
|
||||||
|
///
|
||||||
|
/// `--config` задаёт каталог `init.lua`, а первый позиционный аргумент — файл,
|
||||||
|
/// который [`MyApp`] откроет через `app:open(path)` после выполнения сценария.
|
||||||
|
/// Рабочий каталог процесса и пути сохранения файлов не меняются.
|
||||||
|
fn apply_command_line() -> Result<(), String> {
|
||||||
|
let command_line = constants::parse_command_line(std::env::args_os().skip(1))?;
|
||||||
|
if let Some(directory) = command_line.config_dir {
|
||||||
|
constants::set_script_dir(directory)?;
|
||||||
|
}
|
||||||
|
if let Some(file) = command_line.file {
|
||||||
|
constants::set_startup_file(file)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Параметры окна в стиле исходного приложения: размер 900×600, минимум
|
/// Параметры окна в стиле исходного приложения: размер 900×600, минимум
|
||||||
/// 420×280 и собственный заголовок вместо системного.
|
/// 420×280 и собственный заголовок вместо системного.
|
||||||
fn window_options() -> WindowOptions {
|
fn window_options() -> WindowOptions {
|
||||||
|
|||||||
+35
-13
@@ -1,20 +1,24 @@
|
|||||||
//! Выполнение Lua-сценариев приложения.
|
//! Выполнение Lua-сценариев приложения.
|
||||||
//!
|
//!
|
||||||
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`SCRIPT_DIR`]
|
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`]
|
||||||
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
|
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
|
||||||
//!
|
//!
|
||||||
//! * объект `app` — управление окном и редактором (методы [`AppApi`], вызываются
|
//! * объект `app` — управление окном и редактором (методы [`AppApi`], вызываются
|
||||||
//! через двоеточие: `app:next_tab()`);
|
//! через двоеточие: `app:next_tab()`);
|
||||||
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`SCRIPT_DIR`]
|
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`script_dir`]
|
||||||
//! (или по абсолютному пути) в общем глобальном окружении;
|
//! (или по абсолютному пути) в общем глобальном окружении;
|
||||||
//! * стандартный `require`, который также ищет модули в [`SCRIPT_DIR`].
|
//! * стандартный `require`, который также ищет модули в [`script_dir`].
|
||||||
|
//!
|
||||||
|
//! Каталог по умолчанию — текущий рабочий каталог. Его можно переопределить
|
||||||
|
//! аргументом `--config <каталог>` при запуске приложения (см.
|
||||||
|
//! [`parse_command_line`](crate::constants::parse_command_line)).
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use mlua::{Lua, Table};
|
use mlua::{Lua, Table};
|
||||||
|
|
||||||
use crate::api::{AppApi, TabInfo};
|
use crate::api::{AppApi, TabInfo};
|
||||||
use crate::constants::{SCRIPT_DIR, STARTUP_SCRIPT};
|
use crate::constants::{STARTUP_SCRIPT, script_dir};
|
||||||
|
|
||||||
/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs" }`.
|
/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs" }`.
|
||||||
impl mlua::IntoLua for TabInfo {
|
impl mlua::IntoLua for TabInfo {
|
||||||
@@ -29,24 +33,40 @@ impl mlua::IntoLua for TabInfo {
|
|||||||
/// Движок Lua и состояние его глобального окружения.
|
/// Движок Lua и состояние его глобального окружения.
|
||||||
pub struct ScriptEngine {
|
pub struct ScriptEngine {
|
||||||
lua: Lua,
|
lua: Lua,
|
||||||
|
/// Каталог, относительно которого ищутся `init.lua`, `include` и `require`.
|
||||||
|
script_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScriptEngine {
|
impl ScriptEngine {
|
||||||
/// Создаёт движок и публикует в нём API приложения.
|
/// Создаёт движок и публикует в нём API приложения.
|
||||||
|
///
|
||||||
|
/// Каталог сценариев берётся из [`script_dir`] (то есть из `--config`
|
||||||
|
/// или текущего рабочего каталога).
|
||||||
pub fn new(api: AppApi) -> mlua::Result<Self> {
|
pub fn new(api: AppApi) -> mlua::Result<Self> {
|
||||||
let engine = Self { lua: Lua::new() };
|
Self::new_in(api, script_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создаёт движок с явно заданным каталогом сценариев.
|
||||||
|
///
|
||||||
|
/// Полезно тестам и будущим режимам, где каталог конфигурации приходит
|
||||||
|
/// не из глобального `--config`.
|
||||||
|
pub fn new_in(api: AppApi, directory: impl AsRef<Path>) -> mlua::Result<Self> {
|
||||||
|
let engine = Self {
|
||||||
|
lua: Lua::new(),
|
||||||
|
script_dir: directory.as_ref().to_path_buf(),
|
||||||
|
};
|
||||||
engine.expose_app_api(api)?;
|
engine.expose_app_api(api)?;
|
||||||
engine.configure_require()?;
|
engine.configure_require()?;
|
||||||
Ok(engine)
|
Ok(engine)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Выполняет стартовый сценарий из [`SCRIPT_DIR`].
|
/// Выполняет стартовый сценарий из каталога движка.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Возвращает ошибку, если файла нет, его не удалось прочитать или в нём
|
/// Возвращает ошибку, если файла нет, его не удалось прочитать или в нём
|
||||||
/// есть синтаксическая/исполняемая ошибка.
|
/// есть синтаксическая/исполняемая ошибка.
|
||||||
pub fn run_startup(&self) -> mlua::Result<()> {
|
pub fn run_startup(&self) -> mlua::Result<()> {
|
||||||
self.run_file(&script_path(STARTUP_SCRIPT))
|
self.run_file(&script_path(&self.script_dir, STARTUP_SCRIPT))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Выполняет Lua-файл в глобальном окружении движка.
|
/// Выполняет Lua-файл в глобальном окружении движка.
|
||||||
@@ -73,7 +93,7 @@ impl ScriptEngine {
|
|||||||
|
|
||||||
if let Ok(package) = globals.get::<Table>("package") {
|
if let Ok(package) = globals.get::<Table>("package") {
|
||||||
let current: String = package.get("path").unwrap_or_default();
|
let current: String = package.get("path").unwrap_or_default();
|
||||||
let dir = SCRIPT_DIR;
|
let dir = self.script_dir.display().to_string().replace('\\', "/");
|
||||||
let extra = format!("{dir}/?.lua;{dir}/?/init.lua");
|
let extra = format!("{dir}/?.lua;{dir}/?/init.lua");
|
||||||
let path = if current.is_empty() {
|
let path = if current.is_empty() {
|
||||||
extra
|
extra
|
||||||
@@ -85,10 +105,12 @@ impl ScriptEngine {
|
|||||||
|
|
||||||
// include выполняет файл сразу, как dofile, но путь по умолчанию
|
// include выполняет файл сразу, как dofile, но путь по умолчанию
|
||||||
// отсчитывается от каталога сценариев, а не от cwd.
|
// отсчитывается от каталога сценариев, а не от cwd.
|
||||||
|
let directory = self.script_dir.clone();
|
||||||
globals.set(
|
globals.set(
|
||||||
"include",
|
"include",
|
||||||
self.lua
|
self.lua.create_function(move |lua, path: String| {
|
||||||
.create_function(|lua, path: String| run_file(lua, &script_path(&path)))?,
|
run_file(lua, &script_path(&directory, &path))
|
||||||
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -96,13 +118,13 @@ impl ScriptEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Путь к файлу сценария: абсолютные пути используются как есть, остальные
|
/// Путь к файлу сценария: абсолютные пути используются как есть, остальные
|
||||||
/// отсчитываются от [`SCRIPT_DIR`].
|
/// отсчитываются от каталога сценариев.
|
||||||
fn script_path(path: &str) -> PathBuf {
|
fn script_path(directory: &Path, path: &str) -> PathBuf {
|
||||||
let path = Path::new(path);
|
let path = Path::new(path);
|
||||||
if path.is_absolute() {
|
if path.is_absolute() {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
} else {
|
} else {
|
||||||
Path::new(SCRIPT_DIR).join(path)
|
directory.join(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+294
-1
@@ -7,7 +7,7 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui_kit::{Keystroke, Modifiers, TestAppContext};
|
use gpui_kit::{Keystroke, Modifiers, TestAppContext, px};
|
||||||
|
|
||||||
use crate::api::{AppApi, WindowCommand, drain_window_commands};
|
use crate::api::{AppApi, WindowCommand, drain_window_commands};
|
||||||
use crate::app::MyApp;
|
use crate::app::MyApp;
|
||||||
@@ -21,6 +21,35 @@ fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>
|
|||||||
(api, keymap)
|
(api, keymap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_lsp_api_lists_no_servers_and_validates_config() {
|
||||||
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
let (api, _keymap) = install_api(workspace);
|
||||||
|
|
||||||
|
let dir = temp_script_dir("lsp-api");
|
||||||
|
let script = dir.join("lsp.lua");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
r#"
|
||||||
|
assert(#app:lsp_servers() == 0)
|
||||||
|
assert(app:stop_lsp("missing") == false)
|
||||||
|
assert(app:restart_lsp("missing") == false)
|
||||||
|
|
||||||
|
-- Без cmd сервер не запускается, а ошибка объясняет, чего не хватает.
|
||||||
|
local ok, err = pcall(function() app:run_lsp({ name = "broken" }) end)
|
||||||
|
assert(not ok)
|
||||||
|
assert(tostring(err):find("cmd", 1, true), tostring(err))
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||||||
|
engine.run_file(&script).expect("скрипт LSP API");
|
||||||
|
|
||||||
|
drop(engine);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_keeps_at_least_one_tab() {
|
fn workspace_keeps_at_least_one_tab() {
|
||||||
let mut state = Workspace::default();
|
let mut state = Workspace::default();
|
||||||
@@ -327,6 +356,111 @@ fn lua_script_can_include_files_and_control_the_app() {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_opens_file_from_disk_and_detects_language() {
|
||||||
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
let (api, _keymap) = install_api(workspace.clone());
|
||||||
|
|
||||||
|
let dir = temp_script_dir("open-file");
|
||||||
|
let path = dir.join("main.go");
|
||||||
|
std::fs::write(&path, "package main\n").unwrap();
|
||||||
|
|
||||||
|
let index = api.open(&path).expect("файл открыт");
|
||||||
|
{
|
||||||
|
let state = workspace.borrow();
|
||||||
|
assert_eq!(index, state.active_index());
|
||||||
|
assert_eq!(state.active().name, path.to_string_lossy());
|
||||||
|
assert_eq!(state.active().content, "package main\n");
|
||||||
|
assert_eq!(state.active().language, Some(Language::Go));
|
||||||
|
assert!(!state.active().is_dirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторное открытие чистого файла переиспользует вкладку.
|
||||||
|
let tabs = workspace.borrow().documents().len();
|
||||||
|
assert_eq!(api.open(&path).unwrap(), index);
|
||||||
|
assert_eq!(workspace.borrow().documents().len(), tabs);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_open_missing_file_creates_an_empty_buffer() {
|
||||||
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
let (api, _keymap) = install_api(workspace.clone());
|
||||||
|
|
||||||
|
let dir = temp_script_dir("open-missing");
|
||||||
|
let path = dir.join("new.go");
|
||||||
|
|
||||||
|
let index = api.open(&path).expect("новый буфер");
|
||||||
|
let state = workspace.borrow();
|
||||||
|
assert_eq!(index, state.active_index());
|
||||||
|
assert_eq!(state.active().name, path.to_string_lossy());
|
||||||
|
assert!(state.active().content.is_empty());
|
||||||
|
assert_eq!(state.active().language, Some(Language::Go));
|
||||||
|
assert!(!state.active().is_dirty());
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_app_open_opens_a_file_and_returns_its_tab() {
|
||||||
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
let (api, _keymap) = install_api(workspace.clone());
|
||||||
|
|
||||||
|
let dir = temp_script_dir("lua-open");
|
||||||
|
let path = dir.join("main.go");
|
||||||
|
std::fs::write(&path, "package main\n").unwrap();
|
||||||
|
let script = dir.join("open.lua");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
format!(
|
||||||
|
"local index = app:open({:?})\nassert(index == app:active_tab())\n",
|
||||||
|
path.to_string_lossy()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
|
||||||
|
engine.run_file(&script).expect("app:open из Lua");
|
||||||
|
|
||||||
|
let state = workspace.borrow();
|
||||||
|
assert_eq!(state.active().name, path.to_string_lossy());
|
||||||
|
assert_eq!(state.active().content, "package main\n");
|
||||||
|
assert_eq!(state.active().language, Some(Language::Go));
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
drop(engine);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_engine_loads_init_from_a_custom_config_dir() {
|
||||||
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
let (api, _keymap) = install_api(workspace.clone());
|
||||||
|
|
||||||
|
let dir = temp_script_dir("config-dir");
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("helper.lua"),
|
||||||
|
"function config_value() app:set_active_text(\"custom config\") end\n",
|
||||||
|
)
|
||||||
|
.expect("helper.lua");
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("init.lua"),
|
||||||
|
"include(\"helper.lua\")\nconfig_value()\n",
|
||||||
|
)
|
||||||
|
.expect("init.lua");
|
||||||
|
|
||||||
|
// --config меняет именно каталог поиска init.lua/include, а не cwd.
|
||||||
|
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
|
||||||
|
engine.run_startup().expect("init.lua из своего каталога");
|
||||||
|
|
||||||
|
assert_eq!(workspace.borrow().active().content, "custom config");
|
||||||
|
|
||||||
|
drop(engine);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lua_key_bindings_match_press_release_and_modifiers() {
|
fn lua_key_bindings_match_press_release_and_modifiers() {
|
||||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
@@ -597,3 +731,162 @@ fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
|
|||||||
);
|
);
|
||||||
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
|
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn arrows_move_completion_selection_without_moving_cursor(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let (app, visual) = cx.add_window_view(MyApp::new);
|
||||||
|
|
||||||
|
let before = visual.update(|window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
this.editor_handle()
|
||||||
|
.update(cx, |state, cx| state.focus(window, cx));
|
||||||
|
this.open_test_completion(cx);
|
||||||
|
this.editor_handle().read(cx).cursor()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
visual.simulate_keystrokes("down");
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
let (cursor, selected, open) = visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
(
|
||||||
|
this.editor_handle().read(cx).cursor(),
|
||||||
|
this.completion_selected(),
|
||||||
|
this.completion_is_open(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(selected, 1, "стрелка вниз должна выбрать второй элемент");
|
||||||
|
assert_eq!(cursor, before, "пока список открыт, курсор не двигается");
|
||||||
|
assert!(open, "список должен остаться открытым");
|
||||||
|
|
||||||
|
// На последнем элементе выделение стоит на месте, но клавиша всё равно
|
||||||
|
// принадлежит списку и не должна двигать курсор.
|
||||||
|
visual.simulate_keystrokes("down");
|
||||||
|
visual.run_until_parked();
|
||||||
|
visual.simulate_keystrokes("up");
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
let (cursor, selected, open) = visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
(
|
||||||
|
this.editor_handle().read(cx).cursor(),
|
||||||
|
this.completion_selected(),
|
||||||
|
this.completion_is_open(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert_eq!(selected, 0, "стрелка вверх должна вернуть первый элемент");
|
||||||
|
assert_eq!(cursor, before);
|
||||||
|
assert!(open);
|
||||||
|
|
||||||
|
visual.simulate_keystrokes("escape");
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
let (cursor, open) = visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
(
|
||||||
|
this.editor_handle().read(cx).cursor(),
|
||||||
|
this.completion_is_open(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert!(!open, "Esc должен закрыть список");
|
||||||
|
assert_eq!(cursor, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn long_completion_list_scrolls_instead_of_shrinking_rows(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let (app, visual) = cx.add_window_view(MyApp::new);
|
||||||
|
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
this.editor_handle()
|
||||||
|
.update(cx, |state, cx| state.focus(window, cx));
|
||||||
|
this.open_test_completion_with_count(60, cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
let scroll =
|
||||||
|
visual.update(|_window, cx| app.update(cx, |this, _cx| this.completion_scroll_handle()));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
scroll.max_offset().y > px(0.),
|
||||||
|
"длинный список должен прокручиваться, а не сжимать строки"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn completion_popup_above_the_caret_renders(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let (app, visual) = cx.add_window_view(MyApp::new);
|
||||||
|
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
let text = (0..200)
|
||||||
|
.map(|index| format!("let value_{index} = {index};\n"))
|
||||||
|
.collect::<String>();
|
||||||
|
this.editor_handle().update(cx, |state, cx| {
|
||||||
|
state.set_value(text.clone(), window, cx);
|
||||||
|
let end = text.len();
|
||||||
|
state.set_selected_range(end..end, cx);
|
||||||
|
state.focus(window, cx);
|
||||||
|
});
|
||||||
|
this.open_test_completion(cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Первый кадр обновляет прокрутку редактора, второй — положение поповера,
|
||||||
|
// который должен оказаться над каретой.
|
||||||
|
visual.run_until_parked();
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
visual.update(|_window, cx| app.update(cx, |this, _cx| this.completion_is_open())),
|
||||||
|
"поповер над каретой должен отрисоваться"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn horizontal_arrows_close_completion_and_move_cursor(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let (app, visual) = cx.add_window_view(MyApp::new);
|
||||||
|
|
||||||
|
let before = visual.update(|window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
this.editor_handle()
|
||||||
|
.update(cx, |state, cx| state.focus(window, cx));
|
||||||
|
this.open_test_completion(cx);
|
||||||
|
this.editor_handle().read(cx).cursor()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
visual.simulate_keystrokes("right");
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
let (after, open) = visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
(
|
||||||
|
this.editor_handle().read(cx).cursor(),
|
||||||
|
this.completion_is_open(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
after > before,
|
||||||
|
"горизонтальная стрелка двигает курсор как обычно"
|
||||||
|
);
|
||||||
|
assert!(!open, "горизонтальная стрелка закрывает список");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user