app
Assembles the editor: the screen, the desktop of windows, the menu bar, the status bar, the modal stack and the completion popup — and the routing that decides which of them sees each key and click.
Everything below this package is reusable on its own; this is where the decisions about this editor live.
Event routing
One order, front to back, for both keys and clicks:
completion popup → modal dialog → open menu → focused terminal → menu bar → global shortcuts → desktop
keyLayers() is that list, in that order; each layer returns whether it consumed the event. A modal dialog consumes everything, including keys none of its controls wants, so nothing behind it can be typed into or dragged while a question is waiting.
Two layers are exceptions, and both are deliberate:
- The completion popup consumes the arrows,
Enter,TabandEscape, but lets printable characters through — which is what lets the user keep typing while the list narrows itself. - A focused terminal sits above the global shortcuts, the one place the order is inverted. A shell wants
Ctrl-C,Ctrl-WandCtrl-F, and the editor would otherwise take all three.terminalTakesKeygives it everything except whateditorOwnedKeyreserves — the function keys,Alt-X, andAlt-0…Alt-9— which are the only way out of a full-screen program.
Dialogs are asynchronous
There is no nested event loop. pushModal(dialog, onClose) puts a dialog in front, and settleModals — which runs after every event — pops the ones that have been answered and calls their callbacks. A dialog that opens another one therefore just works, with no re-entrancy anywhere.
In the Open / Save As box, the Name field and the list are kept in step: ListBox.OnSelect writes the highlighted entry into the field, so the field always says what OK is about to act on, and confirm falls back to the highlight when the field is empty. Without that wiring the two controls are independent, and OK on a freshly opened dialog does nothing at all — the user has highlighted a file, the field is still empty, and there is no path to act on.
Terminal windows are windows
terminal provides the widget; this package decides where a shell starts, what closes it, and which windows are terminals at all.
a.terminals maps a window to its view, and is the answer to "is this a terminal?" everywhere it matters: editorViewOf returns no editor for one, closeWindow short-circuits to closeTerminal rather than asking about unsaved work, and Quit calls closeTerminals so no shell outlives the editor.
A new terminal starts in the directory of the file in front — where go build and git want to be — and its title is refreshed on every turn of the event loop, because a program inside it can rename itself from a goroutine nothing else watches.
Agent windows are windows too
acp provides the protocol client and the widget; this package decides which agents exist, starts one per window, and answers the questions an agent asks of its client.
a.agents maps a window to its session, and is the answer to "is this an agent?" everywhere it matters — exactly as a.terminals is for a shell. Closing one asks nothing: a conversation is a running process, not unsaved work.
Two things are this package's rather than acp's, and both are about the editor around the conversation:
- The permission dialog.
session/request_permissionarrives on the session's reading goroutine;recordPermissionputs it in a queue and wakes the loop, andaskNextPermissionopens the box on the next turn. One at a time —pendingPermissions.askingholds the rest back — because two agents asking at once would stack two modals over each other. Escape means the agent's own reject option, not a cancellation: the turn is still running and the agent may carry on without what it asked for. - The file methods.
readForAgentanswers from the buffer when the file is open and modified, so the agent sees the edit you just made rather than the version on disk.writeForAgentputs the agent's text into the buffer and leaves it unsaved, so the change is in front of you, undoable withCtrl-Zand yours to keep withF2. Both takeagentFiles, because they run on a session's goroutine while the event loop may be writing the same buffers.
Project settings and automatic saving
A project's settings file is read by main and handed here through UseSettings, which applies autosave and remembers the path. The theme is not applied here: main resolves it first, because a -theme flag outranks the project's choice and only main knows whether one was given.
Saving the settings file re-applies it. UseSettings used to be the only call, made once from main, so editing settings.toml in the editor did nothing until the next start-up — which is what a user reported. reapplySettings now runs after every successful write and, when the path written is this project's settings file, re-reads it and applies autosave. It matches on the path rather than on a.settingsPath, because a project that had no settings file has nothing remembered and creating one has to count. A file that no longer parses says Saved, but not applied: … and the previous values stay: that is a third outcome, distinct from "saved" and from "cannot save", and the only one that leaves the editor behaving unlike the file on the screen.
That step lives in afterSave, which is the tail both save paths share. The File menu's save and automatic saving differ only in how they report a failure, and a step added to one used to be missing from the other — this one would have been.
The theme is deliberately not re-applied on save: Options ▸ Theme is the live path for it and already writes the choice back here, and a -theme flag is the more explicit statement for the session it was given in.
a.settingsPath is the whole of the write-back rule. It is empty in a project with no settings file, and picking a theme then changes nothing on disk — a theme tried for ten seconds must not put a directory into someone's repository. Once the file exists, rememberTheme rewrites one key of it in place.
Autosave is state, nudged by a timer — not driven by one. noteEdit sets a deadline; saveDueDocuments checks it at the top of every turn of the event loop, exactly as announceOpenDocuments does. A time.AfterFunc calls wake so the loop turns at all while nothing is happening, but that wake-up is a PostEvent, and PostEvent drops what does not fit. A dropped nudge costs a late save; a design where the timer did the saving would lose it.
Two consequences worth knowing: a failed save clears the deadline before writing, so a read-only file is tried once per edit rather than forever; and it reports on the status bar, never in a dialog, because a modal reappearing every two seconds is worse than the problem it describes.
The project tree is one window, held by hand
filetree provides the widget; this package decides where it is rooted, that there is only one, and when it is re-read.
a.treeWindow and a.treeView are a pair of fields rather than a map, because there is at most one tree: the root is fixed at start-up, so a second view of it would have nothing to distinguish it. ProjectTree raises the existing window instead of opening another, the same way opening an already-open file raises its window.
The tree is rooted at os.Getwd() — the same rule the settings file follows, so "the project" means one thing throughout the editor. refreshTree runs after every save, which is the one moment the editor knows the project changed; everything else is the user's F5.
The Go menu, and putting files back afterwards
tools holds the commands; this package runs one in a terminal window and deals with what it did to the files.
A command may ask for values first. A {{label}} in it opens a box titled after the tool, one field per label, before anything runs; Escape runs nothing. What was typed is remembered for the session, per tool, and never written to disk. MaxParameterFields says how many fit on a screen, and a tool asking for more is refused with a message rather than given a box whose OK button is off the bottom.
Where the output goes is the tool's choice, from the project's tools.toml. A popup is the default: it opens at once and fills in as the command runs, its title carrying — running and then — ok or — exit n, and closing it stops a command still going. A terminal window is what an interactive or long command wants — Run is the one default that asks for it — and an editor window is for output to work through with Ctrl-F.
The popup is refreshed from tick, not from the reading goroutine, which may not touch a dialog.
Format rewrites files on disk, including the one in front. Without anything further the editor would sit on a stale copy and the next F2 would write the unformatted version back over gofmt's work — the feature quietly undoing itself. So reloadAfterTools re-reads every open file with no unsaved changes, at the top of the event loop, and says on the status bar how many. A file with unsaved changes is left alone and counted: the edit and the formatter genuinely disagree, and the editor is not in a position to decide.
The exit is noticed on a goroutine that may not touch a buffer, so it sets a.toolsRan and the loop does the work.
tick is that loop turn, extracted so a test can take one. Everything in it is state-driven — announceOpenDocuments, refreshRunningTool, reloadAfterTools, saveDueDocuments, refreshTerminalTitles — for the same reason each time: the only way to wake this loop from another goroutine is PostEvent, which drops what does not fit, so the wake-up may be lost and the state must not be. Tests call tick, not the individual step, or removing a step from the loop would leave them passing.
Menus the tools file asks for
A tool's menu key is a free-form name, so the set of menus on the bar comes from a file that changes while the editor runs. Menu.OnOpen cannot cover that — it refills one menu's items, and a menu that does not exist has no OnOpen to call — so allMenus builds the whole bar and refreshToolMenus replaces it through ui.MenuBar.SetMenus.
Rebuilding is guarded by the tools file's size and modification time, kept in a.toolsStamp. Parsing the file on every turn of the loop would work and would also be work done for nothing on every keystroke; one stat per turn is what it costs instead. The stamp is taken before the bar is built, so a file written between the two is picked up on the next turn rather than missed.
Hot keys for those menus are assigned here, not read from the file, because only the bar knows which letters are taken. hotKeyLabel marks the first free letter of the name and reserves it; tildes written into the name are kept when the letter is free and dropped when it is not. That asymmetry is the point: the bar answers the first menu matching a key, so a clash makes one menu silently unreachable — the bug Snippets and Search already produced once, with every test passing. A name whose every letter is taken gets no hot key at all, which F10 and the mouse still reach.
Go stays a fixed menu rather than becoming another name from the file: it holds Create tools file, which has to be reachable in a project that has no tools file.
createProjectFile is the one place the three project files are created from — settings, snippets and tools all do the same dance, and it is written once.
The About box says only what the build recorded
app.Version is gone. The number comes from version, which takes it from the linker's stamp, then from the Go build system, then gives up and says unknown — because a constant in the source is accurate the day it is written and wrong for every commit after it, and an About box is exactly where a number nobody set gets believed.
aboutText(name, language, info, themeName) is a pure function, so what the box says is tested without opening one. It omits a line whose fact is empty rather than showing a blank one: a binary from go install …@v0.2.0 knows its version and nothing else, and Commit: with nothing after it says only that the editor failed to fill it in.
The language comes from the profile rather than from this package's own prose. It did not, and the sentence "A Turbo C-style editor for Go" was drawn by Turbo Rust — the shape of mistake this whole package is meant to make impossible, in the one string nobody thought of as a setting.
The language server is optional by construction
Language wraps the whole gopls conversation. When there is no server — not installed, or failed to start — every method is a no-op, not an error, so the rest of the app never has to ask whether a language server exists. Its state shows on the status bar; editing and colouring carry on regardless.
Diagnostics arrive on the connection's read loop, off the main goroutine, so Language posts an interrupt event to wake the event loop rather than drawing from there.
What is here
| File | |
|---|---|
app.go |
The App: layout, drawing, routing, the modal stack |
actions_file.go |
New, open, save, close, quit, and where a new window goes |
actions_edit.go |
Undo, redo, clipboard, select all |
actions_view.go |
Tile, cascade, maximise, themes, line numbers, help, About |
terminals.go |
Opening, closing and tracking terminal windows |
agents.go |
Opening, closing and tracking agent windows; the file methods; the status report |
agent_permissions.go |
The queue of agents waiting for an answer, and the dialog that gives one |
agent_files.go |
The project's files, as the @ picker in an agent window lists them |
tree.go |
The project tree window: opening it, closing it, refreshing it |
snippets.go |
The Snippets menu, and inserting one |
gotools.go |
The Go menu: running a command in a terminal, and re-reading what it rewrote |
toolmenus.go |
The menus the tools file asks for, and the hot keys they get |
autosave.go |
The idle deadline and the saving it triggers |
project.go |
The project settings file: creating, opening, writing the theme back |
menus.go |
The menu bar and status bar, in one readable place |
dialogs.go |
File browser, message, confirm, prompt, find, choice |
complete.go |
Asking for a completion and accepting one |
completion.go |
CompletionBox, the popup itself |
language.go |
Language, the optional gopls conversation |
Menus
File New · Open · Save · Save as · Close · Exit
Edit Undo · Redo · Cut · Copy · Paste · Select all
Search Find · Find next · Find previous · Go to line · Go to definition
Run Completion · Describe symbol · Language server status
Options Theme · Line numbers · Create project settings · Project settings
Window Next · New terminal · Project tree · Tile · Cascade · Maximise · List
Snippets built from the project's snippets file · Create snippets file
Agent built from the project's agents file · Cancel turn · Create agents file · Agent status
Go built from the project's tools file · Create tools file
Any menu the tools file names, between Go and Help, built from the tools that named it
Help Keyboard · About
Items that need a file open say so through Enabled, so the menu greys them out instead of doing nothing when chosen.
Tests
The whole application is exercised through tcell.SimulationScreen — layout, routing, dialogs, drawing — with only the terminal itself replaced.
The terminal-window tests pin $SHELL to /bin/sh and skip themselves where there is no /dev/ptmx, so they neither depend on the developer's login shell nor fail on a platform that has no pseudo-terminals.
make test
go test ./app/
The project's three files behave alike
settings.toml, snippets.toml and tools.toml all answer the same three questions — has the project got one, create it, open it — so the behaviour is written once and the differences are a projectFile value each: a title, how the file is named to a person, where the item that writes it lives, and two functions for existence and path.
The rule the menus enforce is that exactly one of each pair is ever available: you can create the file you have not got, and open the one you have. HasProjectTools and its two siblings decide it, and not inverts one for the create item. Before this, choosing create twice opened the file instead — defensible, and not what anybody expected from an item that says "create".
Both branches stay reachable from the API, because a caller that is not a menu has no greying-out: creating over an existing file still reports Already there, and opening a file that is not there still says where the item that makes it lives.
Everything asked of the language server is in one menu
Describe symbol lived in Run and Go to definition in Search, and neither is where a reader looks for "what else knows about this name". They are in Code now, with the four questions that answer with places and the two that answer with symbols. Their keys did not move.
Every item acts on the cursor, not a selection. Almost every request in the protocol takes a position rather than a range, so requiring a selection first would be an invented step that answers nothing extra.
showLocations is where the four location questions end up, and it has four outcomes rather than two. A server that is not ready says so, because "nothing found" and "I cannot answer yet" look identical to a user and only one of them is worth waiting out — it is the most confusing way completion fails, and this would have inherited it for free. One answer jumps, several offer the list: GoToDefinition used to take locations[0] and throw the rest away, so an interface with four implementations sent you to one of them, chosen by the server's ordering.
A list of places shows the text of each line, read from an open window when there is one and from the disk otherwise. The window first because it is the truth: a file edited and not saved would otherwise be listed with text it no longer has, beside line numbers that follow the edits.
Diagnostics are keyed by canonical path
They arrive from the read loop with an absolute URI; a buffer opened as turbo-go main.go holds the relative path the command line gave it. Keyed by whatever arrived, the two never meet — and the failure is invisible, because an editor with no error to show and an editor that cannot find the error are the same blank gutter. pathKey makes both sides canonical — lsp.CanonicalPath: absolute, symbolic links resolved — and the map of open documents is keyed the same way, so Knows does not depend on how a path is spelt. The status bar had been failing this way since long before the gutter marks existed, and every unit test passed because every unit test opened its file by an absolute path.
Absolute alone was not enough. moon-lsp canonicalises the files of a package, so a document announced under a linked spelling of its path is, to it, a file that belongs to no package: it answers no completion about the buffer's types, and publishes the diagnostics under the spelling it knows. On macOS every temporary directory is such a spelling — /var/folders/… is a link to /private/var/… — and Turbo MoonBit's suite, green on Linux, failed both ways the first time it ran on a Mac. The URIs sent to the server (lsp.PathToURI) go through the same function, so a diagnostic comes back under the spelling it was announced by.
refreshMarks runs on each turn of the loop, translating each file's diagnostics into editor.Severity marks. A line with several problems is marked with its worst: the gutter has one column, and a line that is both an error and a hint is a line you want to know is an error.
Saving announces what the server does not know
A window that starts Untitled is skipped by every didOpen: it has no path to announce. The moment Save As gave it one used to send only didSave — and a server ignores didChange and didSave for a document it was never told is open, so that window had no completion, hover or diagnostics until the editor was restarted and the file reopened under its name. Found by a user whose first launch "had no LSP" until they saved, quit and relaunched.
announceSaved is the fix, in afterSave so both save paths get it: a save of a path the server Knows reports the write, and a save of one it does not announces the document. A save that created the file — save and writeQuietly both ask fileExists before writing — also sends workspace/didChangeWatchedFiles with the file as created, after the document has been announced. To moon-lsp a document being open and a file existing are two facts, and it works a package's files out from the directory: a .mbt saved for the first time got no diagnostics however loudly the document had been announced, until told the file is there. Found on macOS, the first time Turbo MoonBit's suite ran there (where moon-lsp evidently notices new files by itself, and a test pinning the old limit went red); confirmed at the protocol level on Linux, where the notification is the only thing that makes the difference. And because Save As can also rename, save remembers the buffer's previous path before SaveAs rewrites it, and renamed — compared through pathKey, so a change of spelling is not a rename — closes the old document on the server. Without that close a ghost stays open there for as long as the editor runs, keeping its diagnostics and shadowing the file if it is ever reopened.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
|