Editor workspace
An IDE-grade editor shell in the browser: many SQL documents open at once, tabs that move between groups, horizontal/vertical splits, and no lost changes across tab switches, splits, reloads, or browser crashes.
This is an engineering document. Specs are written for people building DataGripe: they record what a subsystem does and why the alternatives were rejected, and some of them describe things that are not built yet. If you are using DataGripe, the documentation is the place to be — it is maintained for you and this is not.
Supersedes: nothing (implements docs/initial_idea.md §5)
Goal
An IDE-grade editor shell in the browser: many SQL documents open at once, tabs that move between groups, horizontal/vertical splits, and no lost changes across tab switches, splits, reloads, or browser crashes.
Non-goals
- Server-side document persistence. Phase 1 is deliberately client-only:
there is no authenticated user or seeded workspace yet (auth lands in
Phase 4), so IndexedDB is the source of truth. The
document.*andlayout.saveWebSocket actions already exist inpackages/contracts/src/ws.ts; wiring the stores to them is a later phase and must not change the store semantics defined here. - Query execution, result grids, connections, explorer (Phases 2–3).
- Multiplayer/shared documents (Phase 6,
docs/spec/multiplayer.md). - Rich tab context menus, drag-and-drop onto the sidebar, popout windows.
Definitions
| Term | Meaning |
|---|---|
| Document | A named SQL file. Domain state lives in the Zustand document store; durable copy in the Dexie documents table |
| View | One Dockview panel hosting one Monaco editor on a document's model. Many views may share one document |
| Model | The Monaco text model for a document (datagripe://document/{id}.sql); owns undo history |
| Draft | Unsaved content checkpointed to the Dexie drafts table after each edit burst |
| Layout | Dockview's serialized workspace (panels, groups, splits) in the Dexie layouts table |
| View state | Cursor, selection, and scroll position per view, in the Dexie viewStates table |
Design
State ownership
The layering from docs/initial_idea.md §5 applies unchanged:
- Zustand document store is authoritative for domain state: document list, titles, saved vs current content, dirty flags, revisions.
- Monaco is authoritative for active in-memory editing and undo
history. Store updates flow from
onDidChangeModelContent, never from re-setting editor values. - IndexedDB (Dexie) is the crash-recovery and reload layer. The UI never reads it after boot; all writes are fire-and-forget from store subscriptions and editor events.
Document model
type EditorDocument = {
id: string; // uuid
title: string; // display name, unique per list, e.g. "query-1.sql"
language: "sql" | "markdown" | "plaintext"; // from the name
savedContent: string; // content at last explicit save
currentContent: string; // live content, may differ from savedContent
revision: number; // increments on every save
dirty: boolean; // currentContent !== savedContent
createdAt: string; // ISO 8601
updatedAt: string; // last edit or save
};
revision exists so the later server sync can adopt
WHERE id = ? AND revision = ? guarded saves without a model change.
Naming a document
Creating and renaming both ask for a name in the row the file
occupies, prefilled with query-N.sql for a new one and with the
current name for a rename, with the extension left out of the initial
selection so typing replaces the stem and keeps the suffix that decides
the language. Enter takes it, Escape abandons it, clicking away takes
it. This replaced window.prompt, which is the browser's dialog in the
middle of an application that has its own (roadmap: ui · browser-chrome-in-an-app) and could not show what the name was about to
become.
A collision suggests rather than refuses. A name already used in the
same list gets -1, -2, … before its extension (uniqueName in
contracts, case-insensitively — two files whose names differ only in
case are two files nobody can tell apart in a sidebar), and the person
can rename afterwards. Typing a name that is taken is far more often "I
want another one of these" than a mistake, and a modal saying that name
is taken makes somebody invent notes2.md by hand. Shared files and
scratchpads are two lists and collide separately.
A rename of a shared file saves: the title is the project's, not this browser's, and saving is how a title (and the language it decides) reaches everybody else.
Reverting
revertDocument puts currentContent back to savedContent, clears
dirty, cancels the pending draft checkpoint and deletes the draft row.
It is in the sidebar's context menu, on dirty documents only, and it
asks first — it is the one action here that throws typed content away.
It does not write: the saved row and the server revision are untouched,
because going back to the last save is not a new save.
Persistence schema (Dexie, database datagripe, version 1)
| Table | Key | Row |
|---|---|---|
documents |
id |
{ id, title, content, revision, createdAt, updatedAt } — last saved state |
drafts |
id (= document id) |
{ id, content, baseRevision, updatedAt } — latest unsaved checkpoint |
layouts |
id (= "local") |
{ id, json, updatedAt } — SerializedDockview |
viewStates |
id (= panel id) |
{ id, documentId, state, updatedAt } — monaco.editor.ICodeEditorViewState |
There is exactly one implicit local workspace, so the layout row id is the
constant "local". Multi-workspace keying arrives with server sync.
Write paths
- Edit → Monaco change event →
updateContentin the store → debounced (750 ms, per document) upsert intodraftswith the document's savedrevisionasbaseRevision. Pending checkpoints flush when the last view of a document unmounts and onbeforeunload(best effort). - Save (Ctrl/Cmd+S or Save button) → write
currentContenttodocuments,revision += 1, then delete the draft row. Order matters: a crash between the two writes leaves a recoverable draft, never a lost save. Ctrl/Cmd+S is handled by one window-level listener (capture phase, so Monaco's own bindings cannot consume shortcuts) routed to the most recent editor view (lastEditorViewIdin the view store): Monaco text focus (onDidFocusEditorText) and Dockview panel activation both update it, so commands keep working while a non-editor panel (e.g. Results) is active. Per-editoreditor.addCommandbindings are deliberately not used: Monaco command registrations are global, so identical keybindings from multiple editor instances collide and the latest editor shadows the rest. - Layout change → Dockview
onDidLayoutChange→ debounced (500 ms)toJSON()intolayouts. - View state change (cursor/selection/scroll) → debounced (500 ms)
editor.saveViewState()intoviewStates; also written when a view unmounts.
Boot / recovery
- Load all
documentsanddraftsrows. - Merge: a draft row wins over its document's saved content
(
currentContent = draft.content,dirty = true) whenever the draft is newer than the document row. A draft whose document row was deleted is dropped. This is the "never silently overwrite a newer draft" rule; comparing against a server revision joins this merge when sync lands. - Hydrate the document store.
- Load the
layoutsrow; validate the shape with Zod and drop panels whoseparams.documentIdno longer exists (sanitizeLayout). Apply withfromJSON. Any failure → start with an empty workspace showing the watermark. A corrupt layout must never block editing. - Each restored panel mounts an
EditorView, which acquires the document model and restores its view state fromviewStates.
Monaco model registry
One model per document, owned by createModelRegistry:
acquire(document)returns the shared model, creating it fromcurrentContenton first use; a reference count tracks live views.release(documentId)decrements; at zero the registry disposes the model after a microtask delay so StrictMode remounts and Dockview panel moves (which unmount and remount the React component) do not churn model creation or lose undo history.- Disposal first invokes an
onReleaseflush so the final content is checkpointed todraftsbefore the model goes away. - Editors switch documents exclusively with
editor.setModel(model);setValueon tab switches is forbidden (it destroys undo history and view state).
The registry takes its model factory as a parameter, so the reference counting and flush-on-dispose logic is unit-tested without Monaco.
SQL completion
A CompletionItemProvider for language sql (registered once in
monacoSetup.ts) provides DataGrip-style schema-aware suggestions:
- Catalog cache (
editor/completion/catalog.ts): per-connection schema → table/view tree fetched viaschema.children. Schemas and table lists load eagerly per connection (bounded concurrency); columns load lazily per table on first reference (theschema.childrenrate limit forbids bulk column fetch). Completion returns what is cached; Monaco re-queries on the next keystroke as data arrives. - Context (
editor/completion/context.ts, pure and unit-tested): the statement under the cursor (sql-toolsstatementAt) is scanned for FROM/JOIN/UPDATE/INTO table refs and aliases. Afterqualifier.the qualifier resolves as alias/table (columns) then schema (tables) — falling through when a name matches no known table, so typingFROM schema.still completes. FROM/JOIN position suggests schemas, tables, andschema.tablevariants; elsewhere aliases, in-scope columns, tables, then keywords/functions. - Document → connection resolution mirrors the executions store
(document pref, then workspace default); model URIs parse as
datagripe://document/<id>.sqlwith the id inuri.path(authority === "document"), viadocumentIdFromModelUri.
Formatting
Ctrl+Alt+L reformats — IntelliJ's binding, and DataGrip's. With a selection it formats the selection, without one the whole document.
- The layout comes from
formatSqlin@datagripe/sql-tools: a token-stream formatter, not a parser, so it lays out a statement that is still half-typed. One clause per line, joins hanging under their FROM, comma lists and AND/OR chains inline while they fit inside 100 columns and one item per line when they do not, parens breaking as(…)around their contents, and CASE branches one per line. Only whitespace and keyword case change; identifiers, literals and comments are copied verbatim, and comments keep the line they were written on (a trailing--comment takes the comma with it, ahead of itself). - Every result is re-tokenized and compared with the input before it is returned. Any difference — a comma dropped from malformed input, an unbalanced paren — returns the original text untouched: a formatter that loses SQL is worse than no formatter.
- Wired as Monaco formatting providers (
editor/formatting.ts, registered frommonacoSetup.ts), not as a command of our own, so one registration covers the SQL editor, the editor context menu, and Monaco's own Shift+Alt+F, and the table view's JSON value editor gets Ctrl+Alt+L for free from the JSON language service. - Indentation comes from Monaco's own tab settings per model, the dialect from the document's connection — MySQL backticks and Postgres dollar-quotes tokenize differently, and a formatter that guessed would rewrite a literal.
- The keybinding is registered globally with
monaco.editor.addKeybindingRules, two rules discriminated oneditorHasSelection. Per-editoreditor.addCommandis not used, for the same reason Ctrl/Cmd+S is not: command registrations are global, so identical per-editor bindings collide across split views.
Selection
- Middle-click drag makes a columnar, multi-line selection, as in
IntelliJ: one cursor per line, all of them editable at once. Monaco
implements this itself, but hands the middle button to the Linux
primary clipboard unless
selectionClipboardis off — so the option is off on every editor we create. A browser tab has no primary clipboard to paste from, so nothing is given up. - Alt+Shift+drag (Monaco's own column-select gesture) and Alt+click multi-cursor keep working; this only adds the mouse button IntelliJ users reach for.
Views and Dockview
- Each panel id is a stable
view-{uuid}; the document binding travels in panelparams.documentId, which Dockview serializes into the layout, so view→document bindings survive reload. - Editor panels use
renderer: "always"so hidden tabs keep their editor instance; tab switches are pure visibility changes. - A custom tab component shows a kind glyph, the title and a dirty dot. Tab close removes the view only; the document and any dirty draft are retained until the user explicitly deletes the document in the sidebar.
- Middle click closes a tab, same as its
×. The listener is bound natively on Dockview's own.dv-tabelement rather than as a React handler on the tab content: Dockview owns the interactive control and the drag listeners, so the whole tab responds including its padding, and no static element has to pretend to be a button. It listens onmousedownin the capture phase —mousedownso the browser never starts autoscroll, capture so Dockview does not read the press as the start of a tab drag. - Dragging tabs between groups and dropping on group edges creates horizontal/vertical splits — stock Dockview behavior, no custom DnD.
- A Zustand view store mirrors Dockview's panel inventory
(
viewId → { documentId }, plusactiveViewId) for React consumers (sidebar, save-command routing). Dockview remains the source of truth; the store is a read model fed byonDidAddPanel/onDidRemovePanel/onDidActivePanelChange.
UI structure
┌──────────────────────────────────────────────┐ │ >Datagripe:project_ … (av) │ ├───────────┬──────────────────────────────────┤ │ documents │ Dockview workspace │ │ sidebar │ (editor panels, splits, tabs) │ └───────────┴──────────────────────────────────┘
The header is the prompt on the left (ProjectPrompt) and one avatar on
the right (AccountMenu, docs/spec/updates.md) — role, settings, address
and log-out all moved behind it, because the header is also the drag
region of an installed window and every permanent control there is width
the operating system may take. The status bar holds the version and the
gripe count and nothing else: what it used to carry — the datasource,
its namespace, the project and its class — the sidebar and the prompt
already say, a foot up the same screen.
The sidebar lists every document (dirty dot, click to focus or open, double-click to rename, delete to discard). It is plain React, not a Dockview panel — it has no close semantics and must always be reachable. The empty workspace shows a Dockview watermark with a New query button.
Sections
Below the explorer the sidebar is a stack of collapsible sections
(SidebarSections): Files, Repository when the datasource has
one, and Online. MCP Server is an owner-only navigation header:
its title opens a dock tab, while its switch and status remain in the sidebar. Two rules, both about
not moving:
- Every section starts collapsed. A person opening one is stating a
preference;
dg.sidebar.expandedholds the ids they have opened and nothing else is remembered. (The older pair of keys — a collapse list and an expand list, because the default differed per section — is gone, anddg.sidebar.collapsedis ignored where it survives.) - A section stays where it is in the list, open or shut. Collapsed headers used to dock at the bottom, which meant opening one re-ordered the sidebar around it: the shape you learned was never the shape you were looking at.
A section may carry actions — controls in its header, beside the
toggle rather than inside it, live whether the section is open or shut —
and on, which frames the whole section in green. Both exist for the
MCP switch (docs/spec/mcp.md "The panel"): whether something outside the
app can read this project is not a fact that should need a panel opened
to see.
Scale
One number, --dg-scale, multiplying every type token in
styles/tokens.css. The slider is in Account settings → Appearance
(SCALE_MIN–SCALE_MAX, in steps of SCALE_STEP), and
stores/appearance.ts owns it.
It is local to the browser (dg.appearance.scale), not an account
setting: the reason to turn it up is usually the screen in front of you,
and the same account is also open on a phone. main.tsx applies it to
<html> before the first render, so the app never paints at one size
and jumps to another.
Two pieces of chrome are sized in px rather than type and have to be
multiplied explicitly: the tab strip
(--dv-tabs-and-actions-container-height, which Dockview sizes the row
from) and the header, which is content-sized everywhere and
min-height rather than height in an installed window — the reserved
titlebar strip is a floor, not a ceiling, or the scale would crop the
bar (docs/spec/updates.md).
Nothing else in the application reads the scale. A component that sizes itself from it is a component that stops scaling the day the slider's range changes. Monaco is the one exception, because its font size is a JavaScript option rather than CSS: each editor is created at the scaled size and subscribes for changes, rather than being re-created — a font size is not worth an undo history.
Client–server sync boundary (later phases)
The contracts for workspace.open, document.get/create/save/archive,
and layout.save are already defined. Sync plugs in at three seams
without store-shape changes:
- Boot merge gains a server-revision comparison in step 2.
saveDocumentadditionally sendsdocument.savewith the idempotency key;409 Conflictsurfaces in the UI instead of overwriting.- Layout save additionally sends
layout.savewith its revision guard.
Open questions
- Draft retention policy for deleted-but-dirty documents (currently: delete removes document, draft, and view states immediately).
- Whether the sidebar becomes a Dockview panel once tool windows (explorer, history) arrive in Phase 2–3.
- Autosave cadence to the server once sync lands (explicit save only, or
conservative background saves per
docs/initial_idea.md§5).
This page as Markdown, for an agent or a diff.