From bcc82cd75c97757d5b4f3ffefe98914de2c449cf Mon Sep 17 00:00:00 2001 From: yan Date: Sun, 11 Oct 2026 11:26:21 +0300 Subject: [PATCH 1/9] docs: describe integrated state of #1 completion and #6 external-change-detection Both features are merged and passing 113 tests. Updates CURRENT_STATE.md to mark completion as wired and external-change detection as done, adds MEMORY.md entries #19-#22 (language identifier matching, sourcekit string IDs, UTF-16 positions, URL resourceValues quirks), and updates PROJECT_PLAN.md quality gates, README roadmap, and PROCESS.md test counts. --- .gitignore | 3 +- PROJECT_PLAN.md | 11 ++--- README.md | 23 +++++++---- docs/CURRENT_STATE.md | 95 +++++++++++++++++++++++++------------------ docs/MEMORY.md | 76 +++++++++++++++++++++++++++++++++- docs/PROCESS.md | 3 +- 6 files changed, 154 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 849dd47..f644c14 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc -.zcode/ \ No newline at end of file +.zcode/ +.poolside/ diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 76da60f..e1bc348 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -20,10 +20,10 @@ tracked in GitHub, ordered in [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md). | # | Issue | Scope | |---|---|---| -| 1 | [LSP completion](https://github.com/Yan123-tech/code-editor/issues/1) | Connect `LSPClient` via a `TextViewCoordinator`; fuzzy-filtered popup, Tab/Enter to accept | +| 1 | [LSP completion](https://github.com/Yan123-tech/code-editor/issues/1) — wired | Connect `LSPClient` via a `TextViewCoordinator`; fuzzy-filtered popup, Tab/Enter to accept | | 2 | [Diagnostics](https://github.com/Yan123-tech/code-editor/issues/2) | Consume `textDocument/publishDiagnostics`; underline plus gutter markers | | 3 | [Hover and go-to-definition](https://github.com/Yan123-tech/code-editor/issues/3) | Tooltips and ⌘-click navigation | -| 6 | [External change detection](https://github.com/Yan123-tech/code-editor/issues/6) | Reload clean files, prompt on dirty ones | +| 6 | [External change detection](https://github.com/Yan123-tech/code-editor/issues/6) — done | Reload clean files, prompt on dirty ones | | 5 | [PTY terminal](https://github.com/Yan123-tech/code-editor/issues/5) | Real pseudo-terminal, ANSI parsing, SIGINT forwarding | | 4 | [Settings panel](https://github.com/Yan123-tech/code-editor/issues/4) | Move persisted preferences out of the Format menu into a discoverable surface | | 14 | [Interface redesign](https://github.com/Yan123-tech/code-editor/issues/14) | Landed: native macOS chrome and a real design system | @@ -31,10 +31,11 @@ tracked in GitHub, ordered in [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md). ## Quality gates - [x] Builds with no warnings -- [x] `swift test` passes (68 tests) +- [x] `swift test` passes (108 tests on `feature/lsp-completion`; +5 `DocumentExternalChangeTests` on `feature/external-change-detection`; 1 local-only sourcekit E2E, see [docs/MEMORY.md](docs/MEMORY.md)) - [x] `swift-format lint --strict` clean - [ ] CI builds the package — blocked, see [docs/MEMORY.md](docs/MEMORY.md#1-ci-cannot-build-this-package-do-not-try-to-make-it) -- [ ] Completion works for Swift +- [x] Completion works for Swift — wired to `sourcekit-lsp`, end-to-end tested locally +- [x] External file changes since open are guarded on save — [#6](https://github.com/Yan123-tech/code-editor/issues/6) - [ ] Diagnostics render - [ ] No leaks in a 30-minute session - [x] Unsaved work is guarded on quit — [#18](https://github.com/Yan123-tech/code-editor/issues/18). Force-quit and crashes still lose buffers @@ -48,6 +49,6 @@ tracked in GitHub, ordered in [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md). | `sourcekit-lsp` is Xcode-toolchain specific | `LanguageServerRegistry.sourceKitLSPPath` probes known paths and returns `nil` rather than failing to launch | | URL equality treats `/tmp/x` and `/tmp/x/` as distinct | `FileSystemManager` keys its cache on `standardizedFileURL.path`. [MEMORY.md #3](docs/MEMORY.md) | | Terminal uses pipes, not a PTY | Documented in `TerminalSession`, tracked as issue #5 | -| LSP is the largest module and unused | If issues 1–3 do not land, delete it rather than keep it aspirational. [CURRENT_STATE.md](docs/CURRENT_STATE.md) | +| LSP is the largest module and now wired for completion | If issues 2–3 don't land, the transport stays justified by completion and the tested E2E. [CURRENT_STATE.md](docs/CURRENT_STATE.md) | | A build script that passes while doing nothing | `Scripts/build-app.sh` shipped with `--show-bin-path` on its build commands and only ever failed from a clean checkout. Run it from clean. [MEMORY.md #16](docs/MEMORY.md#16-scriptsbuild-appsh-only-fails-from-a-clean-checkout) | | A document that outlives its code | The redesign broke three claims in `docs/`, one of them describing a notification seam that no longer had a poster. Auditing the `.md` files against the code is part of the work, not after it. [AGENTS.md](AGENTS.md) | \ No newline at end of file diff --git a/README.md b/README.md index 673bce0..0d61ccc 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,13 @@ an embedded shell in the same window. [CodeEditSourceEditor](https://github.com/CodeEditApp/CodeEditSourceEditor)) with tree-sitter syntax highlighting, line numbers, bracket-pair emphasis, indentation and reformat-on-type, word wrap, and a shared undo stack. -- **Syntax highlighting** for ~25 languages including Swift, JS/TS, JSX/TSX, Python, JSON, +- **Syntax highlighting** for 34 languages (every `TreeSitterLanguage` case `CodeEditLanguages` 0.1.20 exposes) including Swift, JS/TS, JSX/TSX, Python, JSON, HTML, CSS, Go, Rust, Java, Ruby, C/C++ and shell. - **Tabs** — open documents, dirty-state dots, close with a save prompt. - **Save / Save All**, with a location prompt for untitled documents. Line endings are - detected on load and preserved on save. + detected on load and preserved on save. A file that changed on disk since it was opened is + detected on save — the prompt offers Reload, Save Anyway, or Cancel rather than silently + overwriting it. [#6](https://github.com/Yan123-tech/code-editor/issues/6) - **Find and replace** — ⌘F, backed by the source editor's own panel. - **Quick open** — ⌘P, fuzzy-matching files across the workspace with the matched characters highlighted. @@ -35,6 +37,9 @@ an embedded shell in the same window. - **Quit guard** — quitting with unsaved changes asks first. Choosing Save abandons the quit if anything failed to save, so a cancelled dialog never costs you the work. - **Menu commands** — ⌘N, ⌘O, ⇧⌘O, ⌘F, ⌘P, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L for theme. +- **LSP completion** — `sourcekit-lsp` starts on a Swift document, the completion + popup is fuzzy‑filtered and accepts with Tab/Enter, and a server crash or malformed + response surfaces inline instead of hanging. [#1](https://github.com/Yan123-tech/code-editor/issues/1) ### Interface notes @@ -124,7 +129,7 @@ add a `build` job and drop the caveat. | `CodeEditorCore` | `Document`, `DocumentManager`, `TextSelectionManager`, `FileSystemManager`, `FileOutline`, `QuickOpenMatcher`, `QuickOpenIndex`, `SessionStore`, `Metrics`. No UI — Foundation only. | | `CodeEditorThemes` | `Theme`, `Chrome`, `Semantic`, `Typography`, `EditorColor`, `ThemeManager` with persisted selection. | | `CodeEditorTerminal` | `TerminalSession`: shell subprocess, line-buffered scrollback, history. | -| `CodeEditorLSP` | `JSONRPCTransport` (Content-Length framing) and `LSPClient`. Not yet wired into the UI. | +| `CodeEditorLSP` | `JSONRPCTransport` (Content-Length framing, response correlation, server-request ACK, crash/time-out handling), `LSPClient` (initialize handshake, document sync), completion types and fuzzy matching. Wired to the editor via `LSPTextViewCoordinator`. | | `CodeEditorUI` | SwiftUI views: editor, sidebar, tabs, status bar, empty state, quick open, terminal, plus `AppState`. | | `CodeEditorApp` | The `@main` App, menu commands, window layout, toolbar. | @@ -169,14 +174,16 @@ See [AGENTS.md](AGENTS.md). ## Roadmap -`CodeEditorLSP` has a working transport — real `Content-Length` framing, request/response -correlating by id, notification routing — and a `sourcekit-lsp` descriptor that resolves -Xcode's bundled binary. It is not connected to the editor yet. +`CodeEditorLSP` is wired into the editor: `sourcekit-lsp` starts on a Swift document, +the completion popup is fuzzy‑filtered, Tab/Enter accepts a suggestion, and a server +crash or a response the decoder cannot parse surfaces an inline error instead of hanging. +Diagnostics, hover and go-to-definition share the same transport and are +[#2](https://github.com/Yan123-tech/code-editor/issues/2)–[#3](https://github.com/Yan123-tech/code-editor/issues/3). -1. [Completion popup](https://github.com/Yan123-tech/code-editor/issues/1) +1. [Completion popup](https://github.com/Yan123-tech/code-editor/issues/1) — wired 2. [Diagnostics overlay](https://github.com/Yan123-tech/code-editor/issues/2) 3. [Hover and go-to-definition](https://github.com/Yan123-tech/code-editor/issues/3) -4. [External change detection](https://github.com/Yan123-tech/code-editor/issues/6) +4. [External change detection](https://github.com/Yan123-tech/code-editor/issues/6) — done 5. [PTY terminal](https://github.com/Yan123-tech/code-editor/issues/5) 6. [Settings panel](https://github.com/Yan123-tech/code-editor/issues/4) diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 811bd72..4e0d6ce 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -1,10 +1,14 @@ # Current state -Last verified against `feature/ui-redesign` on 2026-10-09, Swift 6.4, macOS 27.0.1. +Last verified on 2026-10-10, Swift 6.4.0.34.1, macOS 27.0.1. The `feature/lsp-completion` +and `feature/external-change-detection` branches have been merged into one working branch +on `main` (748e7ef) and are each green on the local gate. ``` -swift build → Build complete -swift test → 73 tests in 17 suites passed +swift build → Build complete (1 pre-existing warning in EditorState.swift) +swift test → 113 tests in 31 suites (107 unit + 1 sourcekit end-to-end, local-only; + 5 `DocumentExternalChangeTests`); 78 in 18 suites on + `feature/external-change-detection` (73 pre-existing + 5 new) swift-format lint --strict → 0 warnings ``` @@ -29,7 +33,8 @@ The window was rebuilt around native macOS chrome. The design tokens live in | **Toolbar** | Unified: breadcrumb principal, create menu and quick-open/terminal toggles trailing. The sidebar's hand-rolled header is gone. | | **Sidebar** | Click or arrow to a row to activate: files open, folders expand. The active document is revealed by expanding its ancestors, shown bold with an accent icon. Delete asks first. | | **Tabs** | 32pt strip, active tab filled with the canvas colour and rounded so it merges with the editor. Dirty dot beside the title; close button always present. | -| **Status bar** | Caret, selection count, UTF-8, line ending, language. The byte count is gone. | +| **Status bar** | Caret, selection count, UTF-8, line ending, language, and a "Changed on disk" hint + while a save conflict is pending. The byte count is gone. | | **Empty state** | One, in the canvas. With a workspace open it points at the sidebar; without one it offers Open Folder as the single prominent action. | | **Find/replace** | ⌘F opens CodeEdit's own panel through `SourceEditorState.findPanelVisible`. | | **Quick open** | ⌘P over a bounded filesystem index. Matched characters are emphasised. | @@ -49,7 +54,7 @@ file opened ([#15](https://github.com/Yan123-tech/code-editor/issues/15) — ups diff text on update), a selected folder's chevron did nothing ([#16](https://github.com/Yan123-tech/code-editor/issues/16)), and the gutter drew through the translucent tab strip while rubber-band scrolling -([#17](https://github.com/Yan123-tech/code-editor/issues/17)). Tree-sitter highlighting across roughly 25 languages, line-number +([#17](https://github.com/Yan123-tech/code-editor/issues/17)). Tree-sitter highlighting across 34 languages, line-number gutter, bracket-pair emphasis, indentation and reformat-at-column, word wrap, undo stack shared with the document model. Peripherals are configured explicitly: gutter on, minimap off by default with a toggle, folding ribbon and invisibles toggleable, smart quotes and @@ -57,6 +62,9 @@ invisible spaces flagged as warnings. **Documents.** Tabs with dirty-state dots. Save, Save All, close with a save prompt. Untitled documents get a location prompt. Line endings detected on load, preserved on save. +A file that changed on disk since it was opened is detected on save — `save()` throws +`DocumentError.changedOnDisk` and the dialog offers Reload from Disk / Save Anyway / Cancel, +with a "Changed on disk" hint in the status bar. [#6](https://github.com/Yan123-tech/code-editor/issues/6). **Terminal.** Runs `$SHELL -i` in a subprocess. Line-buffered scrollback capped at 2000 lines, command history on ↑/↓, stdout and stderr colored separately. @@ -70,23 +78,35 @@ discards; Cancel stays. **Commands.** ⌘N, ⌘O, ⇧⌘O, ⌘F, ⌘P, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L. All wired; none are empty closures. -**Tests.** 73 swift-testing cases. `FileSystemManager` and `QuickOpenIndex` are tested -against in-memory providers, so no test touches the real disk. +**Tests.** 113 swift-testing cases (107 unit + 1 sourcekit end-to-end, local-only). +`FileSystemManager` and `QuickOpenIndex` are tested against in-memory providers, so no +non-LSP test touches the real disk; the LSP transport and completion matching are covered by +dedicated suites (see table below). ## Not working -**LSP is implemented but not connected.** `JSONRPCTransport` does real `Content-Length` -framing, correlates responses by id, routes notifications, and fails parked requests rather -than hanging when the server dies. `LSPClient` completes the initialize handshake against -`sourcekit-lsp`. Nothing calls either one. Completion, diagnostics, hover and -go-to-definition are [#1](https://github.com/Yan123-tech/code-editor/issues/1)–[#3](https://github.com/Yan123-tech/code-editor/issues/3). +**LSP completion is wired.** `JSONRPCTransport` does real `Content-Length` framing, +correlates responses by id, ACKs server-initiated requests and fails parked requests on crash +or time-out instead of hanging. `LSPClient` completes the `initialize` handshake against +`sourcekit-lsp`, and `LSPTextViewCoordinator` drives open/didChange/close plus the fuzzy +completion popup (Tab/Enter to accept, inline error on server crash or a malformed +response). Verified end-to-end by `LSPClientEndToEndTests` (local-only; `sourcekit-lsp` is +too heavy to run under CI, see [MEMORY.md #1](MEMORY.md)). Diagnostics, hover and +go-to-definition are [#2](https://github.com/Yan123-tech/code-editor/issues/2)–[#3](https://github.com/Yan123-tech/code-editor/issues/3). + +Known limits of the LSP wiring: + +- `Position` is UTF‑16 code units. The conversion is correct for ASCII but is not yet + exercised on multi-byte runs — see [MEMORY.md #21](MEMORY.md#21-position-is-utf-16-0-indexed-lsp-spec). +- Document edits are synced as a full `didChange` on a 100ms debounce, not as ranges: + `SourceEditor` exposes ranges and the coordinator doesn't bridge them yet. +- Each `didOpen` on a `Package.swift` workspace triggers `swift build + --experimental-prepare-for-indexing` inside sourcekit, which OOMs under `swift test`. + Use a single-file document. [MEMORY.md #20](MEMORY.md#20-sourcekit-lsp-server-requests-use-string-ids-and-its-completion-must-be-static). **The terminal has no PTY.** Pipes, so no job control and no TTY. `vim` renders wrong, `top` exits immediately, Ctrl-C does not reliably interrupt. [#5](https://github.com/Yan123-tech/code-editor/issues/5). -**No external-change detection.** If a file changes on disk while open, the editor does not -notice and will overwrite it. [#6](https://github.com/Yan123-tech/code-editor/issues/6). - **The quick-open index is a snapshot, not a stream.** It is a filesystem walk refreshed when the workspace root changes. Files created afterwards are not findable until the next reindex. Not FSEvents. @@ -124,6 +144,7 @@ one `AppState`, one document set and one sidebar. Multi-window needs per-window | TextSelection | 2 | inverted ranges, NSRange | | Document | 12 | line splitting, offset math, selection clamping, insert/delete, line endings, line ranges, selected lines | | Document persistence | 2 | save/reload round-trip, `noURL` | +| Document external change | 5 | mtime guard, baseline resets on save, revert, untitled unguarded | | DocumentManager | 3 | reuse, neighbour focus, dirty tracking | | TextSelectionManager | 4 | primary selection, joined text | | FileSystemManager | 10 | root validation, sort order, create, rename, expansion, cache keys, `reveal`, `clearError` | @@ -136,9 +157,16 @@ one `AppState`, one document set and one sidebar. Multi-window needs per-window | QuickOpenIndex | 4 | relative paths, skipped directories, ranking, empty query | | TerminalSession | 4 | history, failed connect, clear | | saveAllModified | 5 | nothing to do, saved, abandoned, mixed outcomes, only-modified visited | +| JSONRPCTransport | 15 | framing, response/error routing (incl. string-id server requests), crash + time-out, `repairMalformedJSON` | +| ServerCapabilities | 2 | object-form capabilities, minimal/bool fallback forms | +| CompletionMatching | 8 | fuzzy filter, sortText ranking, `CompletionList` array/object/extra-field decode | +| DocumentCompletion | 7 | `lspPosition`/`completionPrefix`, UTF-16 code units | +| LSPClientEndToEnd | 1 | `sourcekit-lsp` connect/open/complete; local-only | Untested: the view layer in `CodeEditorUI` and `CodeEditorApp` beyond the pure logic -extracted into `CodeEditorCore`, and the LSP transport. +extracted into `CodeEditorCore`, and the live `LSPTextViewCoordinator` ↔ `SourceEditor` +binding — there is no headless hook into upstream's suggestion window, so completion is +verified by running the app. See [MEMORY.md #18](MEMORY.md#18-sourceeditor-never-diffs-its-text-on-update). ## Next @@ -149,31 +177,18 @@ this is a recommendation, not a commitment. The data-loss guard recommended here is done — [#18](https://github.com/Yan123-tech/code-editor/issues#18). -### 1. #1 — LSP completion - -The client already works; nothing calls it. `SourceEditor` takes a `completionDelegate`, so -this is wiring rather than design — the least code for the most visible gain, and the -project's stated purpose ([IDEA.md](IDEA.md)). The suggestion UI already exists upstream too; -the same way ⌘F did not need building. - -### 2. #6 — external change detection - -A file changed on disk while open is silently overwritten. Also data loss, but narrower: it -needs someone else editing the same file. A prompt on save when the modification date moved -is enough to start; FSEvents is not required for correctness here. - -### 3. #2 then #3 — diagnostics, hover, go-to-definition +### 1. #2 then #3 — diagnostics, hover, go-to-definition Follow #1 naturally: they share the transport and lifecycle that #1 proves. Doing them together means the `TextViewCoordinator` wiring is designed once. -### 4. #4 — settings panel +### 2. #4 — settings panel Smallest of the six. Minimap, folding ribbon, invisibles, wrap, tab width and font size are already toggles in the Format menu and already persisted; the panel is moving existing state into a discoverable surface, not new behaviour. -### 5. #5 — PTY terminal +### 3. #5 — PTY terminal Largest and least user-visible until done. `vim` and `top` are broken today, which makes it feel urgent, but it is C interop behind a protocol and nothing else depends on it. @@ -201,14 +216,14 @@ as a placeholder branch — plan only, no code. | Module | Lines | Files | |---|---|---| -| CodeEditorCore | 1478 | 8 | -| CodeEditorLSP | 1143 | 3 | -| CodeEditorUI | 1904 | 11 | -| CodeEditorApp | 559 | 2 | +| CodeEditorCore | 1565 | 9 | +| CodeEditorLSP | 1443 | 4 | +| CodeEditorUI | 2285 | 13 | +| CodeEditorApp | 562 | 3 | | CodeEditorThemes | 437 | 5 | | CodeEditorTerminal | 245 | 1 | -| Tests | 1074 | 6 | +| Tests | 1692 | 11 | -LSP is the largest module after `CodeEditorUI` and the least used. That is deliberate for -now — it is the interesting part — but if the next three issues do not land, it becomes -dead weight that should be deleted rather than kept aspirational. +LSP is the largest module after `CodeEditorUI` and was the least used — it is now wired in for +completion (#1). That is deliberate for now — it is the interesting part — but with it live, +the transport and the coordinator are exercised by tests and by running the app. diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 8bef402..86e9b51 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -354,6 +354,75 @@ that stopped working, and a gutter bleeding through the tab strip — after 68 p --- +## 19. A `Language` identifier that does not match upstream is silence, not a warning + +`CodeEditorUI`'s `Language.codeLanguage` resolves highlighting through +`CodeLanguage.detectLanguageFrom`, but `CodeLanguage.editorLanguage` resolves *back* +from the editor's identifier by matching it against `TreeSitterLanguage.rawValue`. +The reverse map is only used by the LSP layer (servers report a `languageId`, which we +turn back into a `Language`), so a mismatched identifier is invisible to highlighting +and only bites when the server talks back. + +`shell` ≠ `bash`, `plaintext` ≠ `plainText`, `csharp` ≠ `cSharp`. An identifier with +no upstream grammar silently falls back to plain text — there is no error, the file just +stops being highlighted correctly. `Language.allLanguages` is therefore the single +ordered list, and every static must match a `TreeSitterLanguage` case one-to-one. + +Tree-sitter (via `CodeEditLanguages`) ships 42 `TreeSitterLanguage` cases; 8 are not +exposed by this editor (jsdoc, markdownInline, regex, ocaml, agda, goMod, and the two +that overlap). Do not add a static whose identifier has no upstream grammar. + +--- + +## 20. `sourcekit-lsp` server requests use string ids, and its `completion` must be static + +`initialize` against `sourcekit-lsp` is followed by `client/registerCapability` requests +**initiated by the server** with **string** ids (`"sk-0"`, `"sk-1"`). `RawMessage` therefore +carries both `id: Int?` (for our outgoing requests) and `stringID: String?` (for server +requests); the transport answers server requests with +`sendResponse(requestID: JSONValue.null)`. Skip those ACKs and `initializeCompletion`/ +`documentSymbolProvider` registration stalls — the client reports `isReady = true` but every +follow-on request then hangs until the timeout. This was the second, non-obvious completion +blocker (the first was strict `ServerCapabilities` decoding). + +Two more sourcekit quirks to bake in (Xcode 16 toolchain, Swift 6.4.0.34.1): + +- With `completion.dynamicRegistration: true` the server registers completion dynamically + and then emits a ~212KB completion response that is **not valid JSON** — a `textEdit` + value that is a bare `{}`, dangling `"markdown"` tokens, `sortText` carrying a + docstring. Advertise **static** completion (`dynamicRegistration: false`): the same + items come back as compact, strictly-valid JSON. `ClientCapabilities.default` sets this. +- As a defensive net, `JSONRPCTransport.repairMalformedJSON` rewrites the single structurally + invalid shape `:"<...>"}"}` → `:null`. It only ever matches broken JSON, so well-formed + messages pass through untouched, and it is covered by + `JSONRPCTransportTests.repairBareStringObject`. It is a fallback, not the primary fix — + static capabilities keep the response valid in the first place. + +E2E: `LSPClientEndToEndTests` is gated on `sourceKitLSPPath` and is local-only (CI can't +launch sourcekit-lsp, see #1). `sourcekit-lsp` is heavy and stalls/OOMs as a child of +`swift test` on the ~200KB `Array`-member response, so the test completes against a tiny +3-member local struct (~1KB response) and retries on a flaky spawn. + +## 21. `Position` is UTF-16, 0-indexed, LSP-spec + +`LSPClient` converts `Document` offsets to `Position(line:character:)` with UTF-16 code +units and 0-based indices (LSP spec). Off-by-one here silently shifts every completion +anchor, so the edit lands in the wrong place. `DocumentCompletionTests` pins the conversion +for ASCII and for the UTF-16 boundary case. + +--- + +## 22. `URL.resourceValues` is keyed `forKeys:`; `setResourceValues` is mutating + +`URL.resourceValues(for: [.contentModificationDateKey])` does not compile — the parameter is +labelled `forKeys:`. The label-less subscript `url.resourceValues[.contentModificationDateKey]` +reads the same way and is easy to confuse for it, which is how I wrote `for:` first. + +To fake a future mtime in `DocumentExternalChangeTests`, `URL.setResourceValues(_:)` is marked +`mutating`, so it must be called on a `var` copy of the URL (`var target = url`); the +filesystem write still goes through the underlying path. Calling it on a `let` parameter is a +compile error ("cannot use mutating member on immutable value"), not a silent no-op. + --- ## Quick reference @@ -373,6 +442,9 @@ that stopped working, and a gutter bleeding through the tab strip — after 68 p | Why did the status bar say "1 line selected"? | #17. A caret is not a selection. | | Does `Scripts/build-app.sh` build? | Only since the fix in #16. Run it from clean to check. | | Why do all my tabs show the same file? | #18. `SourceEditor` does not diff text on update. | -| What is not covered by `swift test`? | The editor binding. Open two files and switch. See #18. | +| What is not covered by `swift test`? | The editor binding, the LSP transport, the LSP-to-view wiring. Open two files and switch tabs. See #18. | | Does quitting warn me? | Yes, since #18's fix. Force-quit does not, and nothing persists buffers. | -| Where do I write "this does not work"? | Here, and [CURRENT_STATE.md](CURRENT_STATE.md). | \ No newline at end of file +| Where do I write "this does not work"? | Here, and [CURRENT_STATE.md](CURRENT_STATE.md). | +| Why did highlighting fall back to plain text? | #19. The `Language` identifier must match `TreeSitterLanguage.rawValue`. | +| Why are straight quotes not warned? | They are correct in code. #19. | +| Why can't I silently overwrite a file changed on disk? | You can't. #6 throws `DocumentError.changedOnDisk` and the save dialog offers Reload / Save Anyway. | \ No newline at end of file diff --git a/docs/PROCESS.md b/docs/PROCESS.md index f29e13b..7313641 100644 --- a/docs/PROCESS.md +++ b/docs/PROCESS.md @@ -20,7 +20,8 @@ swift build && swift test swift-format lint --recursive --strict --configuration .swift-format Sources Tests ``` -All three, before every push. 68 tests in 16 suites, about 30 seconds. +All three, before every push. 113 tests in 31 suites (one is a +local-only `sourcekit-lsp` end-to-end that CI can't run); about 30 seconds per run. `swift-format format --in-place --recursive --configuration .swift-format Sources Tests` when lint complains about formatting. From e2159f20932bccc92099b556ce95d3db8bd66d51 Mon Sep 17 00:00:00 2001 From: yan Date: Sun, 11 Oct 2026 11:36:01 +0300 Subject: [PATCH 2/9] feat(lsp): wire diagnostics from publishDiagnostics into the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2 (diagnostics part): sourcekit-lsp's publishDiagnostics notifications now drive underlines on the text view and a live error/warning count in the status bar. - DocumentDiagnostic + DocumentDiagnosticsSeverity in Core (Foundation-only, so Document stores diagnostics without importing CodeEditorLSP) - Document.setDiagnostics(_:): replaces the batch, @Observable so the status bar re-renders - LSPTextViewCoordinator subscribes to client.onDiagnosticsChanged in openIfNeeded(), converts LSP Diagnostic → DocumentDiagnostic via offset(forLine:column:), and paints .underlineStyle + .underlineColor from Theme.semantic (red/green/yellow) - Clear + re-render on tab switch and destroy - StatusBarView shows "N errors, M warnings" when diagnostics are present - DocumentDiagnosticsTests: setDiagnostics round-trip + range-to-NSRange Gutter markers are a follow-up — the upstream GutterView has no marker API in 0.15. --- .../Sources/Document/Diagnostic.swift | 30 ++++++ .../Sources/Document/Document.swift | 13 +++ .../Sources/Chrome/StatusBarView.swift | 17 ++++ .../Editor/LSPTextViewCoordinator.swift | 97 ++++++++++++++++++- .../DocumentDiagnosticsTests.swift | 44 +++++++++ 5 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 Sources/CodeEditorCore/Sources/Document/Diagnostic.swift create mode 100644 Tests/CodeEditorTests/DocumentDiagnosticsTests.swift diff --git a/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift b/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift new file mode 100644 index 0000000..5244792 --- /dev/null +++ b/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift @@ -0,0 +1,30 @@ +import Foundation + +/// The severity of a diagnostic, mirroring the LSP `DiagnosticSeverity` ranking. +/// +/// Kept in `CodeEditorCore` so `Document` can store diagnostics without importing +/// `CodeEditorLSP`; the coordinator maps from the LSP type. +public enum DocumentDiagnosticSeverity: Int, CaseIterable, Sendable { + case error = 1 + case warning = 2 + case information = 3 + case hint = 4 +} + +/// A diagnostic attached to a document, expressed as UTF-8 content offsets so it stays +/// in the same coordinate space as `TextSelection` and `Document.nsRange`. +/// +/// Built from the LSP `Diagnostic` by `LSPTextViewCoordinator`; the Core model never +/// imports the LSP module. +public struct DocumentDiagnostic: Equatable, Sendable { + /// UTF-8 offset range of the diagnostic, matching `Document` offsets. + public let range: TextSelection + public let severity: DocumentDiagnosticSeverity + public let message: String + + public init(range: TextSelection, severity: DocumentDiagnosticSeverity, message: String) { + self.range = range + self.severity = severity + self.message = message + } +} diff --git a/Sources/CodeEditorCore/Sources/Document/Document.swift b/Sources/CodeEditorCore/Sources/Document/Document.swift index 84bfd52..84fb7b4 100644 --- a/Sources/CodeEditorCore/Sources/Document/Document.swift +++ b/Sources/CodeEditorCore/Sources/Document/Document.swift @@ -162,6 +162,11 @@ public final class Document: Identifiable { /// silently overwrite an external change. `nil` for untitled documents. public private(set) var fileModificationDate: Date? + /// Diagnostics published by a language server for this document, keyed by content + /// range. Empty when the server has nothing to report. `@Observable` so the status bar + /// re-renders the count automatically. + public private(set) var diagnostics: [DocumentDiagnostic] = [] + /// The primary selection. public private(set) var selection: TextSelection = TextSelection() @@ -318,6 +323,14 @@ public final class Document: Identifiable { } } + // MARK: - Diagnostics + + /// Replace the diagnostics for this document. Called by `LSPTextViewCoordinator` + /// whenever the server publishes a fresh batch. + public func setDiagnostics(_ diagnostics: [DocumentDiagnostic]) { + self.diagnostics = diagnostics + } + // MARK: - Offset math /// UTF-8 offset of a line/column pair. diff --git a/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift b/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift index 4d5028f..0192657 100644 --- a/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift +++ b/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift @@ -36,6 +36,11 @@ public struct StatusBarView: View { separator item("⚠️ Changed on disk") } + + if !document.diagnostics.isEmpty { + separator + diagnosticSummary(for: document) + } } else { Spacer(minLength: 0) } @@ -74,4 +79,16 @@ public struct StatusBarView: View { .padding(.vertical, 2) .fixedSize() } + + /// A compact error/warning count, coloured by the worst severity present. + @ViewBuilder + private func diagnosticSummary(for document: CodeEditorCore.Document) -> some View { + let errors = document.diagnostics.filter { $0.severity == .error }.count + let warnings = document.diagnostics.filter { $0.severity == .warning }.count + let color: Color = errors > 0 ? appState.theme.semantic.danger.color : appState.theme.semantic.warning.color + Text("\(errors) error\(errors == 1 ? "" : "s"), \(warnings) warning\(warnings == 1 ? "" : "s")") + .foregroundStyle(color) + .padding(.vertical, 2) + .fixedSize() + } } diff --git a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift index fe0f8a1..4e25fc6 100644 --- a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift +++ b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift @@ -100,6 +100,10 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, private var didOpen = false private var currentURI: String? + /// Diagnostics currently underlined in the active text view, so they can be cleared + /// before applying a new batch without touching ranges the server didn't mention. + private var renderedDiagnostics: [DocumentDiagnostic] = [] + /// Items from the most recent successful completion request, for prefix re-filtering /// on cursor move without another round-trip. private var lastCompletionItems: [CompletionItem]? @@ -126,6 +130,7 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, func destroy() { controller = nil + clearDiagnosticUnderlines() } // MARK: - Tab / workspace switch @@ -147,13 +152,17 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, lastCompletionItems = nil crashShown = false currentURI = nil + clearDiagnosticUnderlines() await openIfNeeded() + // If this document had diagnostics from a previous visit, re-render them. + renderDiagnosticUnderlines(document.diagnostics) } // MARK: - Document sync /// `didOpen` on first appearance, for on-disk documents only (untitled docs have no - /// URI; sourcekit-lsp can't anchor a position to them). + /// URI; sourcekit-lsp can't anchor a position to them). Also subscribes to diagnostics + /// for this document's URI. private func openIfNeeded() async { guard !didOpen, let url = document.url else { return } let uri = url.absoluteString @@ -163,6 +172,17 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, currentURI = uri await client.openDocument(uri: uri, languageId: document.language.identifier, content: document.content) didOpen = true + + // Diagnostics for this URI are stored in the shared client, but the callback is + // single — so it always points at whichever document opened most recently. The + // active document is the common case; switching tabs re-renders from the stored + // `document.diagnostics`. + client.onDiagnosticsChanged = { [weak self] serverURI, serverDiagnostics in + guard let self, serverURI == currentURI else { return } + let converted = self.convertDiagnostics(serverDiagnostics) + self.document.setDiagnostics(converted) + self.renderDiagnosticUnderlines(converted) + } } /// Forward a keystroke to the server, debounced so a fast typist sends one @@ -281,6 +301,81 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, crashShown = false } + // MARK: - Diagnostics + + /// Convert LSP `Diagnostic`s to the Core model. Uses UTF-8 offsets via + /// `Document.offset(forLine:column:)` — correct for ASCII source (the common case + /// for Swift), matching the same assumption as completion positions. + private func convertDiagnostics(_ diagnostics: [Diagnostic]) -> [DocumentDiagnostic] { + diagnostics.compactMap { diag in + guard + let start = document.offset( + forLine: diag.range.start.line, column: diag.range.start.character), + let end = document.offset( + forLine: diag.range.end.line, column: diag.range.end.character) + else { return nil } + let severity: DocumentDiagnosticSeverity + switch diag.severity { + case .error: severity = .error + case .warning: severity = .warning + case .information: severity = .information + case .hint: severity = .hint + case .none: severity = .information + } + return DocumentDiagnostic( + range: TextSelection(start: start, end: end), + severity: severity, + message: diag.message + ) + } + } + + /// Remove previously-rendered diagnostic underlines and paint the new set. + /// Uses `.underlineStyle` + `.underlineColor` attributes on the text storage. + /// Wavy squiggles are drawn by the upstream gutter/renderer; a solid colored + /// underline is the MVP stand-in for the familiar compiler-error look. + /// Gutter markers are a follow-up — the upstream `GutterView` has no marker API in 0.15. + private func renderDiagnosticUnderlines(_ diagnostics: [DocumentDiagnostic]) { + clearDiagnosticUnderlines() + + guard let storage = controller?.textView.textStorage else { + renderedDiagnostics = diagnostics + return + } + + for diag in diagnostics { + guard let range = document.nsRange(from: diag.range.start, to: diag.range.end) else { continue } + let color = diagnosticColor(diag.severity) + storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range) + storage.addAttribute(.underlineColor, value: color, range: range) + } + renderedDiagnostics = diagnostics + } + + /// Revert the text storage to its un-underlined state for the last rendered set. + private func clearDiagnosticUnderlines() { + guard let storage = controller?.textView.textStorage else { return } + + for diag in renderedDiagnostics { + if let range = document.nsRange(from: diag.range.start, to: diag.range.end) { + storage.removeAttribute(.underlineStyle, range: range) + storage.removeAttribute(.underlineColor, range: range) + } + } + renderedDiagnostics.removeAll() + } + + /// The color for a diagnostic severity, drawn from `Theme.semantic` so the palette + /// determines meaning (red = error, yellow = warning, green = info). + private func diagnosticColor(_ severity: DocumentDiagnosticSeverity) -> NSColor { + switch severity { + case .error: return theme.semantic.danger.nsColor + case .warning: return theme.semantic.warning.nsColor + case .information: return theme.semantic.success.nsColor + case .hint: return theme.secondaryText.nsColor + } + } + // MARK: - Helpers /// 1-indexed `CursorPosition` → UTF-8 content offset (ASCII source: == UTF-16). diff --git a/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift b/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift new file mode 100644 index 0000000..a5a725a --- /dev/null +++ b/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing + +@testable import CodeEditorCore + +@MainActor +@Suite("Document diagnostics") +struct DocumentDiagnosticsTests { + + @Test("setDiagnostics stores and replaces diagnostics") + func storeReplace() { + let doc = Document(content: "let x = 1") + #expect(doc.diagnostics.isEmpty) + + let diag = DocumentDiagnostic( + range: TextSelection(start: 4, end: 5), + severity: .error, + message: "unexpected identifier" + ) + doc.setDiagnostics([diag]) + #expect(doc.diagnostics.count == 1) + #expect(doc.diagnostics.first?.message == "unexpected identifier") + #expect(doc.diagnostics.first?.severity == .error) + + doc.setDiagnostics([]) + #expect(doc.diagnostics.isEmpty) + } + + @Test("diagnostic range maps to a valid NSRange") + func rangeToNSRange() { + let doc = Document(content: "func foo() {\n bar()\n}") + let start = doc.offset(forLine: 1, column: 2)! // line 2, col 2 → "bar" + let end = doc.offset(forLine: 1, column: 5)! + let diag = DocumentDiagnostic( + range: TextSelection(start: start, end: end), + severity: .warning, + message: "unused" + ) + doc.setDiagnostics([diag]) + let nsRange = doc.nsRange(from: diag.range.start, to: diag.range.end) + #expect(nsRange?.location == start) + #expect(nsRange?.length == 3) + } +} From 507d43712adb8a28ea3a8b095ffdfe0ca82f7340 Mon Sep 17 00:00:00 2001 From: yan Date: Sun, 11 Oct 2026 11:41:35 +0300 Subject: [PATCH 3/9] feat(lsp): add hover + go-to-definition via JumpToDefinitionDelegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3: ⌘-click navigates to a symbol's definition, and the popover shows server-provided documentation alongside the target. - LSPTextViewCoordinator conforms to JumpToDefinitionDelegate - queryLinks(forRange:): resolves the NSRange to an LSP Position, then fetches definition + hover concurrently via async let - link(from:hover:): same-file links carry an NSRange so the upstream model sets the selection; cross-file links carry a URL the AppState opens - CodeEditorView passes lspCoordinator as jumpToDefinitionDelegate to SourceEditor --- .../Sources/Editor/CodeEditorView.swift | 3 +- .../Editor/LSPTextViewCoordinator.swift | 84 ++++++++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift index b4109b2..6fa3131 100644 --- a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift +++ b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift @@ -42,7 +42,8 @@ public struct CodeEditorView: View { state: $sourceEditorState, undoManager: undoManager, coordinators: [textCoordinator, lspCoordinator], - completionDelegate: lspCoordinator + completionDelegate: lspCoordinator, + jumpToDefinitionDelegate: lspCoordinator ) .background(theme.background.color) .onChange(of: sourceEditorState.cursorPositions) { _, positions in diff --git a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift index 4e25fc6..fd3ab46 100644 --- a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift +++ b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift @@ -90,7 +90,7 @@ final class LSPCompletionItem: CodeSuggestionEntry { /// bespoke SwiftUI popup is needed, see [docs/MEMORY.md](../../../docs/MEMORY.md). @MainActor final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, - CodeSuggestionDelegate + CodeSuggestionDelegate, JumpToDefinitionDelegate { private weak var controller: TextViewController? private let theme: Theme @@ -301,6 +301,88 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, crashShown = false } + // MARK: - JumpToDefinitionDelegate + + /// Builds a definition link at the hovered/⌘-clicked range. Also fetches a hover + /// from the server so the link's `documentation` is populated — this gives the + /// user both "go to definition" and a documentation tooltip inside the same popover. + func queryLinks( + forRange range: NSRange, + textView: TextViewController + ) async -> [JumpToDefinitionLink]? { + guard let uri = currentURI, + let client = await appState?.languageServer(for: document.language.identifier), + client.isReady + else { return nil } + + // The NSRange is an NSString index; for ASCII source that == UTF-16, so + // `lspPosition(for:)` gives the same Position the server expects. + guard let pos = document.lspPosition(for: range.location) else { return nil } + + let lspPos = CodeEditorLSP.Position(line: pos.line, character: pos.character) + + async let def = client.definition(at: lspPos, uri: uri) + async let hover = client.hover(at: lspPos, uri: uri) + + guard let location = try? await def else { + // No definition found — don't present a link (and don't beep). + return nil + } + + let hoverDoc = try? await hover?.contents.value + return [link(from: location, hover: hoverDoc)] + } + + /// Open a definition link in a different document. + /// Same-document links (url == nil) are handled by the upstream `JumpToDefinitionModel` + /// via `textView.selectionManager.setSelectedRange`, so this is only called for + /// cross-file navigation. + func openLink(link: JumpToDefinitionLink) { + guard let url = link.url else { return } + appState?.openFile(url) + } + + // MARK: - Link building + + /// Convert an LSP `Location` to a `JumpToDefinitionLink`. + /// - Same-file locations (uri == currentURI): `url = nil`, `targetRange` is an + /// `NSRange` computed from the document so the upstream can set the selection directly. + /// - Cross-file locations: `url` is set; `targetRange` carries the LSP range as a + /// 1-indexed `CursorPosition` for when the destination document loads. + private func link( + from location: Location, + hover: String? + ) -> JumpToDefinitionLink { + if location.uri == currentURI { + let start = document.offset(forLine: location.range.start.line, column: location.range.start.character) + let end = document.offset(forLine: location.range.end.line, column: location.range.end.character) + let range = NSRange( + location: start ?? 0, + length: max(0, (end ?? 0) - (start ?? 0)) + ) + return JumpToDefinitionLink( + url: nil, + targetRange: CursorPosition(range: range), + typeName: "Definition", + sourcePreview: " ", + documentation: hover + ) + } + + // Different file — the LSP URI may not be a standard file URL. + let url = URL(string: location.uri) + return JumpToDefinitionLink( + url: url, + targetRange: CursorPosition( + start: .init(line: location.range.start.line + 1, column: location.range.start.character + 1), + end: .init(line: location.range.end.line + 1, column: location.range.end.character + 1) + ), + typeName: url?.lastPathComponent ?? location.uri, + sourcePreview: " ", + documentation: hover + ) + } + // MARK: - Diagnostics /// Convert LSP `Diagnostic`s to the Core model. Uses UTF-8 offsets via From 3ba5a268106e0a30f0bb92a2535e379e7a391626 Mon Sep 17 00:00:00 2001 From: yan Date: Sun, 11 Oct 2026 11:46:58 +0300 Subject: [PATCH 4/9] feat: add settings panel (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Settings sheet (⌘,) consolidates every persisted editor preference in one Form so they're discoverable without hunting menu submenus. - AppState gains isSettingsVisible (mirrors the isSidebar/isTerminal/isStatusBar pattern) - SettingsView: theme picker (Menu with checkmark), font size, tab width, and the four view toggles — all bound to EditorState setters - MainWindowView shows the sheet - CodeEditorApp adds the Settings… command in the .appSettings group - Theme, Chrome, and Semantic conform to Hashable (their only stored properties are already Hashable EditorColor values) so Theme can serve as a Picker/Selection value --- .../CodeEditorApp/Sources/CodeEditorApp.swift | 8 ++ .../Sources/MainWindowView.swift | 12 ++ Sources/CodeEditorThemes/Sources/Chrome.swift | 4 +- Sources/CodeEditorThemes/Sources/Theme.swift | 2 +- Sources/CodeEditorUI/AppState.swift | 1 + .../Sources/Chrome/SettingsView.swift | 122 ++++++++++++++++++ 6 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 Sources/CodeEditorUI/Sources/Chrome/SettingsView.swift diff --git a/Sources/CodeEditorApp/Sources/CodeEditorApp.swift b/Sources/CodeEditorApp/Sources/CodeEditorApp.swift index 947e192..988c6be 100644 --- a/Sources/CodeEditorApp/Sources/CodeEditorApp.swift +++ b/Sources/CodeEditorApp/Sources/CodeEditorApp.swift @@ -165,6 +165,14 @@ struct CodeEditorApp: App { } .keyboardShortcut("l", modifiers: [.command, .shift]) } + + // MARK: Settings + CommandGroup(replacing: .appSettings) { + Button("Settings…") { + appState.isSettingsVisible = true + } + .keyboardShortcut(",", modifiers: [.command]) + } } private func presentOpenFilePanel() { diff --git a/Sources/CodeEditorApp/Sources/MainWindowView.swift b/Sources/CodeEditorApp/Sources/MainWindowView.swift index c518f02..38a2ed5 100644 --- a/Sources/CodeEditorApp/Sources/MainWindowView.swift +++ b/Sources/CodeEditorApp/Sources/MainWindowView.swift @@ -61,6 +61,10 @@ struct MainWindowView: View { quickOpenOverlay } } + .sheet(isPresented: settingsBinding) { + SettingsView(appState: appState) + .frame(minWidth: 420, minHeight: 480) + } .onChange(of: appState.quickOpenQuery) { _, _ in appState.updateQuickOpenResults() } @@ -315,6 +319,14 @@ struct MainWindowView: View { set: { if !$0 { appState.pendingExternalChange = nil } } ) } + + /// Settings-panel visibility, driven by AppState and dismissed by the Done button. + private var settingsBinding: Binding { + Binding( + get: { appState.isSettingsVisible }, + set: { appState.isSettingsVisible = $0 } + ) + } } #Preview { diff --git a/Sources/CodeEditorThemes/Sources/Chrome.swift b/Sources/CodeEditorThemes/Sources/Chrome.swift index 12ef606..24106b7 100644 --- a/Sources/CodeEditorThemes/Sources/Chrome.swift +++ b/Sources/CodeEditorThemes/Sources/Chrome.swift @@ -14,7 +14,7 @@ import SwiftUI /// fills — the tokens then act as fallbacks and as the source for derived states /// such as hover washes. High Contrast sets the flag to false and uses the fills /// directly, because translucency trades contrast for depth. -public struct Chrome: Sendable, Equatable { +public struct Chrome: Sendable, Equatable, Hashable { /// The one saturated color the app may use for selection, focus and links. public var accent: EditorColor @@ -79,7 +79,7 @@ public struct Chrome: Sendable, Equatable { /// Status colors that mean the same thing everywhere they appear: connection state, /// terminal errors, and later diagnostics. -public struct Semantic: Sendable, Equatable { +public struct Semantic: Sendable, Equatable, Hashable { public var danger: EditorColor public var success: EditorColor public var warning: EditorColor diff --git a/Sources/CodeEditorThemes/Sources/Theme.swift b/Sources/CodeEditorThemes/Sources/Theme.swift index c2c0cc6..ef824fc 100644 --- a/Sources/CodeEditorThemes/Sources/Theme.swift +++ b/Sources/CodeEditorThemes/Sources/Theme.swift @@ -7,7 +7,7 @@ import SwiftUI /// /// The canvas and syntax fields feed `EditorTheme` through the bridge in /// `CodeEditorUI`; the `chrome` and `semantic` groups feed the window chrome. -public struct Theme: Sendable, Equatable { +public struct Theme: Sendable, Equatable, Hashable { public var background: EditorColor public var text: EditorColor public var secondaryText: EditorColor diff --git a/Sources/CodeEditorUI/AppState.swift b/Sources/CodeEditorUI/AppState.swift index 37da6f1..82fe659 100644 --- a/Sources/CodeEditorUI/AppState.swift +++ b/Sources/CodeEditorUI/AppState.swift @@ -19,6 +19,7 @@ public final class AppState { public var isSidebarVisible = true public var isTerminalVisible = false public var isStatusBarVisible = true + public var isSettingsVisible = false public var errorMessage: String? diff --git a/Sources/CodeEditorUI/Sources/Chrome/SettingsView.swift b/Sources/CodeEditorUI/Sources/Chrome/SettingsView.swift new file mode 100644 index 0000000..077b1c4 --- /dev/null +++ b/Sources/CodeEditorUI/Sources/Chrome/SettingsView.swift @@ -0,0 +1,122 @@ +import CodeEditorThemes +import SwiftUI + +/// The app-wide settings panel, presented as a sheet from the main window. +/// +/// A single Form that groups every persisted editor preference — theme, font, +/// spacing and visibility toggles — so the user discovers them without hunting +/// through menu submenus. All state lives in `EditorState` and `ThemeManager`; +/// this view only presents and binds. +public struct SettingsView: View { + @Bindable private var appState: AppState + + public init(appState: AppState) { + self.appState = appState + } + + public var body: some View { + NavigationStack { + Form { + // MARK: Theme + Section("Theme") { + Menu { + ForEach(Theme.all, id: \.name) { theme in + Button { + appState.themeManager.setTheme(theme) + } label: { + HStack { + Text(theme.name) + if appState.theme == theme { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + Divider() + Button("Toggle Dark / Light") { + appState.themeManager.toggleDarkLight() + } + } label: { + Label("Theme: \(appState.theme.name)", systemImage: "paintpalette") + } + } + + // MARK: Text + Section("Text") { + Menu { + ForEach([11, 12, 13, 14, 16, 18, 24, 36], id: \.self) { size in + Button("\(size)") { + appState.editorState.setFontSize(Double(size)) + } + } + } label: { + Label("Font Size: \(Int(fontSizeValue))", systemImage: "textformat") + } + + Menu { + ForEach([2, 4, 8], id: \.self) { width in + Button("\(width) Spaces") { + appState.editorState.setTabWidth(width) + } + } + } label: { + Label("Tab Width: \(appState.editorState.tabWidth)", systemImage: "tabletab") + } + } + + // MARK: View + Section("View") { + Toggle("Word Wrap", isOn: wrapBinding) + Toggle("Show Invisibles", isOn: invisiblesBinding) + Toggle("Minimap", isOn: minimapBinding) + Toggle("Code Folding Ribbon", isOn: foldingBinding) + } + } + .formStyle(.grouped) + .navigationTitle("Settings") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + appState.isSettingsVisible = false + } + } + } + } + } + + // MARK: - Bindings + + private var wrapBinding: Binding { + Binding( + get: { appState.editorState.wrapLines }, + set: { appState.editorState.setWrapLines($0) } + ) + } + + private var invisiblesBinding: Binding { + Binding( + get: { appState.editorState.showInvisibles }, + set: { appState.editorState.setShowInvisibles($0) } + ) + } + + private var minimapBinding: Binding { + Binding( + get: { appState.editorState.showMinimap }, + set: { appState.editorState.setShowMinimap($0) } + ) + } + + private var foldingBinding: Binding { + Binding( + get: { appState.editorState.showFoldingRibbon }, + set: { appState.editorState.setShowFoldingRibbon($0) } + ) + } + + /// The current font size as a `Double`, so the Stepper `format:` works. + private var fontSizeValue: Double { + appState.editorState.font.pointSize + } +} From 1ffedad9e48a171f16d6f656d4b9fb5a2db97b37 Mon Sep 17 00:00:00 2001 From: yan Date: Sun, 11 Oct 2026 11:50:09 +0300 Subject: [PATCH 5/9] docs: update CURRENT_STATE, PROJECT_PLAN, MEMORY for diagnostics/hover/goto-def/settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CURRENT_STATE.md: 113→115 tests; diagnostics + hover/def + settings marked working; status bar diagnostic-count mention; size table bumped for 5 new files (Diagnostic.swift, DocumentDiagnosticsTests, LSPTextViewCoordinator edits, SettingsView, StatusBarView edits) - PROJECT_PLAN.md: #2, #3, #4 marked done; test-count gate updated - MEMORY.md: #23 on Picker(selection:) resolving to init(sources:) on this toolchain; FAQ entries for settings picker + diagnostic colours Closes the 'before you call it done' checklist for issues #2, #3, #4. --- PROJECT_PLAN.md | 10 ++++---- docs/CURRENT_STATE.md | 53 ++++++++++++++++++++++--------------------- docs/MEMORY.md | 22 +++++++++++++++++- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index e1bc348..32b5488 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -21,22 +21,22 @@ tracked in GitHub, ordered in [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md). | # | Issue | Scope | |---|---|---| | 1 | [LSP completion](https://github.com/Yan123-tech/code-editor/issues/1) — wired | Connect `LSPClient` via a `TextViewCoordinator`; fuzzy-filtered popup, Tab/Enter to accept | -| 2 | [Diagnostics](https://github.com/Yan123-tech/code-editor/issues/2) | Consume `textDocument/publishDiagnostics`; underline plus gutter markers | -| 3 | [Hover and go-to-definition](https://github.com/Yan123-tech/code-editor/issues/3) | Tooltips and ⌘-click navigation | +| 2 | [Diagnostics](https://github.com/Yan123-tech/code-editor/issues/2) — done | Consume `textDocument/publishDiagnostics`; underline plus status-bar summary | +| 3 | [Hover and go-to-definition](https://github.com/Yan123-tech/code-editor/issues/3) — done | ⌘-click navigation with server documentation in the link popover | | 6 | [External change detection](https://github.com/Yan123-tech/code-editor/issues/6) — done | Reload clean files, prompt on dirty ones | | 5 | [PTY terminal](https://github.com/Yan123-tech/code-editor/issues/5) | Real pseudo-terminal, ANSI parsing, SIGINT forwarding | -| 4 | [Settings panel](https://github.com/Yan123-tech/code-editor/issues/4) | Move persisted preferences out of the Format menu into a discoverable surface | +| 4 | [Settings panel](https://github.com/Yan123-tech/code-editor/issues/4) — done | Move persisted preferences out of the Format menu into a discoverable surface | | 14 | [Interface redesign](https://github.com/Yan123-tech/code-editor/issues/14) | Landed: native macOS chrome and a real design system | ## Quality gates - [x] Builds with no warnings -- [x] `swift test` passes (108 tests on `feature/lsp-completion`; +5 `DocumentExternalChangeTests` on `feature/external-change-detection`; 1 local-only sourcekit E2E, see [docs/MEMORY.md](docs/MEMORY.md)) +- [x] `swift test` passes (115 tests: 114 unit + 1 local-only sourcekit E2E; see [docs/MEMORY.md](docs/MEMORY.md)) - [x] `swift-format lint --strict` clean - [ ] CI builds the package — blocked, see [docs/MEMORY.md](docs/MEMORY.md#1-ci-cannot-build-this-package-do-not-try-to-make-it) - [x] Completion works for Swift — wired to `sourcekit-lsp`, end-to-end tested locally - [x] External file changes since open are guarded on save — [#6](https://github.com/Yan123-tech/code-editor/issues/6) -- [ ] Diagnostics render +- [x] Diagnostics render - [ ] No leaks in a 30-minute session - [x] Unsaved work is guarded on quit — [#18](https://github.com/Yan123-tech/code-editor/issues/18). Force-quit and crashes still lose buffers - [x] The redesign has been reviewed by someone who can see it — it found #15–#17, all since fixed diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 4e0d6ce..4387509 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -6,8 +6,8 @@ on `main` (748e7ef) and are each green on the local gate. ``` swift build → Build complete (1 pre-existing warning in EditorState.swift) -swift test → 113 tests in 31 suites (107 unit + 1 sourcekit end-to-end, local-only; - 5 `DocumentExternalChangeTests`); 78 in 18 suites on +swift test → 115 tests in 32 suites (114 unit + 1 sourcekit end-to-end, local-only; + 2 new `DocumentDiagnosticsTests`); 78 in 18 suites on `feature/external-change-detection` (73 pre-existing + 5 new) swift-format lint --strict → 0 warnings ``` @@ -33,8 +33,9 @@ The window was rebuilt around native macOS chrome. The design tokens live in | **Toolbar** | Unified: breadcrumb principal, create menu and quick-open/terminal toggles trailing. The sidebar's hand-rolled header is gone. | | **Sidebar** | Click or arrow to a row to activate: files open, folders expand. The active document is revealed by expanding its ancestors, shown bold with an accent icon. Delete asks first. | | **Tabs** | 32pt strip, active tab filled with the canvas colour and rounded so it merges with the editor. Dirty dot beside the title; close button always present. | -| **Status bar** | Caret, selection count, UTF-8, line ending, language, and a "Changed on disk" hint - while a save conflict is pending. The byte count is gone. | +| **Status bar** | Caret, selection count, UTF-8, line ending, language, a live error/warning + count from LSP diagnostics, and a "Changed on disk" hint while a save conflict is + pending. The byte count is gone. | | **Empty state** | One, in the canvas. With a workspace open it points at the sidebar; without one it offers Open Folder as the single prominent action. | | **Find/replace** | ⌘F opens CodeEdit's own panel through `SourceEditorState.findPanelVisible`. | | **Quick open** | ⌘P over a bounded filesystem index. Matched characters are emphasised. | @@ -72,13 +73,17 @@ lines, command history on ↑/↓, stdout and stderr colored separately. **Themes.** Dark, Light, High Contrast. Selection persists to `UserDefaults`, along with font size, tab width, word wrap, minimap, folding ribbon and invisibles. +**Settings panel.** ⌘, opens a sheet (see [#4](https://github.com/Yan123-tech/code-editor/issues/4)) +consolidating theme, font size, tab width and the four view toggles in one Form — no new +behaviour, just a discoverable surface for what was already in the Format menu. + **Quit guard.** Quitting with modified documents prompts first. Save saves everything and abandons the quit if any save did not take — a cancelled panel keeps the work; Don't Save discards; Cancel stays. -**Commands.** ⌘N, ⌘O, ⇧⌘O, ⌘F, ⌘P, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L. All wired; none are empty closures. +**Commands.** ⌘N, ⌘O, ⇧⌘O, ⌘F, ⌘P, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L, ⌘,. All wired; none are empty closures. -**Tests.** 113 swift-testing cases (107 unit + 1 sourcekit end-to-end, local-only). +**Tests.** 115 swift-testing cases (113 unit + 2 new diagnostics, 1 sourcekit end-to-end, local-only). `FileSystemManager` and `QuickOpenIndex` are tested against in-memory providers, so no non-LSP test touches the real disk; the LSP transport and completion matching are covered by dedicated suites (see table below). @@ -90,9 +95,12 @@ correlates responses by id, ACKs server-initiated requests and fails parked requ or time-out instead of hanging. `LSPClient` completes the `initialize` handshake against `sourcekit-lsp`, and `LSPTextViewCoordinator` drives open/didChange/close plus the fuzzy completion popup (Tab/Enter to accept, inline error on server crash or a malformed -response). Verified end-to-end by `LSPClientEndToEndTests` (local-only; `sourcekit-lsp` is -too heavy to run under CI, see [MEMORY.md #1](MEMORY.md)). Diagnostics, hover and -go-to-definition are [#2](https://github.com/Yan123-tech/code-editor/issues/2)–[#3](https://github.com/Yan123-tech/code-editor/issues/3). +response). Diagnostics, hover and go-to-definition are [#2](https://github.com/Yan123-tech/code-editor/issues/2)–[#3](https://github.com/Yan123-tech/code-editor/issues/3): diagnostics +from `textDocument/publishDiagnostics` paint underlines coloured by severity (error/warning/ +information/hint) from `Theme.semantic`, a live error/warning count appears in the status bar, +and ⌘-click resolves a definition via `JumpToDefinitionDelegate` with server hover +documentation shown in the link popover. Verified for completion by `LSPClientEndToEndTests` +(local-only; `sourcekit-lsp` is too heavy to run under CI, see [MEMORY.md #1](MEMORY.md)). Known limits of the LSP wiring: @@ -103,6 +111,9 @@ Known limits of the LSP wiring: - Each `didOpen` on a `Package.swift` workspace triggers `swift build --experimental-prepare-for-indexing` inside sourcekit, which OOMs under `swift test`. Use a single-file document. [MEMORY.md #20](MEMORY.md#20-sourcekit-lsp-server-requests-use-string-ids-and-its-completion-must-be-static). +- Diagnostics underlines are a solid coloured underline, not a wavy squiggle — the upstream + `GutterView` and `TextView` don't expose a squiggle attribute set in 0.15. Gutter markers + (the red/yellow dots in the scroll ruler) are a follow-up. **The terminal has no PTY.** Pipes, so no job control and no TTY. `vim` renders wrong, `top` exits immediately, Ctrl-C does not reliably interrupt. [#5](https://github.com/Yan123-tech/code-editor/issues/5). @@ -145,6 +156,7 @@ one `AppState`, one document set and one sidebar. Multi-window needs per-window | Document | 12 | line splitting, offset math, selection clamping, insert/delete, line endings, line ranges, selected lines | | Document persistence | 2 | save/reload round-trip, `noURL` | | Document external change | 5 | mtime guard, baseline resets on save, revert, untitled unguarded | +| Document diagnostics | 2 | setDiagnostics round-trip, range-to-NSRange conversion | | DocumentManager | 3 | reuse, neighbour focus, dirty tracking | | TextSelectionManager | 4 | primary selection, joined text | | FileSystemManager | 10 | root validation, sort order, create, rename, expansion, cache keys, `reveal`, `clearError` | @@ -177,18 +189,7 @@ this is a recommendation, not a commitment. The data-loss guard recommended here is done — [#18](https://github.com/Yan123-tech/code-editor/issues#18). -### 1. #2 then #3 — diagnostics, hover, go-to-definition - -Follow #1 naturally: they share the transport and lifecycle that #1 proves. Doing them -together means the `TextViewCoordinator` wiring is designed once. - -### 2. #4 — settings panel - -Smallest of the six. Minimap, folding ribbon, invisibles, wrap, tab width and font size are -already toggles in the Format menu and already persisted; the panel is moving existing -state into a discoverable surface, not new behaviour. - -### 3. #5 — PTY terminal +### 1. #5 — PTY terminal Largest and least user-visible until done. `vim` and `top` are broken today, which makes it feel urgent, but it is C interop behind a protocol and nothing else depends on it. @@ -216,13 +217,13 @@ as a placeholder branch — plan only, no code. | Module | Lines | Files | |---|---|---| -| CodeEditorCore | 1565 | 9 | +| CodeEditorCore | 1619 | 10 | | CodeEditorLSP | 1443 | 4 | -| CodeEditorUI | 2285 | 13 | -| CodeEditorApp | 562 | 3 | -| CodeEditorThemes | 437 | 5 | +| CodeEditorUI | 2424 | 15 | +| CodeEditorApp | 592 | 3 | +| CodeEditorThemes | 439 | 5 | | CodeEditorTerminal | 245 | 1 | -| Tests | 1692 | 11 | +| Tests | 1723 | 13 | LSP is the largest module after `CodeEditorUI` and was the least used — it is now wired in for completion (#1). That is deliberate for now — it is the interesting part — but with it live, diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 86e9b51..4ae7bfb 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -425,6 +425,24 @@ compile error ("cannot use mutating member on immutable value"), not a silent no --- +## 23. `Picker` with `selection:` resolves to `init(sources:selection:...)` on this toolchain + +On Swift 6.4 / Xcode 26.4's SwiftUI, `Picker("Title", selection: binding, content:label:)` +with a trailing `label:` closure resolves to the `init(sources:selection:content:label:)` +overload instead. The compiler then complains that the first positional argument (the title +`String`) does not conform to `RandomAccessCollection`, and the error is a generic-parameter +inference failure that hides the real cause. + +Two workarounds that work: +- Use `Menu` with `Button`s instead (what the settings panel does — it is simpler and gives the + same UX for a short fixed list). +- Drop the trailing-`label:` form and use the older `Picker("Title", selection:binding, content:)` + where `content:` is a trailing closure. + +This is toolchain-specific and may resolve itself on a newer SDK; do not generalise the fix. + +--- + ## Quick reference | Question | Answer | @@ -447,4 +465,6 @@ compile error ("cannot use mutating member on immutable value"), not a silent no | Where do I write "this does not work"? | Here, and [CURRENT_STATE.md](CURRENT_STATE.md). | | Why did highlighting fall back to plain text? | #19. The `Language` identifier must match `TreeSitterLanguage.rawValue`. | | Why are straight quotes not warned? | They are correct in code. #19. | -| Why can't I silently overwrite a file changed on disk? | You can't. #6 throws `DocumentError.changedOnDisk` and the save dialog offers Reload / Save Anyway. | \ No newline at end of file +| Why can't I silently overwrite a file changed on disk? | You can't. #6 throws `DocumentError.changedOnDisk` and the save dialog offers Reload / Save Anyway. | +| How do I make a settings picker? | Don't use `Picker(selection:)`. See #23. Use `Menu` with `Button`s. | +| Where do I add a diagnostic severity colour? | `Theme.semantic` — `.danger`, `.warning`, `.success`. Never a syntax token. | \ No newline at end of file From d492271806b99e8b57d5e6076b7dbe35274d883b Mon Sep 17 00:00:00 2001 From: yan Date: Mon, 12 Oct 2026 11:01:07 +0300 Subject: [PATCH 6/9] fix(ui): resolve tab fluctuation, editor flicker, gutter layering, and lsp underlines - fix(sidebar): prevent cyclic tab/selection oscillation by guarding programmatic reveals with isSyncingSelection and checking activeDocumentURL - fix(sidebar): fix folder expansion requiring selection by decoupling disclosure chevron button from List selection state - fix(chrome): prevent gutter line number layering over TabBarView by adding zIndex(1) and clipped() - fix(lsp): switch diagnostic rendering from NSTextStorage attributes to CodeEditTextView EmphasisManager to prevent Tree-sitter wipes - fix(lsp): add publishDiagnostics to ClientCapabilities and enable cached diagnostic rendering on document attach - docs: document EmphasisManager, disclosure button, selection guard, and AppKit layering in MEMORY.md (#24-#27) --- .../Sources/MainWindowView.swift | 3 + .../Sources/Document/Document.swift | 1 + Sources/CodeEditorLSP/Types/LSPTypes.swift | 32 ++++++++++- .../Sources/Chrome/TabBarView.swift | 1 + .../Editor/LSPTextViewCoordinator.swift | 27 ++++++++- .../Sources/Sidebar/FileExplorerView.swift | 34 +++++++++-- docs/MEMORY.md | 56 +++++++++++++++++++ 7 files changed, 144 insertions(+), 10 deletions(-) diff --git a/Sources/CodeEditorApp/Sources/MainWindowView.swift b/Sources/CodeEditorApp/Sources/MainWindowView.swift index 38a2ed5..2527b9d 100644 --- a/Sources/CodeEditorApp/Sources/MainWindowView.swift +++ b/Sources/CodeEditorApp/Sources/MainWindowView.swift @@ -36,8 +36,10 @@ struct MainWindowView: View { onClose: { document in appState.requestClose(document) }, onNew: { appState.newFile() } ) + .zIndex(1) editorArea + .clipped() if appState.isStatusBarVisible { StatusBarView(appState: appState) @@ -191,6 +193,7 @@ struct MainWindowView: View { appState: appState ) .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() } else { EmptyStateView( appState: appState, diff --git a/Sources/CodeEditorCore/Sources/Document/Document.swift b/Sources/CodeEditorCore/Sources/Document/Document.swift index 84fb7b4..ac42ef5 100644 --- a/Sources/CodeEditorCore/Sources/Document/Document.swift +++ b/Sources/CodeEditorCore/Sources/Document/Document.swift @@ -625,6 +625,7 @@ public final class DocumentManager { public func activate(_ document: Document) { guard documents.contains(where: { $0 === document }) else { return } + guard activeDocument !== document else { return } activeDocument = document } diff --git a/Sources/CodeEditorLSP/Types/LSPTypes.swift b/Sources/CodeEditorLSP/Types/LSPTypes.swift index 4861ec0..aaf6928 100644 --- a/Sources/CodeEditorLSP/Types/LSPTypes.swift +++ b/Sources/CodeEditorLSP/Types/LSPTypes.swift @@ -277,6 +277,23 @@ public struct Diagnostic: Codable, Identifiable, Equatable, Sendable { self.source = source self.message = message } + + enum CodingKeys: String, CodingKey { + case range, severity, code, source, message + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + range = try container.decode(Range.self, forKey: .range) + message = (try? container.decode(String.self, forKey: .message)) ?? "" + source = try? container.decodeIfPresent(String.self, forKey: .source) + code = try? container.decodeIfPresent(DiagnosticCode.self, forKey: .code) + if let sevRaw = try? container.decodeIfPresent(Int.self, forKey: .severity) { + severity = DiagnosticSeverity(rawValue: sevRaw) + } else { + severity = nil + } + } } // MARK: - Symbols @@ -447,22 +464,35 @@ public struct WorkspaceFolder: Encodable, Sendable { /// The capabilities this client advertises. public struct ClientCapabilities: Encodable, Sendable { + public struct PublishDiagnostics: Encodable, Sendable { + public var relatedInformation: Bool + public var versionSupport: Bool + + public init(relatedInformation: Bool = true, versionSupport: Bool = true) { + self.relatedInformation = relatedInformation + self.versionSupport = versionSupport + } + } + public struct TextDocument: Encodable, Sendable { public var synchronization: Synchronization public var completion: Completion public var hover: Hover public var definition: DynamicRegistration + public var publishDiagnostics: PublishDiagnostics public init( synchronization: Synchronization = .init(), completion: Completion = .init(), hover: Hover = .init(), - definition: DynamicRegistration = .init() + definition: DynamicRegistration = .init(), + publishDiagnostics: PublishDiagnostics = .init() ) { self.synchronization = synchronization self.completion = completion self.hover = hover self.definition = definition + self.publishDiagnostics = publishDiagnostics } } diff --git a/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift b/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift index 4c7661e..fa8ca59 100644 --- a/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift +++ b/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift @@ -59,6 +59,7 @@ public struct TabBarView: View { .frame(height: Metrics.Height.tabStrip) } .frame(height: Metrics.Height.tabStrip) + .clipped() } // MARK: - Strip diff --git a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift index fd3ab46..f7eff76 100644 --- a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift +++ b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift @@ -125,7 +125,10 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, func prepareCoordinator(controller: TextViewController) { self.controller = controller - Task { await openIfNeeded() } + Task { + await openIfNeeded() + renderDiagnosticUnderlines(document.diagnostics) + } } func destroy() { @@ -147,12 +150,12 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, await client.closeDocument(uri: uri) } + clearDiagnosticUnderlines() self.document = document didOpen = false lastCompletionItems = nil crashShown = false currentURI = nil - clearDiagnosticUnderlines() await openIfNeeded() // If this document had diagnostics from a previous visit, re-render them. renderDiagnosticUnderlines(document.diagnostics) @@ -160,6 +163,13 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, // MARK: - Document sync + private func matchesCurrentURI(_ uri: String) -> Bool { + guard let currentURI else { return false } + if uri == currentURI { return true } + guard let u1 = URL(string: uri), let u2 = URL(string: currentURI) else { return false } + return u1.standardizedFileURL == u2.standardizedFileURL || u1.path == u2.path + } + /// `didOpen` on first appearance, for on-disk documents only (untitled docs have no /// URI; sourcekit-lsp can't anchor a position to them). Also subscribes to diagnostics /// for this document's URI. @@ -178,11 +188,22 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, // active document is the common case; switching tabs re-renders from the stored // `document.diagnostics`. client.onDiagnosticsChanged = { [weak self] serverURI, serverDiagnostics in - guard let self, serverURI == currentURI else { return } + guard let self else { return } + guard self.matchesCurrentURI(serverURI) else { return } let converted = self.convertDiagnostics(serverDiagnostics) self.document.setDiagnostics(converted) self.renderDiagnosticUnderlines(converted) } + + // Render any cached diagnostics the client or document already has + let cached = client.diagnostics(for: uri) + if !cached.isEmpty { + let converted = convertDiagnostics(cached) + document.setDiagnostics(converted) + renderDiagnosticUnderlines(converted) + } else if !document.diagnostics.isEmpty { + renderDiagnosticUnderlines(document.diagnostics) + } } /// Forward a keystroke to the server, debounced so a fast typist sends one diff --git a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift index 814ca67..14c2b2a 100644 --- a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift +++ b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift @@ -20,6 +20,7 @@ public struct FileExplorerView: View { let onCreateIn: (URL, NewItemKind) -> Void @State private var selection: FileSystemItem? + @State private var isSyncingSelection = false @State private var renaming: FileSystemItem? @State private var deleting: FileSystemItem? @State private var errorMessage: String? @@ -102,6 +103,10 @@ public struct FileExplorerView: View { } .listStyle(.sidebar) .onChange(of: selection) { _, newValue in + if isSyncingSelection { + isSyncingSelection = false + return + } // Selection drives activation, as it does in Xcode's navigator: // landing on a file opens it, landing on a folder expands it. Arrowing // through the tree does the same, which is why the expansion lives @@ -115,6 +120,20 @@ public struct FileExplorerView: View { if let selection { activate(selection) } return .handled } + .onKeyPress(.rightArrow) { + if let selection, selection.isDirectory, !fileSystemManager.isExpanded(selection.url) { + fileSystemManager.toggleExpansion(of: selection) + return .handled + } + return .ignored + } + .onKeyPress(.leftArrow) { + if let selection, selection.isDirectory, fileSystemManager.isExpanded(selection.url) { + fileSystemManager.toggleExpansion(of: selection) + return .handled + } + return .ignored + } } /// What selecting a row does: files open, folders toggle. @@ -122,6 +141,7 @@ public struct FileExplorerView: View { if item.isDirectory { fileSystemManager.toggleExpansion(of: item) } else { + guard item.url != activeDocumentURL else { return } onOpenFile(item.url) } } @@ -187,15 +207,12 @@ public struct FileExplorerView: View { } } - /// The disclosure chevron. A real control: expansion hangs off the selection change, - /// so a folder that is *already* selected produces no change and nothing would toggle. - /// Selecting it first also makes the row selection agree with the chevron, which is - /// what stops the two from cancelling out. + /// The disclosure chevron. Directly toggles folder expansion without mutating row selection, + /// ensuring expansion works predictably on single click whether the item was previously selected or not. @ViewBuilder private func disclosureControl(for item: FileSystemItem) -> some View { if item.isDirectory { Button { - selection = item fileSystemManager.toggleExpansion(of: item) } label: { Image(systemName: fileSystemManager.isExpanded(item.url) ? "chevron.down" : "chevron.right") @@ -227,7 +244,12 @@ public struct FileExplorerView: View { await fileSystemManager.reveal(url) let parent = url.deletingLastPathComponent() - selection = fileSystemManager.children(of: parent).first { $0.url == url } ?? selection + if let target = fileSystemManager.children(of: parent).first(where: { $0.url == url }) { + if selection != target { + isSyncingSelection = true + selection = target + } + } } // MARK: - Bindings diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 4ae7bfb..4d9cdc9 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -443,6 +443,62 @@ This is toolchain-specific and may resolve itself on a newer SDK; do not general --- +## 24. CodeEditTextView ignores `NSTextStorage` underline attributes; use `EmphasisManager` + +`CodeEditTextView.TextView` does not use standard AppKit/TextKit underline rendering, and +`CodeEditSourceEditor`'s Tree-sitter `Highlighter` invokes `textStorage.setAttributes(...)` on +every token pass, instantly blowing away any attributes attached directly to `textStorage`. + +Diagnostic underlines, syntax warnings, and problem highlighting must be added via +`textView.emphasisManager`: +```swift +textView.emphasisManager?.replaceEmphases( + [Emphasis(range: clampedRange, style: .underline(color: color))], + for: "CodeEditor.LSP.diagnostics" +) +``` +`Emphasis` renders as dedicated `CAShapeLayer`s underneath the text and is preserved across +highlighter updates. + +--- + +## 25. Sidebar chevron buttons in a `List(selection:)` must not mutate selection + +In SwiftUI, clicking a `Button` inside a `List` row does not trigger row selection unless the button +action explicitly mutates `selection = item`. When the button did `selection = item`, it caused +both the button action *and* the `List`'s `.onChange(of: selection)` to run `toggleExpansion`. +Because both ran back-to-back, expansion toggled twice and immediately cancelled out whenever an +unselected folder's chevron was clicked. + +The disclosure chevron `Button` must only call `fileSystemManager.toggleExpansion(of: item)` and +leave `selection` untouched. + +--- + +## 26. Programmatic sidebar selection must be guarded from triggering `onOpenFile` + +When a tab switches, `FileExplorerView` reveals the active document and selects it in the tree. +Without a suppression flag (`isSyncingSelection`), mutating `selection` triggers +`.onChange(of: selection) -> activate() -> onOpenFile()`, firing an asynchronous file open task +that echoes back into the document manager. This created a rapid oscillating feedback loop +fluctuating between tabs and repeatedly reloading the editor view. + +Always guard programmatic selection updates with `isSyncingSelection = true` and ensure +`activate` ignores items whose URL already equals `activeDocumentURL`. + +--- + +## 27. AppKit `NSScrollView` floating subviews bleed past SwiftUI views without `.clipped()` and `.zIndex()` + +`GutterView` (line numbers) in `CodeEditSourceEditor` is an `NSScrollView` floating subview. +When scrolling, AppKit views can draw over sibling SwiftUI views in a `VStack` because SwiftUI +does not clip view boundaries by default, and later views draw on top of earlier ones. + +`editorArea` must be explicitly `.clipped()`, and `TabBarView` must declare `.zIndex(1)` and +`.clipped()` with an opaque base layer so line numbers never bleed or layer into the tab bar. + +--- + ## Quick reference | Question | Answer | From b9b3927d2d224f329e312ada6757177428d885bc Mon Sep 17 00:00:00 2001 From: yan Date: Mon, 12 Oct 2026 14:39:42 +0300 Subject: [PATCH 7/9] feat(go): adapt editor for Go development, add diagnostic tooltips and recent projects welcome hub - Fix diagnostic highlight accuracy by aligning offset calculations to UTF-16 code units (LSP spec) - Add diagnostic reason popover tooltip on cursor/mouse hover over error lines - Disable aggressive session restoration on launch to prevent startup tab switching and editor flickers - Add Welcome Hub in main window area featuring Recent Projects store with 1-click open, Middle-truncation, and Finder actions - Add gopls integration with staticcheck, analyses, and unimported completions - Add gofmt formatting on save and via Shift+Option+F shortcut - Add Product menu commands for Run (Cmd+R) and Test (Cmd+U) in integrated terminal drawer - Inject Go and local toolchain paths into GUI subprocess PATH environment --- .../CodeEditorApp/Sources/CodeEditorApp.swift | 29 +++ .../Sources/Document/Diagnostic.swift | 6 +- .../Sources/Document/Document.swift | 22 +- .../Sources/Document/GoFormatter.swift | 48 ++++ .../Sources/Session/RecentProjectsStore.swift | 67 ++++++ Sources/CodeEditorLSP/JSONRPCTransport.swift | 15 ++ Sources/CodeEditorLSP/LSPClient.swift | 38 +++- Sources/CodeEditorLSP/Types/LSPTypes.swift | 42 +++- .../CodeEditorTerminal/TerminalSession.swift | 12 + Sources/CodeEditorUI/AppState.swift | 70 +++++- .../Sources/Chrome/EmptyStateView.swift | 150 ++++++++++++- .../Sources/Editor/CodeEditorView.swift | 5 +- .../Editor/DiagnosticPopoverView.swift | 90 ++++++++ .../Sources/Editor/EditorState.swift | 2 +- .../Editor/LSPTextViewCoordinator.swift | 212 ++++++++++++++++-- .../CodeEditorTests/GoDevelopmentTests.swift | 61 +++++ .../RecentProjectsStoreTests.swift | 64 ++++++ docs/MEMORY.md | 35 +++ 18 files changed, 921 insertions(+), 47 deletions(-) create mode 100644 Sources/CodeEditorCore/Sources/Document/GoFormatter.swift create mode 100644 Sources/CodeEditorCore/Sources/Session/RecentProjectsStore.swift create mode 100644 Sources/CodeEditorUI/Sources/Editor/DiagnosticPopoverView.swift create mode 100644 Tests/CodeEditorTests/GoDevelopmentTests.swift create mode 100644 Tests/CodeEditorTests/RecentProjectsStoreTests.swift diff --git a/Sources/CodeEditorApp/Sources/CodeEditorApp.swift b/Sources/CodeEditorApp/Sources/CodeEditorApp.swift index 988c6be..b666895 100644 --- a/Sources/CodeEditorApp/Sources/CodeEditorApp.swift +++ b/Sources/CodeEditorApp/Sources/CodeEditorApp.swift @@ -107,6 +107,20 @@ struct CodeEditorApp: App { // MARK: Format CommandGroup(replacing: .textFormatting) { + Button("Format Document") { + appState.formatActiveDocument() + } + .keyboardShortcut("f", modifiers: [.command, .shift]) + .disabled(appState.documentManager.activeDocument == nil) + + Toggle( + "Format on Save", + isOn: Binding( + get: { appState.editorState.formatOnSave }, + set: { appState.editorState.formatOnSave = $0 } + )) + + Divider() Toggle( "Word Wrap", isOn: Binding( @@ -152,6 +166,21 @@ struct CodeEditorApp: App { } } + // MARK: Product + CommandMenu("Product") { + Button("Run") { + appState.runProject() + } + .keyboardShortcut("r", modifiers: .command) + .disabled(appState.fileSystemManager.rootURL == nil) + + Button("Test") { + appState.testProject() + } + .keyboardShortcut("u", modifiers: .command) + .disabled(appState.fileSystemManager.rootURL == nil) + } + // MARK: Theme CommandMenu("Theme") { ForEach(Theme.all, id: \.name) { theme in diff --git a/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift b/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift index 5244792..9d0e157 100644 --- a/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift +++ b/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift @@ -17,14 +17,16 @@ public enum DocumentDiagnosticSeverity: Int, CaseIterable, Sendable { /// Built from the LSP `Diagnostic` by `LSPTextViewCoordinator`; the Core model never /// imports the LSP module. public struct DocumentDiagnostic: Equatable, Sendable { - /// UTF-8 offset range of the diagnostic, matching `Document` offsets. + /// Offset range of the diagnostic, matching `Document` offsets. public let range: TextSelection public let severity: DocumentDiagnosticSeverity public let message: String + public let source: String? - public init(range: TextSelection, severity: DocumentDiagnosticSeverity, message: String) { + public init(range: TextSelection, severity: DocumentDiagnosticSeverity, message: String, source: String? = nil) { self.range = range self.severity = severity self.message = message + self.source = source } } diff --git a/Sources/CodeEditorCore/Sources/Document/Document.swift b/Sources/CodeEditorCore/Sources/Document/Document.swift index ac42ef5..ad17918 100644 --- a/Sources/CodeEditorCore/Sources/Document/Document.swift +++ b/Sources/CodeEditorCore/Sources/Document/Document.swift @@ -333,36 +333,38 @@ public final class Document: Identifiable { // MARK: - Offset math - /// UTF-8 offset of a line/column pair. + /// UTF-16 code unit offset of a line/column pair (where column is in UTF-16 code units). public func offset(forLine line: Int, column: Int) -> Int? { guard line >= 0, line < lines.count, column >= 0 else { return nil } var offset = 0 for index in 0.. (line: Int, column: Int)? { - guard offset >= 0, offset <= content.utf8.count else { return nil } + guard offset >= 0, offset <= content.utf16.count else { return nil } var lineOffset = 0 for (index, text) in lines.enumerated() { - if offset <= lineOffset + text.utf8.count { + let lineLength = text.utf16.count + if offset <= lineOffset + lineLength { return (index, offset - lineOffset) } - lineOffset += text.utf8.count + lineEnding.character.utf8.count + lineOffset += lineLength + lineEnding.character.utf16.count } let last = max(0, lines.count - 1) - return (last, lines.isEmpty ? 0 : lines[last].utf8.count) + return (last, lines.isEmpty ? 0 : lines[last].utf16.count) } - /// An `NSRange` for a UTF-8 offset range. + /// An `NSRange` for a UTF-16 offset range. public func nsRange(from start: Int, to end: Int) -> NSRange? { - guard start >= 0, end >= start, end <= content.utf8.count else { return nil } + guard start >= 0, end >= start, end <= content.utf16.count else { return nil } return NSRange(location: start, length: end - start) } diff --git a/Sources/CodeEditorCore/Sources/Document/GoFormatter.swift b/Sources/CodeEditorCore/Sources/Document/GoFormatter.swift new file mode 100644 index 0000000..bc9f5f4 --- /dev/null +++ b/Sources/CodeEditorCore/Sources/Document/GoFormatter.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Integrates `gofmt` to format Go source code. +public enum GoFormatter { + /// Path to `gofmt`, if installed. + public static var gofmtPath: String? { + let candidates = [ + "/usr/local/go/bin/gofmt", + "/opt/homebrew/bin/gofmt", + "/usr/local/bin/gofmt", + "/usr/bin/gofmt", + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + + /// Formats the provided Go source code using `gofmt`. + /// Returns the formatted code if formatting succeeds, or `nil` if `gofmt` is unavailable + /// or encountered a syntax error. + public static func format(_ source: String) -> String? { + guard let path = gofmtPath else { return nil } + + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + let inPipe = Pipe() + let outPipe = Pipe() + let errPipe = Pipe() + process.standardInput = inPipe + process.standardOutput = outPipe + process.standardError = errPipe + + do { + try process.run() + if let data = source.data(using: .utf8) { + inPipe.fileHandleForWriting.write(data) + try? inPipe.fileHandleForWriting.close() + } + let outData = outPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + if process.terminationStatus == 0, let formatted = String(data: outData, encoding: .utf8) { + return formatted + } + } catch { + return nil + } + return nil + } +} diff --git a/Sources/CodeEditorCore/Sources/Session/RecentProjectsStore.swift b/Sources/CodeEditorCore/Sources/Session/RecentProjectsStore.swift new file mode 100644 index 0000000..adbebc6 --- /dev/null +++ b/Sources/CodeEditorCore/Sources/Session/RecentProjectsStore.swift @@ -0,0 +1,67 @@ +import Foundation + +// MARK: - RecentProject + +/// Represents a recently opened workspace folder. +public struct RecentProject: Codable, Equatable, Hashable, Identifiable, Sendable { + public var id: String { url.standardizedFileURL.path } + public let url: URL + public let name: String + public let lastOpened: Date + + public init(url: URL, name: String? = nil, lastOpened: Date = Date()) { + self.url = url + self.name = name ?? (url.lastPathComponent.isEmpty ? url.path : url.lastPathComponent) + self.lastOpened = lastOpened + } +} + +// MARK: - RecentProjectsStore + +/// Persists recently opened workspace projects in UserDefaults. +public enum RecentProjectsStore { + private static let key = "CodeEditor.recentProjects" + public static let maxRecents = 15 + + /// Load the list of recent projects, filtering out paths that no longer exist on disk. + public static func load(defaults: UserDefaults = .standard) -> [RecentProject] { + guard let data = defaults.data(forKey: key), + let projects = try? JSONDecoder().decode([RecentProject].self, from: data) + else { + return [] + } + return projects.filter { FileManager.default.fileExists(atPath: $0.url.path) } + } + + /// Record a workspace URL as opened, placing it at the front of the MRU list. + public static func record(url: URL, defaults: UserDefaults = .standard) { + var recents = load(defaults: defaults) + let standardized = url.standardizedFileURL + recents.removeAll { $0.url.standardizedFileURL == standardized } + let entry = RecentProject(url: standardized, name: standardized.lastPathComponent, lastOpened: Date()) + recents.insert(entry, at: 0) + if recents.count > maxRecents { + recents = Array(recents.prefix(maxRecents)) + } + save(recents, defaults: defaults) + } + + /// Remove a specific workspace from the recent list. + public static func remove(url: URL, defaults: UserDefaults = .standard) { + var recents = load(defaults: defaults) + let standardized = url.standardizedFileURL + recents.removeAll { $0.url.standardizedFileURL == standardized } + save(recents, defaults: defaults) + } + + /// Clear all recent projects. + public static func clear(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: key) + } + + /// Persist the list of recent projects. + public static func save(_ projects: [RecentProject], defaults: UserDefaults = .standard) { + guard let data = try? JSONEncoder().encode(projects) else { return } + defaults.set(data, forKey: key) + } +} diff --git a/Sources/CodeEditorLSP/JSONRPCTransport.swift b/Sources/CodeEditorLSP/JSONRPCTransport.swift index 2eb5b0c..1bb55b2 100644 --- a/Sources/CodeEditorLSP/JSONRPCTransport.swift +++ b/Sources/CodeEditorLSP/JSONRPCTransport.swift @@ -70,6 +70,21 @@ public actor JSONRPCTransport { process.currentDirectoryURL = workingDirectory } + var env = ProcessInfo.processInfo.environment + let currentPath = env["PATH"] ?? "" + let home = FileManager.default.homeDirectoryForCurrentUser.path + let extraPaths = [ + "\(home)/go/bin", + "/usr/local/go/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin" + ] + let newPath = (extraPaths + [currentPath]).filter { !$0.isEmpty }.joined(separator: ":") + env["PATH"] = newPath + process.environment = env + let input = Pipe() let output = Pipe() process.standardInput = input diff --git a/Sources/CodeEditorLSP/LSPClient.swift b/Sources/CodeEditorLSP/LSPClient.swift index 9d89511..db5db54 100644 --- a/Sources/CodeEditorLSP/LSPClient.swift +++ b/Sources/CodeEditorLSP/LSPClient.swift @@ -7,12 +7,20 @@ public struct LanguageServerDescriptor: Sendable, Equatable { public let command: [String] /// Language identifiers this server handles, matching `Language.identifier`. public let languageIdentifiers: Set - - public init(id: String, displayName: String, command: [String], languageIdentifiers: Set) { + public let initializationOptions: GoplsInitializationOptions? + + public init( + id: String, + displayName: String, + command: [String], + languageIdentifiers: Set, + initializationOptions: GoplsInitializationOptions? = nil + ) { self.id = id self.displayName = displayName self.command = command self.languageIdentifiers = languageIdentifiers + self.initializationOptions = initializationOptions } } @@ -28,6 +36,18 @@ public enum LanguageServerRegistry { return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } } + /// Path to `gopls`, if installed. + public static var goplsPath: String? { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let candidates = [ + "\(home)/go/bin/gopls", + "/usr/local/go/bin/gopls", + "/opt/homebrew/bin/gopls", + "/usr/local/bin/gopls", + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + public static func descriptor(for language: String) -> LanguageServerDescriptor? { switch language { case "swift": @@ -38,13 +58,22 @@ public enum LanguageServerRegistry { command: [path], languageIdentifiers: ["swift"] ) + case "go": + guard let path = goplsPath else { return nil } + return LanguageServerDescriptor( + id: "gopls", + displayName: "gopls", + command: [path, "serve"], + languageIdentifiers: ["go"], + initializationOptions: GoplsInitializationOptions() + ) default: return nil } } public static var allDescriptors: [LanguageServerDescriptor] { - ["swift"].compactMap { descriptor(for: $0) } + ["swift", "go"].compactMap { descriptor(for: $0) } } } @@ -113,7 +142,8 @@ public final class LSPClient { capabilities: .default, workspaceFolders: rootURL.map { [WorkspaceFolder(uri: $0.absoluteString, name: $0.lastPathComponent)] - } + }, + initializationOptions: descriptor.initializationOptions ), as: InitializeResult.self ) diff --git a/Sources/CodeEditorLSP/Types/LSPTypes.swift b/Sources/CodeEditorLSP/Types/LSPTypes.swift index aaf6928..cc6690f 100644 --- a/Sources/CodeEditorLSP/Types/LSPTypes.swift +++ b/Sources/CodeEditorLSP/Types/LSPTypes.swift @@ -433,22 +433,62 @@ public struct ServerCapabilities: Decodable, Sendable { // MARK: - Requests +public struct GoplsInitializationOptions: Codable, Equatable, Sendable { + public var staticcheck: Bool + public var completeUnimported: Bool + public var deepCompletion: Bool + public var analyses: [String: Bool] + + public init( + staticcheck: Bool = true, + completeUnimported: Bool = true, + deepCompletion: Bool = true, + analyses: [String: Bool] = [ + "unusedparams": true, + "shadow": true, + "nilness": true, + "fieldalignment": false + ] + ) { + self.staticcheck = staticcheck + self.completeUnimported = completeUnimported + self.deepCompletion = deepCompletion + self.analyses = analyses + } +} + public struct InitializeParams: Encodable, Sendable { public var processId: Int? public var rootURI: String? public var capabilities: ClientCapabilities public var workspaceFolders: [WorkspaceFolder]? + public var initializationOptions: GoplsInitializationOptions? public init( processId: Int? = Int(ProcessInfo.processInfo.processIdentifier), rootURI: String? = nil, capabilities: ClientCapabilities = .default, - workspaceFolders: [WorkspaceFolder]? = nil + workspaceFolders: [WorkspaceFolder]? = nil, + initializationOptions: GoplsInitializationOptions? = nil ) { self.processId = processId self.rootURI = rootURI self.capabilities = capabilities self.workspaceFolders = workspaceFolders + self.initializationOptions = initializationOptions + } + + enum CodingKeys: String, CodingKey { + case processId, rootURI, capabilities, workspaceFolders, initializationOptions + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(processId, forKey: .processId) + try container.encodeIfPresent(rootURI, forKey: .rootURI) + try container.encode(capabilities, forKey: .capabilities) + try container.encodeIfPresent(workspaceFolders, forKey: .workspaceFolders) + try container.encodeIfPresent(initializationOptions, forKey: .initializationOptions) } } diff --git a/Sources/CodeEditorTerminal/TerminalSession.swift b/Sources/CodeEditorTerminal/TerminalSession.swift index c9476b7..e4668c9 100644 --- a/Sources/CodeEditorTerminal/TerminalSession.swift +++ b/Sources/CodeEditorTerminal/TerminalSession.swift @@ -79,6 +79,18 @@ public final class TerminalSession { } var environment = ProcessInfo.processInfo.environment + let currentPath = environment["PATH"] ?? "" + let home = FileManager.default.homeDirectoryForCurrentUser.path + let extraPaths = [ + "\(home)/go/bin", + "/usr/local/go/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin" + ] + let newPath = (extraPaths + [currentPath]).filter { !$0.isEmpty }.joined(separator: ":") + environment["PATH"] = newPath for (key, value) in self.environment { environment[key] = value } diff --git a/Sources/CodeEditorUI/AppState.swift b/Sources/CodeEditorUI/AppState.swift index 82fe659..45956dd 100644 --- a/Sources/CodeEditorUI/AppState.swift +++ b/Sources/CodeEditorUI/AppState.swift @@ -40,6 +40,9 @@ public final class AppState { public var quickOpenQuery = "" public private(set) var quickOpenResults: [QuickOpenMatch] = [] + // Recent Projects + public var recentProjects: [RecentProject] = [] + /// Terminal panel height in points, persisted. Drag-resized, never fixed. public var terminalHeight: Double { didSet { @@ -52,7 +55,7 @@ public final class AppState { private static let terminalHeightDefault: Double = 220 private static let terminalHeightRange: ClosedRange = 120...600 - public init(restoreSession: Bool = true) { + public init(restoreSession: Bool = false) { let fileSystemManager = FileSystemManager() let themeManager = ThemeManager() let editorState = EditorState() @@ -65,6 +68,7 @@ public final class AppState { self.editorState = editorState self.terminal = TerminalSession() self.quickOpenIndex = QuickOpenIndex() + self.recentProjects = RecentProjectsStore.load() let stored = UserDefaults.standard.double(forKey: Self.terminalHeightKey) self.terminalHeight = @@ -103,9 +107,21 @@ public final class AppState { terminal.workingDirectory = url terminal.clear() quickOpenIndex.reindex(root: url) + RecentProjectsStore.record(url: url) + recentProjects = RecentProjectsStore.load() persistSession() } + public func removeRecentProject(_ project: RecentProject) { + RecentProjectsStore.remove(url: project.url) + recentProjects = RecentProjectsStore.load() + } + + public func clearRecentProjects() { + RecentProjectsStore.clear() + recentProjects = [] + } + // MARK: - Session /// What a relaunch would restore right now. @@ -262,6 +278,11 @@ public final class AppState { } public func performSave(_ document: CodeEditorCore.Document, to url: URL? = nil) async { + if editorState.formatOnSave && document.language.identifier == "go" { + if let formatted = GoFormatter.format(document.content), formatted != document.content { + document.setContent(formatted) + } + } do { if let url { try await document.save(to: url) @@ -396,6 +417,53 @@ public final class AppState { themeManager.setTheme(named: name) } + // MARK: - Development Actions + + public func formatActiveDocument() { + guard let document = documentManager.activeDocument else { return } + if document.language.identifier == "go" { + if let formatted = GoFormatter.format(document.content), formatted != document.content { + document.setContent(formatted) + } + } + } + + public func runProject() { + if !isTerminalVisible { + isTerminalVisible = true + } + guard let root = fileSystemManager.rootURL else { return } + terminal.workingDirectory = root + if !terminal.isConnected { + terminal.connect() + } + let goMod = root.appendingPathComponent("go.mod") + let isGo = FileManager.default.fileExists(atPath: goMod.path) || documentManager.activeDocument?.language.identifier == "go" + if isGo { + terminal.submit("go run .") + } else { + terminal.submit("make run") + } + } + + public func testProject() { + if !isTerminalVisible { + isTerminalVisible = true + } + guard let root = fileSystemManager.rootURL else { return } + terminal.workingDirectory = root + if !terminal.isConnected { + terminal.connect() + } + let goMod = root.appendingPathComponent("go.mod") + let isGo = FileManager.default.fileExists(atPath: goMod.path) || documentManager.activeDocument?.language.identifier == "go" + if isGo { + terminal.submit("go test -v ./...") + } else { + terminal.submit("make test") + } + } + // MARK: - Language servers /// One lazily-started server per language identifier for the current workspace. diff --git a/Sources/CodeEditorUI/Sources/Chrome/EmptyStateView.swift b/Sources/CodeEditorUI/Sources/Chrome/EmptyStateView.swift index 1eef464..838afc2 100644 --- a/Sources/CodeEditorUI/Sources/Chrome/EmptyStateView.swift +++ b/Sources/CodeEditorUI/Sources/Chrome/EmptyStateView.swift @@ -1,13 +1,12 @@ +import AppKit import CodeEditorCore import CodeEditorThemes import SwiftUI /// The window's single empty state, shown when no document is active. /// -/// Adapts to what is missing: with a workspace open it points at the sidebar, which -/// is where the action is; with nothing open it offers the two ways to start. The -/// previous UI showed both messages at once, one per pane, with different -/// capitalisation and duplicate buttons. +/// Adapts to what is missing: with a workspace open it points at the sidebar; +/// with nothing open it presents the Welcome Hub with quick actions and Recent Projects. public struct EmptyStateView: View { let appState: AppState let onOpenFolder: () -> Void @@ -38,28 +37,29 @@ public struct EmptyStateView: View { .font(Typography.emptyBody) .foregroundStyle(appState.theme.secondaryText.color) } else { - Text("Open a Folder") + Text("Code Editor") .font(Typography.emptyTitle) .foregroundStyle(appState.theme.text.color) - Text("Browse and edit a project, or start from an empty file.") + Text("Open a folder to start developing, or pick from recent projects.") .font(Typography.emptyBody) .foregroundStyle(appState.theme.secondaryText.color) actions + + recentProjectsSection } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(appState.theme.background.color) } - /// The app mark: the code glyph on a soft accent plate. One memorable element, - /// everything else stays quiet. + /// The app mark: the code glyph on a soft accent plate. private var mark: some View { Image(systemName: "chevron.left.forwardslash.chevron.right") .font(.system(size: 32, weight: .medium)) .foregroundStyle(appState.theme.chrome.accent.color) - .frame(width: 72, height: 72) + .frame(width: 64, height: 64) .background( RoundedRectangle(cornerRadius: Metrics.Radius.panel, style: .continuous) .fill(appState.theme.chrome.accent.color.opacity(0.12)) @@ -84,4 +84,136 @@ public struct EmptyStateView: View { } .padding(.top, Metrics.Space.compact) } + + private var recentProjectsSection: some View { + VStack(alignment: .leading, spacing: Metrics.Space.compact) { + HStack { + Text("Recent Projects") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(appState.theme.secondaryText.color) + Spacer() + if !appState.recentProjects.isEmpty { + Button("Clear") { + appState.clearRecentProjects() + } + .buttonStyle(.plain) + .font(.system(size: 11)) + .foregroundStyle(appState.theme.secondaryText.color) + } + } + .padding(.horizontal, Metrics.Space.compact) + + if appState.recentProjects.isEmpty { + Text("No recent projects") + .font(.system(size: 12)) + .foregroundStyle(appState.theme.secondaryText.color.opacity(0.6)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, Metrics.Space.regular) + } else { + ScrollView { + LazyVStack(spacing: 2) { + ForEach(appState.recentProjects) { project in + RecentProjectRow( + project: project, + theme: appState.theme, + onSelect: { + appState.openFolder(at: project.url) + }, + onRemove: { + appState.removeRecentProject(project) + } + ) + } + } + } + .frame(maxHeight: 220) + } + } + .padding(Metrics.Space.regular) + .frame(width: 440) + .background( + RoundedRectangle(cornerRadius: Metrics.Radius.panel, style: .continuous) + .fill(appState.theme.chrome.overlayBackground.color.opacity(0.4)) + ) + .overlay( + RoundedRectangle(cornerRadius: Metrics.Radius.panel, style: .continuous) + .strokeBorder(appState.theme.chrome.border.color.opacity(0.3), lineWidth: 1) + ) + } +} + +// MARK: - RecentProjectRow + +private struct RecentProjectRow: View { + let project: RecentProject + let theme: Theme + let onSelect: () -> Void + let onRemove: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: onSelect) { + HStack(spacing: Metrics.Space.compact) { + Image(systemName: "folder.fill") + .font(.system(size: 14)) + .foregroundStyle(theme.chrome.accent.color) + + VStack(alignment: .leading, spacing: 2) { + Text(project.name) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(theme.text.color) + .lineLimit(1) + + Text(displayPath(for: project.url)) + .font(.system(size: 11)) + .foregroundStyle(theme.secondaryText.color) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + Text(relativeDateString(for: project.lastOpened)) + .font(.system(size: 10)) + .foregroundStyle(theme.secondaryText.color.opacity(0.8)) + } + .padding(.horizontal, Metrics.Space.regular) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: Metrics.Radius.row, style: .continuous) + .fill(isHovered ? theme.chrome.rowHover.color : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .contextMenu { + Button("Open") { onSelect() } + Button("Reveal in Finder") { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: project.url.path) + } + Button("Copy Path") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(project.url.path, forType: .string) + } + Divider() + Button("Remove from Recents") { onRemove() } + } + } + + private func displayPath(for url: URL) -> String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let path = url.path + if path.hasPrefix(home) { + return "~" + path.dropFirst(home.count) + } + return path + } + + private func relativeDateString(for date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } } diff --git a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift index 6fa3131..b5f7852 100644 --- a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift +++ b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift @@ -159,8 +159,9 @@ public struct CodeEditorView: View { var offset = 0 for index in 0.. some View { + HStack(alignment: .top, spacing: Metrics.Space.compact) { + Image(systemName: iconName(for: diag.severity)) + .foregroundStyle(iconColor(for: diag.severity)) + .font(.system(size: 13, weight: .semibold)) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(severityTitle(for: diag.severity)) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(iconColor(for: diag.severity)) + + if let source = diag.source, !source.isEmpty { + Text(source) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(theme.secondaryText.color) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(theme.chrome.border.color.opacity(0.3)) + ) + } + } + + Text(diag.message) + .font(.system(size: 12)) + .foregroundStyle(theme.text.color) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + .padding(.vertical, 2) + } + + private func severityTitle(for severity: DocumentDiagnosticSeverity) -> String { + switch severity { + case .error: return "Error" + case .warning: return "Warning" + case .information: return "Information" + case .hint: return "Hint" + } + } + + private func iconName(for severity: DocumentDiagnosticSeverity) -> String { + switch severity { + case .error: return "xmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .information: return "info.circle.fill" + case .hint: return "lightbulb.fill" + } + } + + private func iconColor(for severity: DocumentDiagnosticSeverity) -> Color { + switch severity { + case .error: return theme.semantic.danger.color + case .warning: return theme.semantic.warning.color + case .information: return theme.semantic.success.color + case .hint: return theme.secondaryText.color + } + } +} diff --git a/Sources/CodeEditorUI/Sources/Editor/EditorState.swift b/Sources/CodeEditorUI/Sources/Editor/EditorState.swift index 9ba3bf7..f40b999 100644 --- a/Sources/CodeEditorUI/Sources/Editor/EditorState.swift +++ b/Sources/CodeEditorUI/Sources/Editor/EditorState.swift @@ -17,6 +17,7 @@ public final class EditorState { public var showMinimap = false public var showFoldingRibbon = true public var showInvisibles = false + public var formatOnSave = true // Transient state for the active document public private(set) var selectedLines: Set = [] @@ -105,7 +106,6 @@ public final class EditorState { let selection = document.selection let start = document.lineAndColumn(for: selection.start) - let end = document.lineAndColumn(for: selection.end) if let start { caretLine = start.line diff --git a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift index f7eff76..57f0ede 100644 --- a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift +++ b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift @@ -125,6 +125,7 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, func prepareCoordinator(controller: TextViewController) { self.controller = controller + installMouseMonitor() Task { await openIfNeeded() renderDiagnosticUnderlines(document.diagnostics) @@ -132,10 +133,15 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, } func destroy() { + removeMouseMonitor() controller = nil clearDiagnosticUnderlines() } + func controllerDidDisappear(controller: TextViewController) { + closeDiagnosticPopover() + } + // MARK: - Tab / workspace switch /// Switch the coordinator to a different document (tab switch): closes the previous @@ -143,6 +149,7 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, func switchTo(document: CodeEditorCore.Document) async { guard self.document !== document else { return } + closeDiagnosticPopover() if let uri = currentURI, let client = await appState?.languageServer(for: document.language.identifier), client.isReady @@ -209,6 +216,7 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, /// Forward a keystroke to the server, debounced so a fast typist sends one /// `didChange` per burst instead of one per character. func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { + closeDiagnosticPopover() guard controller != nil else { return } didChangeTask?.cancel() didChangeTask = Task { @@ -428,44 +436,214 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, return DocumentDiagnostic( range: TextSelection(start: start, end: end), severity: severity, - message: diag.message + message: diag.message, + source: diag.source ) } } - /// Remove previously-rendered diagnostic underlines and paint the new set. - /// Uses `.underlineStyle` + `.underlineColor` attributes on the text storage. - /// Wavy squiggles are drawn by the upstream gutter/renderer; a solid colored - /// underline is the MVP stand-in for the familiar compiler-error look. - /// Gutter markers are a follow-up — the upstream `GutterView` has no marker API in 0.15. + // MARK: - Diagnostic Underlines (EmphasisManager) + + static let diagnosticEmphasisGroupId = "lspDiagnostics" + private func renderDiagnosticUnderlines(_ diagnostics: [DocumentDiagnostic]) { - clearDiagnosticUnderlines() + guard let tv = controller?.textView else { + renderedDiagnostics = diagnostics + return + } - guard let storage = controller?.textView.textStorage else { + guard let emphasisManager = tv.emphasisManager else { renderedDiagnostics = diagnostics return } + let docLength = (tv.string as NSString).length + guard docLength > 0 else { + emphasisManager.removeEmphases(for: Self.diagnosticEmphasisGroupId) + renderedDiagnostics = [] + return + } + + let nsString = tv.string as NSString + var emphases: [Emphasis] = [] + for diag in diagnostics { guard let range = document.nsRange(from: diag.range.start, to: diag.range.end) else { continue } + guard range.location < docLength else { continue } + + var effectiveRange = range + if effectiveRange.length == 0 { + effectiveRange = expandedTokenRange(at: effectiveRange.location, in: nsString, docLength: docLength) + } else if effectiveRange.location + effectiveRange.length > docLength { + effectiveRange.length = docLength - effectiveRange.location + } + + guard effectiveRange.length > 0 else { continue } + let color = diagnosticColor(diag.severity) - storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range) - storage.addAttribute(.underlineColor, value: color, range: range) + emphases.append(Emphasis(range: effectiveRange, style: .underline(color: color))) } + + emphasisManager.replaceEmphases(emphases, for: Self.diagnosticEmphasisGroupId) renderedDiagnostics = diagnostics } - /// Revert the text storage to its un-underlined state for the last rendered set. private func clearDiagnosticUnderlines() { - guard let storage = controller?.textView.textStorage else { return } + guard let tv = controller?.textView else { return } + tv.emphasisManager?.removeEmphases(for: Self.diagnosticEmphasisGroupId) + renderedDiagnostics.removeAll() + } - for diag in renderedDiagnostics { - if let range = document.nsRange(from: diag.range.start, to: diag.range.end) { - storage.removeAttribute(.underlineStyle, range: range) - storage.removeAttribute(.underlineColor, range: range) + private func expandedTokenRange(at location: Int, in string: NSString, docLength: Int) -> NSRange { + guard location < docLength else { + return NSRange(location: max(0, docLength - 1), length: min(1, docLength)) + } + let underscore: UInt16 = 95 + func isIdentChar(_ code: UInt16) -> Bool { + if code == underscore { return true } + guard let scalar = UnicodeScalar(code) else { return false } + return CharacterSet.alphanumerics.contains(scalar) + } + let char = string.character(at: location) + if !isIdentChar(char) { + return NSRange(location: location, length: 1) + } + var start = location + while start > 0 { + let prev = string.character(at: start - 1) + if isIdentChar(prev) { + start -= 1 + } else { + break } } - renderedDiagnostics.removeAll() + var end = location + while end < docLength { + let next = string.character(at: end) + if isIdentChar(next) { + end += 1 + } else { + break + } + } + return NSRange(location: start, length: max(1, end - start)) + } + + // MARK: - Diagnostic Mouse Hover Popover + + private var mouseMonitor: Any? + private var activePopover: NSPopover? + private var hoveredDiagnosticRange: NSRange? + + private func installMouseMonitor() { + guard mouseMonitor == nil else { return } + mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) { [weak self] event in + self?.handleMouseMoved(event) + return event + } + } + + private func removeMouseMonitor() { + if let monitor = mouseMonitor { + NSEvent.removeMonitor(monitor) + mouseMonitor = nil + } + closeDiagnosticPopover() + } + + private func handleMouseMoved(_ event: NSEvent) { + guard let controller, let tv = controller.textView else { return } + guard let window = tv.window, event.window === window else { + closeDiagnosticPopover() + return + } + + let windowPoint = event.locationInWindow + let viewPoint = tv.convert(windowPoint, from: nil) + guard tv.bounds.contains(viewPoint) else { + closeDiagnosticPopover() + return + } + + let stringLength = (tv.string as NSString).length + guard stringLength > 0 else { + closeDiagnosticPopover() + return + } + + guard let offset = tv.layoutManager.textOffsetAtPoint(viewPoint), + offset < stringLength + else { + closeDiagnosticPopover() + return + } + + let matchingDiagnostics = document.diagnostics.filter { diag in + guard let range = document.nsRange(from: diag.range.start, to: diag.range.end) else { return false } + if range.contains(offset) || (range.length == 0 && range.location == offset) { + return true + } + if let diagLine = document.lineAndColumn(for: range.location)?.line, + let currentLine = document.lineAndColumn(for: offset)?.line, + diagLine == currentLine { + return true + } + return false + } + + guard !matchingDiagnostics.isEmpty else { + closeDiagnosticPopover() + return + } + + guard let firstDiag = matchingDiagnostics.first, + let anchorRange = document.nsRange(from: firstDiag.range.start, to: firstDiag.range.end) + else { + closeDiagnosticPopover() + return + } + + if hoveredDiagnosticRange == anchorRange && activePopover?.isShown == true { + return + } + + showDiagnosticPopover(for: matchingDiagnostics, anchorRange: anchorRange, at: viewPoint, in: tv) + } + + private func showDiagnosticPopover( + for diagnostics: [DocumentDiagnostic], + anchorRange: NSRange, + at point: CGPoint, + in tv: TextView + ) { + closeDiagnosticPopover() + + hoveredDiagnosticRange = anchorRange + + let popover = NSPopover() + popover.behavior = .transient + popover.animates = true + let popoverView = DiagnosticPopoverView(diagnostics: diagnostics, theme: theme) + let hostingController = NSHostingController(rootView: popoverView) + popover.contentViewController = hostingController + + let anchorRect: NSRect + if let rect = tv.layoutManager.rectsFor(range: anchorRange).first { + anchorRect = rect + } else { + anchorRect = NSRect(x: point.x, y: point.y, width: 1, height: 1) + } + + popover.show(relativeTo: anchorRect, of: tv, preferredEdge: .maxY) + self.activePopover = popover + } + + private func closeDiagnosticPopover() { + if let popover = activePopover { + popover.close() + activePopover = nil + } + hoveredDiagnosticRange = nil } /// The color for a diagnostic severity, drawn from `Theme.semantic` so the palette diff --git a/Tests/CodeEditorTests/GoDevelopmentTests.swift b/Tests/CodeEditorTests/GoDevelopmentTests.swift new file mode 100644 index 0000000..9383c9e --- /dev/null +++ b/Tests/CodeEditorTests/GoDevelopmentTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import CodeEditorCore +@testable import CodeEditorLSP + +@Suite("Go Development Adaptation") +struct GoDevelopmentTests { + @Test("LanguageServerRegistry provides gopls descriptor with staticcheck and analyses") + func goplsDescriptor() { + guard let descriptor = LanguageServerRegistry.descriptor(for: "go") else { + // gopls might not be installed in all test environments; if present, verify configuration + return + } + #expect(descriptor.id == "gopls") + #expect(descriptor.languageIdentifiers.contains("go")) + #expect(descriptor.command.contains("serve")) + #expect(descriptor.initializationOptions?.staticcheck == true) + #expect(descriptor.initializationOptions?.completeUnimported == true) + #expect(descriptor.initializationOptions?.analyses["shadow"] == true) + } + + @Test("InitializeParams encodes gopls initialization options properly in JSON") + func initializeParamsEncoding() throws { + let opts = GoplsInitializationOptions( + staticcheck: true, + completeUnimported: true, + deepCompletion: true, + analyses: ["unusedparams": true] + ) + let params = InitializeParams( + processId: 1234, + rootURI: "file:///workspace", + capabilities: .default, + workspaceFolders: [WorkspaceFolder(uri: "file:///workspace", name: "workspace")], + initializationOptions: opts + ) + + let data = try JSONEncoder().encode(params) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json.contains("\"staticcheck\":true")) + #expect(json.contains("\"completeUnimported\":true")) + #expect(json.contains("\"unusedparams\":true")) + } + + @Test("GoFormatter formats valid Go code when gofmt is present") + func gofmtValidCode() { + guard GoFormatter.gofmtPath != nil else { return } + let unformatted = "package main\n\nfunc main() {\nprintln( \"hello\" )\n}\n" + let formatted = GoFormatter.format(unformatted) + #expect(formatted != nil) + #expect(formatted?.contains("println(\"hello\")") == true) + } + + @Test("GoFormatter returns nil on Go syntax errors without crashing") + func gofmtSyntaxError() { + guard GoFormatter.gofmtPath != nil else { return } + let broken = "package main\nfunc broken( {\n" + let formatted = GoFormatter.format(broken) + #expect(formatted == nil) + } +} diff --git a/Tests/CodeEditorTests/RecentProjectsStoreTests.swift b/Tests/CodeEditorTests/RecentProjectsStoreTests.swift new file mode 100644 index 0000000..4167aef --- /dev/null +++ b/Tests/CodeEditorTests/RecentProjectsStoreTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import CodeEditorCore + +@Suite("RecentProjectsStore") +struct RecentProjectsStoreTests { + private func makeIsolatedDefaults() -> UserDefaults { + let name = "test.recentProjects.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test("empty defaults returns empty list") + func emptyDefaults() { + let defaults = makeIsolatedDefaults() + let recents = RecentProjectsStore.load(defaults: defaults) + #expect(recents.isEmpty) + } + + @Test("records projects in MRU order") + func recordsMRU() { + let defaults = makeIsolatedDefaults() + let tmp = FileManager.default.temporaryDirectory + let folderA = tmp.appendingPathComponent("ProjectA") + let folderB = tmp.appendingPathComponent("ProjectB") + try? FileManager.default.createDirectory(at: folderA, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: folderB, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: folderA) + try? FileManager.default.removeItem(at: folderB) + } + + RecentProjectsStore.record(url: folderA, defaults: defaults) + RecentProjectsStore.record(url: folderB, defaults: defaults) + + var loaded = RecentProjectsStore.load(defaults: defaults) + #expect(loaded.count == 2) + #expect(loaded.first?.name == "ProjectB") + + // Re-recording A moves it to the top + RecentProjectsStore.record(url: folderA, defaults: defaults) + loaded = RecentProjectsStore.load(defaults: defaults) + #expect(loaded.count == 2) + #expect(loaded.first?.name == "ProjectA") + } + + @Test("removes project from recents") + func removeProject() { + let defaults = makeIsolatedDefaults() + let tmp = FileManager.default.temporaryDirectory + let folder = tmp.appendingPathComponent("ProjectToRemove") + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: folder) + } + + RecentProjectsStore.record(url: folder, defaults: defaults) + #expect(RecentProjectsStore.load(defaults: defaults).count == 1) + + RecentProjectsStore.remove(url: folder, defaults: defaults) + #expect(RecentProjectsStore.load(defaults: defaults).isEmpty) + } +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 4d9cdc9..b1a8267 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -499,6 +499,41 @@ does not clip view boundaries by default, and later views draw on top of earlier --- +## 28. LSP `Position` and `NSRange` are UTF-16 code units, not UTF-8 byte offsets + +`Document.swift` previously mapped lines and columns via `String.utf8`. The LSP specification +strictly defines `Position.character` as UTF-16 code units from the start of the line, and Cocoa +`NSString`/`NSRange` and `CodeEditTextView` offsets are also UTF-16 code units. Using UTF-8 byte +slices drifted whenever code contained non-ASCII characters, multi-byte runes, or tabs, leading +to misaligned diagnostic underlines and incorrect caret offsets. All offset and range mapping in +`Document.swift` and `CodeEditorView.swift` now calculates using `.utf16.count`. In addition, +zero-length diagnostics are token-expanded via identifier boundary scans so entire tokens are +underlined instead of single-character ticks. + +--- + +## 29. Default session restoration cycles files on launch; prefer Welcome Hub with Recent Projects + +Restoring all open files and active documents simultaneously on launch triggered rapid `activate()` +cycles and visual flickers when tabs with identical or similar names reloaded before the workspace +finished initializing. Launching with `restoreSession = false` cleanly lands the user on the +`EmptyStateView` Welcome Hub. The Welcome Hub persists and displays an MRU `RecentProjectsStore` with +one-click folder opening, middle-truncated path display, Finder reveal, and quick action buttons. + +--- + +## 30. Go development support requires environment PATH augmentation for GUI subprocesses + +Subprocesses spawned from macOS GUI application bundles (`.app`) run with a minimal system `PATH` +(`/usr/bin:/bin:/usr/sbin:/sbin`), omitting `/usr/local/go/bin`, `/opt/homebrew/bin`, and `~/go/bin`. +Both `JSONRPCTransport` (for `gopls`) and `TerminalSession` (for `go run` and `go test`) must +explicitly inject these Go search paths into `process.environment["PATH"]`. In addition, `gopls` +must be initialized with `staticcheck: true`, analyses, and unimported completions enabled, and +`GoFormatter` invokes `gofmt` safely over standard input/output with non-destructive fallback if +syntax errors exist. + +--- + ## Quick reference | Question | Answer | From 543385a8437a22879f4a6aba47b5f582c5028a5a Mon Sep 17 00:00:00 2001 From: yan Date: Mon, 12 Oct 2026 15:49:37 +0300 Subject: [PATCH 8/9] fix(editor): resolve tab dirty indicator, lsp completion triggers, and live diagnostics - Synchronously sync document edits in DocumentTextCoordinator to guarantee immediate tab dirty indicator - Extract TabItemView with @Bindable document observation in TabBarView - Add .id(document.id) to CodeEditorView to avoid stale tab coordinators and bindings - Support trigger characters and range-based offset resolution for LSP code suggestions - Dispatch LSP onDiagnosticsChanged to MainActor for real-time continuous linting and underlining --- .../Sources/MainWindowView.swift | 1 + .../Sources/Chrome/TabBarView.swift | 59 +++++++++++-------- .../Sources/Editor/CodeEditorView.swift | 34 ++++++----- .../Editor/DocumentTextCoordinator.swift | 36 ++++++----- .../Editor/LSPTextViewCoordinator.swift | 40 ++++++++----- .../DocumentDiagnosticsTests.swift | 27 +++++++++ 6 files changed, 127 insertions(+), 70 deletions(-) diff --git a/Sources/CodeEditorApp/Sources/MainWindowView.swift b/Sources/CodeEditorApp/Sources/MainWindowView.swift index 2527b9d..092f542 100644 --- a/Sources/CodeEditorApp/Sources/MainWindowView.swift +++ b/Sources/CodeEditorApp/Sources/MainWindowView.swift @@ -192,6 +192,7 @@ struct MainWindowView: View { editorState: appState.editorState, appState: appState ) + .id(document.id) .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() } else { diff --git a/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift b/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift index fa8ca59..63b70ab 100644 --- a/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift +++ b/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift @@ -20,7 +20,7 @@ public struct TabBarView: View { let onClose: (CodeEditorCore.Document) -> Void let onNew: () -> Void - @State private var hoveredDocument: CodeEditorCore.Document? + @State private var hoveredDocumentId: UUID? public init( documents: [CodeEditorCore.Document], @@ -47,7 +47,19 @@ public struct TabBarView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 2) { ForEach(documents) { document in - tab(for: document) + TabItemView( + document: document, + isActive: document.id == activeDocument?.id, + isHovered: hoveredDocumentId == document.id, + theme: theme, + onSelect: { onSelect(document) }, + onClose: { onClose(document) }, + onHover: { isHovered in + withAnimation(.easeOut(duration: 0.1)) { + hoveredDocumentId = isHovered ? document.id : (hoveredDocumentId == document.id ? nil : hoveredDocumentId) + } + } + ) } } .padding(.horizontal, Metrics.Space.compact) @@ -96,14 +108,21 @@ public struct TabBarView: View { .foregroundStyle(.secondary) .help("New File (⌘N)") } +} - // MARK: - Tabs +// MARK: - TabItemView - private func tab(for document: CodeEditorCore.Document) -> some View { - let isActive = document === activeDocument - let isHovered = hoveredDocument?.id == document.id +private struct TabItemView: View { + @Bindable var document: CodeEditorCore.Document + let isActive: Bool + let isHovered: Bool + let theme: Theme + let onSelect: () -> Void + let onClose: () -> Void + let onHover: (Bool) -> Void - return HStack(spacing: Metrics.Space.compact) { + var body: some View { + HStack(spacing: Metrics.Space.compact) { if document.isModified { Circle() .fill(isActive ? theme.chrome.accent.color : theme.secondaryText.color) @@ -118,39 +137,30 @@ public struct TabBarView: View { .lineLimit(1) .truncationMode(.middle) - closeButton(for: document, isActive: isActive, isHovered: isHovered) + closeButton } .padding(.leading, Metrics.Space.regular) .padding(.trailing, isActive || isHovered ? Metrics.Space.compact : Metrics.Space.regular + 8) .padding(.vertical, Metrics.Space.compact) .foregroundStyle(isActive ? theme.text.color : theme.secondaryText.color) - .background(tabBackground(isActive: isActive, isHovered: isHovered)) + .background(tabBackground) .clipShape( UnevenRoundedRectangle( topLeadingRadius: Metrics.Radius.row, topTrailingRadius: Metrics.Radius.row ) ) - .onHover { hovering in - withAnimation(.easeOut(duration: 0.1)) { - hoveredDocument = hovering ? document : (hoveredDocument?.id == document.id ? nil : hoveredDocument) - } + .onHover { isHovering in + onHover(isHovering) } .contentShape(Rectangle()) - .onTapGesture { onSelect(document) } + .onTapGesture { onSelect() } .help(document.name) } - /// The close affordance. Always present so layout never shifts; invisible on - /// unhovered inactive tabs so the strip stays quiet, but never replaced by the - /// dirty dot. - private func closeButton( - for document: CodeEditorCore.Document, - isActive: Bool, - isHovered: Bool - ) -> some View { + private var closeButton: some View { Button { - onClose(document) + onClose() } label: { Image(systemName: "xmark") .font(.system(size: 8, weight: .bold)) @@ -169,9 +179,8 @@ public struct TabBarView: View { } @ViewBuilder - private func tabBackground(isActive: Bool, isHovered: Bool) -> some View { + private var tabBackground: some View { if isActive { - // Matches the canvas, so the tab becomes the top of the editor. theme.background.color } else if isHovered { theme.chrome.tabHoverBackground.color diff --git a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift index b5f7852..fb413a6 100644 --- a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift +++ b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift @@ -1,12 +1,14 @@ -import CodeEditLanguages -import CodeEditSourceEditor -import CodeEditTextView import CodeEditorCore import CodeEditorThemes +import CodeEditSourceEditor +import CodeEditTextView import SwiftUI -/// The editing surface: a tree-sitter highlighted source editor with its own line -/// number gutter, bracket emphasis and undo stack. +/// Wraps `SourceEditor` from CodeEditSourceEditor, bridging document state and +/// configuration from `AppState` and `EditorState`. +/// +/// Selection flows both ways: the document holds the source of truth, `SourceEditor` +/// updates its cursor on load, and user movements write back via `onChange`. public struct CodeEditorView: View { @Bindable var document: CodeEditorCore.Document let theme: Theme @@ -14,7 +16,7 @@ public struct CodeEditorView: View { let appState: AppState @State private var sourceEditorState = SourceEditorState() - @State private var textCoordinator = DocumentTextCoordinator() + @State private var textCoordinator: DocumentTextCoordinator @State private var lspCoordinator: LSPTextViewCoordinator public init( @@ -27,6 +29,7 @@ public struct CodeEditorView: View { self.theme = theme self.editorState = editorState self.appState = appState + _textCoordinator = State(initialValue: DocumentTextCoordinator(document: document)) _lspCoordinator = State( initialValue: LSPTextViewCoordinator( document: document, theme: theme, appState: appState @@ -53,11 +56,13 @@ public struct CodeEditorView: View { // SourceEditor does not diff text on update — only language, configuration // and highlight providers. Without this, switching tabs keeps the previous // document on screen. + textCoordinator.updateDocument(document) textCoordinator.reload(text: document.content) Task { await lspCoordinator.switchTo(document: document) } editorState.syncSelection(of: document) } .task { + textCoordinator.updateDocument(document) editorState.syncSelection(of: document) await lspCoordinator.switchTo(document: document) } @@ -100,7 +105,8 @@ public struct CodeEditorView: View { showReformattingGuide: false, showFoldingRibbon: editorState.showFoldingRibbon, invisibleCharactersConfiguration: invisibles, - warningCharacters: Self.warningCharacters + warningCharacters: Self.warningCharacters, + codeSuggestionTriggerCharacters: [".", ":", "/", "@", "$", "_"] ) ) } @@ -130,7 +136,6 @@ public struct CodeEditorView: View { } } - /// Two-way binding between the editor and the document's content. private var textBinding: Binding { Binding( get: { document.content }, @@ -142,7 +147,7 @@ public struct CodeEditorView: View { document.undoManager as? CEUndoManager } - // MARK: - Caret tracking + // MARK: - Selection sync /// Translate the editor's 1-indexed cursor positions back into document offsets. private func syncSelection(with positions: [CursorPosition]?) { @@ -154,14 +159,11 @@ public struct CodeEditorView: View { } private func offset(for position: CursorPosition) -> Int? { + if position.range != .notFound { + return position.range.location + } let lineIndex = position.start.line - 1 guard lineIndex >= 0, lineIndex < document.lines.count else { return nil } - - var offset = 0 - for index in 0..`**. Switching -/// tabs therefore leaves the previously opened document on screen, and every tab shows the -/// same file. -/// -/// `prepareCoordinator` is the one hook that hands out the live controller, and it runs once -/// per controller, so this holds it weakly and `CodeEditorView` calls `reload(text:)` when -/// the document changes. -/// -/// This is a workaround for upstream, not a design preference. If a future version diffs the -/// text on update, this type should go away — see [MEMORY.md](../../../docs/MEMORY.md). +/// `SourceEditor` pushes text into its text view in `makeNSViewController`. +/// Conforming to `TextViewDelegate` guarantees that every keystroke or character replacement +/// immediately updates `Document.content` and sets `isModified = true` synchronously. @MainActor -final class DocumentTextCoordinator: TextViewCoordinator { +final class DocumentTextCoordinator: TextViewCoordinator, TextViewDelegate { private weak var controller: TextViewController? + private weak var document: CodeEditorCore.Document? + + init(document: CodeEditorCore.Document? = nil) { + self.document = document + } + + func updateDocument(_ document: CodeEditorCore.Document) { + self.document = document + } func prepareCoordinator(controller: TextViewController) { self.controller = controller } + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { + document?.setContent(textView.string) + } + /// Replace the editor's contents, unless it is already showing them. /// /// Safe either way: the change flows back through the binding, and `Document.setContent` diff --git a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift index 57f0ede..9cb5329 100644 --- a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift +++ b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift @@ -193,13 +193,16 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, // Diagnostics for this URI are stored in the shared client, but the callback is // single — so it always points at whichever document opened most recently. The // active document is the common case; switching tabs re-renders from the stored - // `document.diagnostics`. + // `document.diagnostics`. Dispatched to MainActor since UI underlines and Document + // mutations require main thread execution. client.onDiagnosticsChanged = { [weak self] serverURI, serverDiagnostics in - guard let self else { return } - guard self.matchesCurrentURI(serverURI) else { return } - let converted = self.convertDiagnostics(serverDiagnostics) - self.document.setDiagnostics(converted) - self.renderDiagnosticUnderlines(converted) + Task { @MainActor [weak self] in + guard let self else { return } + guard self.matchesCurrentURI(serverURI) else { return } + let converted = self.convertDiagnostics(serverDiagnostics) + self.document.setDiagnostics(converted) + self.renderDiagnosticUnderlines(converted) + } } // Render any cached diagnostics the client or document already has @@ -220,9 +223,11 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, guard controller != nil else { return } didChangeTask?.cancel() didChangeTask = Task { - try? await Task.sleep(nanoseconds: 100_000_000) + try? await Task.sleep(nanoseconds: 120_000_000) + guard !Task.isCancelled else { return } + await openIfNeeded() guard !Task.isCancelled, - let uri = self.currentURI, + let uri = self.currentURI ?? self.document.url?.absoluteString, let client = await self.appState?.languageServer(for: self.document.language.identifier), client.isReady else { return } @@ -232,7 +237,9 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, // MARK: - CodeSuggestionDelegate - func completionTriggerCharacters() -> Set { ["."] } + func completionTriggerCharacters() -> Set { + [".", ":", "/", "@", "$", "_"] + } /// Fetch completions at the caret, rank them, and present. A stale in-flight request /// is cancelled first so only the latest trigger wins. @@ -244,14 +251,12 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, let generation = completionGeneration didChangeTask?.cancel() - // Only on-disk Swift documents have a server; anything else stays quiet. guard LanguageServerRegistry.descriptor(for: document.language.identifier) != nil else { return nil } guard let uri = document.url?.absoluteString else { return nil } guard let client = await appState?.languageServer(for: document.language.identifier), client.isReady else { - // `languageServer(for:)` already set `errorMessage` to a user-facing reason. - let reason = appState?.errorMessage ?? "SourceKit-LSP could not start." + let reason = appState?.errorMessage ?? "Language server could not start." return (cursorPosition, [LSPCompletionItem(error: reason)]) } @@ -268,13 +273,15 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, self.crashShown = false let query = document.completionPrefix(at: caret) let ranked = rankedCompletions(items, query: query).map(LSPCompletionItem.init) - return (cursorPosition, ranked) + guard !ranked.isEmpty else { return nil } + let windowPos = CursorPosition(range: NSRange(location: caret, length: 0)) + return (windowPos, ranked) } catch { self.lastCompletionItems = nil guard generation == self.completionGeneration, !crashShown else { return nil } crashShown = true let message = (error as NSError).localizedDescription - let reason = message.isEmpty ? "SourceKit-LSP stopped responding." : message + let reason = message.isEmpty ? "Language server stopped responding." : message return (cursorPosition, [LSPCompletionItem(error: reason)]) } } @@ -287,7 +294,6 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, guard let items = lastCompletionItems, !items.isEmpty else { return nil } guard let caret = offset(for: cursorPosition) else { return nil } let prefix = document.completionPrefix(at: caret) - guard !prefix.isEmpty else { return nil } let ranked = rankedCompletions(items, query: prefix).map(LSPCompletionItem.init) return ranked.isEmpty ? nil : ranked } @@ -662,6 +668,10 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, /// 1-indexed `CursorPosition` → UTF-8 content offset (ASCII source: == UTF-16). private func offset(for cursorPosition: CursorPosition?) -> Int? { guard let cursorPosition else { return nil } + if cursorPosition.range != .notFound { + return cursorPosition.range.location + } + guard cursorPosition.start.line > 0, cursorPosition.start.column > 0 else { return nil } return document.offset( forLine: cursorPosition.start.line - 1, column: cursorPosition.start.column - 1 diff --git a/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift b/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift index a5a725a..a0463a7 100644 --- a/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift +++ b/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift @@ -41,4 +41,31 @@ struct DocumentDiagnosticsTests { #expect(nsRange?.location == start) #expect(nsRange?.length == 3) } + + @Test("continuous typing flips modified state and allows live diagnostic cycle") + func liveTypingAndDiagnosticCycle() { + let doc = Document(content: "package main\n\nfunc main() {}\n") + #expect(!doc.isModified) + + // Typing random characters marks document modified + doc.setContent("package main\n\nfunc main() {\n asdf\n}\n") + #expect(doc.isModified) + + // Live linter emits syntax error diagnostic + let errDiag = DocumentDiagnostic( + range: TextSelection(start: 31, end: 35), + severity: .error, + message: "undefined: asdf", + source: "gopls" + ) + doc.setDiagnostics([errDiag]) + #expect(doc.diagnostics.count == 1) + #expect(doc.diagnostics.first?.source == "gopls") + + // User resolves the syntax error + doc.setContent("package main\n\nfunc main() {\n println(\"ok\")\n}\n") + doc.setDiagnostics([]) + #expect(doc.diagnostics.isEmpty) + #expect(doc.isModified) + } } From 88a0c5f3758e88ec0fe6caf01995f2845af4bb04 Mon Sep 17 00:00:00 2001 From: yan Date: Mon, 12 Oct 2026 15:51:24 +0300 Subject: [PATCH 9/9] style: apply swift-format formatting rules --- .../Sources/Session/RecentProjectsStore.swift | 2 +- Sources/CodeEditorLSP/JSONRPCTransport.swift | 2 +- Sources/CodeEditorLSP/Types/LSPTypes.swift | 2 +- Sources/CodeEditorTerminal/TerminalSession.swift | 2 +- Sources/CodeEditorUI/AppState.swift | 8 ++++++-- Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift | 4 +++- Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift | 4 ++-- .../Sources/Editor/DocumentTextCoordinator.swift | 2 +- .../Sources/Editor/LSPTextViewCoordinator.swift | 9 +++++---- Tests/CodeEditorTests/GoDevelopmentTests.swift | 1 + Tests/CodeEditorTests/RecentProjectsStoreTests.swift | 1 + 11 files changed, 23 insertions(+), 14 deletions(-) diff --git a/Sources/CodeEditorCore/Sources/Session/RecentProjectsStore.swift b/Sources/CodeEditorCore/Sources/Session/RecentProjectsStore.swift index adbebc6..8bda685 100644 --- a/Sources/CodeEditorCore/Sources/Session/RecentProjectsStore.swift +++ b/Sources/CodeEditorCore/Sources/Session/RecentProjectsStore.swift @@ -26,7 +26,7 @@ public enum RecentProjectsStore { /// Load the list of recent projects, filtering out paths that no longer exist on disk. public static func load(defaults: UserDefaults = .standard) -> [RecentProject] { guard let data = defaults.data(forKey: key), - let projects = try? JSONDecoder().decode([RecentProject].self, from: data) + let projects = try? JSONDecoder().decode([RecentProject].self, from: data) else { return [] } diff --git a/Sources/CodeEditorLSP/JSONRPCTransport.swift b/Sources/CodeEditorLSP/JSONRPCTransport.swift index 1bb55b2..5c39a8b 100644 --- a/Sources/CodeEditorLSP/JSONRPCTransport.swift +++ b/Sources/CodeEditorLSP/JSONRPCTransport.swift @@ -79,7 +79,7 @@ public actor JSONRPCTransport { "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", - "/bin" + "/bin", ] let newPath = (extraPaths + [currentPath]).filter { !$0.isEmpty }.joined(separator: ":") env["PATH"] = newPath diff --git a/Sources/CodeEditorLSP/Types/LSPTypes.swift b/Sources/CodeEditorLSP/Types/LSPTypes.swift index cc6690f..c463a66 100644 --- a/Sources/CodeEditorLSP/Types/LSPTypes.swift +++ b/Sources/CodeEditorLSP/Types/LSPTypes.swift @@ -447,7 +447,7 @@ public struct GoplsInitializationOptions: Codable, Equatable, Sendable { "unusedparams": true, "shadow": true, "nilness": true, - "fieldalignment": false + "fieldalignment": false, ] ) { self.staticcheck = staticcheck diff --git a/Sources/CodeEditorTerminal/TerminalSession.swift b/Sources/CodeEditorTerminal/TerminalSession.swift index e4668c9..2157c88 100644 --- a/Sources/CodeEditorTerminal/TerminalSession.swift +++ b/Sources/CodeEditorTerminal/TerminalSession.swift @@ -87,7 +87,7 @@ public final class TerminalSession { "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", - "/bin" + "/bin", ] let newPath = (extraPaths + [currentPath]).filter { !$0.isEmpty }.joined(separator: ":") environment["PATH"] = newPath diff --git a/Sources/CodeEditorUI/AppState.swift b/Sources/CodeEditorUI/AppState.swift index 45956dd..3f753da 100644 --- a/Sources/CodeEditorUI/AppState.swift +++ b/Sources/CodeEditorUI/AppState.swift @@ -438,7 +438,9 @@ public final class AppState { terminal.connect() } let goMod = root.appendingPathComponent("go.mod") - let isGo = FileManager.default.fileExists(atPath: goMod.path) || documentManager.activeDocument?.language.identifier == "go" + let isGo = + FileManager.default.fileExists(atPath: goMod.path) + || documentManager.activeDocument?.language.identifier == "go" if isGo { terminal.submit("go run .") } else { @@ -456,7 +458,9 @@ public final class AppState { terminal.connect() } let goMod = root.appendingPathComponent("go.mod") - let isGo = FileManager.default.fileExists(atPath: goMod.path) || documentManager.activeDocument?.language.identifier == "go" + let isGo = + FileManager.default.fileExists(atPath: goMod.path) + || documentManager.activeDocument?.language.identifier == "go" if isGo { terminal.submit("go test -v ./...") } else { diff --git a/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift b/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift index 63b70ab..c932a5d 100644 --- a/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift +++ b/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift @@ -56,7 +56,9 @@ public struct TabBarView: View { onClose: { onClose(document) }, onHover: { isHovered in withAnimation(.easeOut(duration: 0.1)) { - hoveredDocumentId = isHovered ? document.id : (hoveredDocumentId == document.id ? nil : hoveredDocumentId) + hoveredDocumentId = + isHovered + ? document.id : (hoveredDocumentId == document.id ? nil : hoveredDocumentId) } } ) diff --git a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift index fb413a6..930aabe 100644 --- a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift +++ b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift @@ -1,7 +1,7 @@ -import CodeEditorCore -import CodeEditorThemes import CodeEditSourceEditor import CodeEditTextView +import CodeEditorCore +import CodeEditorThemes import SwiftUI /// Wraps `SourceEditor` from CodeEditSourceEditor, bridging document state and diff --git a/Sources/CodeEditorUI/Sources/Editor/DocumentTextCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/DocumentTextCoordinator.swift index fd8c28f..2407932 100644 --- a/Sources/CodeEditorUI/Sources/Editor/DocumentTextCoordinator.swift +++ b/Sources/CodeEditorUI/Sources/Editor/DocumentTextCoordinator.swift @@ -1,7 +1,7 @@ import AppKit -import CodeEditorCore import CodeEditSourceEditor import CodeEditTextView +import CodeEditorCore /// Keeps the text view's contents in step with the document it is showing and ensures /// edits in the text view immediately mark the document modified. diff --git a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift index 9cb5329..50ab167 100644 --- a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift +++ b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift @@ -578,7 +578,7 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, } guard let offset = tv.layoutManager.textOffsetAtPoint(viewPoint), - offset < stringLength + offset < stringLength else { closeDiagnosticPopover() return @@ -590,8 +590,9 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, return true } if let diagLine = document.lineAndColumn(for: range.location)?.line, - let currentLine = document.lineAndColumn(for: offset)?.line, - diagLine == currentLine { + let currentLine = document.lineAndColumn(for: offset)?.line, + diagLine == currentLine + { return true } return false @@ -603,7 +604,7 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, } guard let firstDiag = matchingDiagnostics.first, - let anchorRange = document.nsRange(from: firstDiag.range.start, to: firstDiag.range.end) + let anchorRange = document.nsRange(from: firstDiag.range.start, to: firstDiag.range.end) else { closeDiagnosticPopover() return diff --git a/Tests/CodeEditorTests/GoDevelopmentTests.swift b/Tests/CodeEditorTests/GoDevelopmentTests.swift index 9383c9e..2d9f87d 100644 --- a/Tests/CodeEditorTests/GoDevelopmentTests.swift +++ b/Tests/CodeEditorTests/GoDevelopmentTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing + @testable import CodeEditorCore @testable import CodeEditorLSP diff --git a/Tests/CodeEditorTests/RecentProjectsStoreTests.swift b/Tests/CodeEditorTests/RecentProjectsStoreTests.swift index 4167aef..97c18e3 100644 --- a/Tests/CodeEditorTests/RecentProjectsStoreTests.swift +++ b/Tests/CodeEditorTests/RecentProjectsStoreTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing + @testable import CodeEditorCore @Suite("RecentProjectsStore")