Skip to content
Merged
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.zcode/
.zcode/
.poolside/
19 changes: 10 additions & 9 deletions PROJECT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,23 @@ 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 |
| 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 |
| 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) — 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 (68 tests)
- [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)
- [ ] Completion works for Swift
- [ ] Diagnostics render
- [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)
- [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
Expand All @@ -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) |
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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. |

Expand Down Expand Up @@ -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)

Expand Down
37 changes: 37 additions & 0 deletions Sources/CodeEditorApp/Sources/CodeEditorApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -165,6 +194,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() {
Expand Down
16 changes: 16 additions & 0 deletions Sources/CodeEditorApp/Sources/MainWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -61,6 +63,10 @@ struct MainWindowView: View {
quickOpenOverlay
}
}
.sheet(isPresented: settingsBinding) {
SettingsView(appState: appState)
.frame(minWidth: 420, minHeight: 480)
}
.onChange(of: appState.quickOpenQuery) { _, _ in
appState.updateQuickOpenResults()
}
Expand Down Expand Up @@ -186,7 +192,9 @@ struct MainWindowView: View {
editorState: appState.editorState,
appState: appState
)
.id(document.id)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
} else {
EmptyStateView(
appState: appState,
Expand Down Expand Up @@ -315,6 +323,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<Bool> {
Binding(
get: { appState.isSettingsVisible },
set: { appState.isSettingsVisible = $0 }
)
}
}

#Preview {
Expand Down
32 changes: 32 additions & 0 deletions Sources/CodeEditorCore/Sources/Document/Diagnostic.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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 {
/// 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, source: String? = nil) {
self.range = range
self.severity = severity
self.message = message
self.source = source
}
}
36 changes: 26 additions & 10 deletions Sources/CodeEditorCore/Sources/Document/Document.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -318,38 +323,48 @@ 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.
/// 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 {
offset += lines[index].utf8.count + lineEnding.character.utf8.count
offset += lines[index].utf16.count + lineEnding.character.utf16.count
}
offset += lines[line].utf8.prefix(column).count
let lineUTF16 = lines[line].utf16
offset += min(column, lineUTF16.count)
return offset
}

/// Line/column pair for a UTF-8 offset.
/// Line/column pair for a UTF-16 code unit offset.
public func lineAndColumn(for offset: Int) -> (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)
}

Expand Down Expand Up @@ -612,6 +627,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
}

Expand Down
48 changes: 48 additions & 0 deletions Sources/CodeEditorCore/Sources/Document/GoFormatter.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading