# Spec — Editor workspace

**Status:** current
**Phase:** 1
**Source:** https://github.com/datagripe/datagripe/blob/main/docs/spec/editor-workspace.md

> An engineering document, not user documentation. It records what
> a subsystem does and why the alternatives were rejected, and some
> of it describes things that are not built. For using DataGripe,
> read [the documentation](/docs.md) instead.

**Supersedes:** nothing (implements [`docs/initial_idea.md`](https://github.com/datagripe/datagripe/blob/main/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`](/specs/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`](https://github.com/datagripe/datagripe/blob/main/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

```ts
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

```text
┌──────────────────────────────────────────────┐
│ >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`](https://github.com/datagripe/datagripe/blob/main/docs/initial_idea.md) §5).
