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.

Status
current
Phase
1

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.* and layout.save WebSocket actions already exist in packages/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 → updateContent in the store → debounced (750 ms, per document) upsert into drafts with the document's saved revision as baseRevision. Pending checkpoints flush when the last view of a document unmounts and on beforeunload (best effort).
  • Save (Ctrl/Cmd+S or Save button) → write currentContent to documents, 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 (lastEditorViewId in 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-editor editor.addCommand bindings 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() into layouts.
  • View state change (cursor/selection/scroll) → debounced (500 ms) editor.saveViewState() into viewStates; also written when a view unmounts.

Boot / recovery

  1. Load all documents and drafts rows.
  2. 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.
  3. Hydrate the document store.
  4. Load the layouts row; validate the shape with Zod and drop panels whose params.documentId no longer exists (sanitizeLayout). Apply with fromJSON. Any failure → start with an empty workspace showing the watermark. A corrupt layout must never block editing.
  5. Each restored panel mounts an EditorView, which acquires the document model and restores its view state from viewStates.

Monaco model registry

One model per document, owned by createModelRegistry:

  • acquire(document) returns the shared model, creating it from currentContent on 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 onRelease flush so the final content is checkpointed to drafts before the model goes away.
  • Editors switch documents exclusively with editor.setModel(model); setValue on 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 via schema.children. Schemas and table lists load eagerly per connection (bounded concurrency); columns load lazily per table on first reference (the schema.children rate 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-tools statementAt) is scanned for FROM/JOIN/UPDATE/INTO table refs and aliases. After qualifier. the qualifier resolves as alias/table (columns) then schema (tables) — falling through when a name matches no known table, so typing FROM schema. still completes. FROM/JOIN position suggests schemas, tables, and schema.table variants; 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>.sql with the id in uri.path (authority === "document"), via documentIdFromModelUri.

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 formatSql in @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 from monacoSetup.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 on editorHasSelection. Per-editor editor.addCommand is 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 selectionClipboard is 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 panel params.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-tab element 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 on mousedown in the capture phase — mousedown so 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 }, plus activeViewId) for React consumers (sidebar, save-command routing). Dockview remains the source of truth; the store is a read model fed by onDidAddPanel / 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.expanded holds 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, and dg.sidebar.collapsed is 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:

  1. Boot merge gains a server-revision comparison in step 2.
  2. saveDocument additionally sends document.save with the idempotency key; 409 Conflict surfaces in the UI instead of overwriting.
  3. Layout save additionally sends layout.save with 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.