Files
windmill/frontend/src/lib/components/Editor.svelte
T
Diego Imbert e47aedac0a feat: add SQL migrations for data tables (#9693)
* feat: add datatable_migrations table

* feat: add route to run datatable migrations

* feat: sync datatable migrations as .up.sql/.down.sql files

* feat: add datatable migrate up/down commands and post-push run prompt

* feat: add datatable migrate new command to scaffold migrations

* feat: add datatable migrations management UI

* feat: prompt to create migration on DDL in datatable SQL editors

* feat: support running a single specific datatable migration

* feat: view migration content, run single migration, fix stacked modal

* feat: per-row revert button with out-of-order warning

* fix: avoid migrations list flicker on refresh after an action

* feat: generate initial datatable migration via pg_dump

* fix: surface datatable migration API error details in toasts

* fix: revert created migration if create-and-run fails to run

* fix: include postgres error detail in migration run/rollback failures

* feat: sync datatable migrations as files via the workspace export

* refactor: move datatable migrations to migrations/datatable/ path

* fix: drop redundant datatable_migration label in sync output

* fix: exclude datatable migration sql files from script metadata generation

* feat: run datatable migrations as user-permissioned labeled jobs

* feat: reject invalid datatable migrations on sync push

* feat: datatable migrate up/down default to all datatables, --datatable to target one

* fix: surface postgres error detail when datatable migrations fail to run

* chore: regenerate CLI docs for datatable migrate commands

* feat: default new datatable migration to a BEGIN/END transaction template

* fix: validate datatable migration name and datatable at the API boundary

* fix: ensure detected DDL ends with semicolon when wrapped in transaction

* fix: re-prompt instead of stripping DDL when new-migration modal is cancelled

* feat: refresh datatable schema after running a migration from the SQL REPL

* feat: record db manager DDL on data tables as migrations

* feat: make datatable migrations opt-in per data table

* fix: make migration view editor read-only so its code can scroll

* fix: don't re-prompt DDL guard when creating a migration without running

* feat: generate down migrations for db manager DDL (postgres)

* fix: correct down migration for db manager alters (no double-wrap, serial)

* feat: explain migrations purpose with a tooltip in the migrations modal

* compare paeg

* feat: add datatable_migration kind to workspace diff pipeline

* chore: point ee-repo-ref at datatable_migration git-sync companion

* fix: harden datatable migration version allocation and initial-migration bookkeeping, add tests

* feat: deploy and run datatable migrations on workspace merge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Refactor + handle datatable setting delete/rename

* refactor: move datatable migration rename/delete cascade into module

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(windmill-utils-internal): bump to 1.7.1 for datatable migration deploy provider methods

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(db-manager): add Migrations button to top bar, make Refresh icon-only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* BEGIN/END placeholder in down migration

* feat: autofocus migration name input and flag it red when empty

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(datatable-migrations): allow non-admins to create/run/revert migrations, gate only opt in/out

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* border nits

* refresh db manager schema on migrations

* BEGIN/END scaffold in CLI

* feat(cli): push local datatable migrations before running on migrate up

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: flag invalid migration name with red border, not just empty

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop random slug from auto-generated migration names

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: offer revert-and-delete when deleting an installed migration

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: record fork merge as a migration when target datatable opts in

* nit

* clone migrations on fork

* windmill-utils-internal

* fix(datatable-migrations): serialize run/rollback with a per-db advisory lock

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(db-manager): fail closed when migrations-status check errors on DDL apply

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: fix generate_initial migration ordering comment to match code

* chore(datatable-migrations): remove unused update_datatable_migrations endpoint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: run DDL migration guard on the script editor Test button

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* split

* ee-repo-ref

* chore(frontend): sync package-lock with package.json (@emnapi deps)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(datatable-migrations): never resolve instance credentials into migration job args

datatable_database_arg eagerly resolved instance data-table credentials
(including the shared instance-wide Postgres password) and passed them as the
migration job's plaintext `database` arg, landing in v2_job.args. Since the
run route has no admin gate, a non-admin could run a migration and read
args.database to recover the password, granting cross-workspace psql access to
all instance data-table DBs.

Pass a `datatable://<name>` reference for both resource-backed and instance
data tables instead; the pg executor already resolves it to real credentials
server-side at run time, so nothing sensitive is ever stored in the job args.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nit

* fix: handle dollar-quoting and comments when splitting SQL statements

* feat: deploy datatable migrations on merge with explicit opt-in error

* fix(frontend): sync package-lock with npm 11 peer-dep resolution

npm ci failed with 'Missing: @emnapi/core@1.11.2 / @emnapi/runtime@1.11.2 from
lock file'. @napi-rs/wasm-runtime declares @emnapi/core|runtime ^1.7.1 as
peerDependencies while @rolldown/binding-wasm32-wasi pins them to exactly
1.10.0. Newer npm (bundled with node 24 in CI) installs the peer deps at the
highest match (1.11.2) alongside rolldown's nested 1.10.0, so the ideal tree
needs both versions; the committed lock only had 1.10.0.

Regenerate the lock with npm 11.18 so it carries both 1.11.2 (top-level, for
the peer deps) and 1.10.0 (nested, for rolldown's pin). Verified npm ci passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nit npm publish

* fix: fail closed on migrations-status error in fork schema merge

* nit CI emnapi/core version

* prevent initial_datatable_migration if migrations already exist

* fix(datatable-migrations): validate persisted data table names as path segments

edit_datatable_config only validated rename segments, not the actual
settings.datatables keys, so a data table could be saved directly under a name
like '..' or one containing '/'. Since new tables default to
migrations_enabled = true, generate_initial_datatable_migration would then
insert a migration row and the sync export would build
migrations/datatable/<name>/... paths from that name, producing malformed or
directory-escaping export paths.

Validate every persisted data table name in edit_datatable_config (alongside
the existing rename checks) and add validate_datatable_path_segment to
generate_initial_datatable_migration for defense in depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: scope datatable _wm_migrations by data table and cascade renames/deletes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(system_prompts): resolve nested local command groups in CLI docs generator

The CLI docs generator anchored on the first `new Command()` in a file and
never resolved locally-defined command groups passed as
`.command("name", localCmd)`. For datatable this flattened the nested
`migrate` group: it emitted `datatable new/up/down` plus a bare
`datatable migrate`, and mislabeled the datatable command with the migrate
group's description. jobs was broken the same way (its description was pull's,
and pull/push rendered empty).

Anchor block extraction on the `export default`ed command, recurse into
locally-defined `const x = new Command()` groups mounted as subcommands, and
render nested sub-subcommands. Regenerated docs now show
`datatable migrate new/up/down` and `jobs pull/push` with their real
options.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop unreleased _wm_migrations legacy-upgrade handling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: return datatable migration SQL from getItemValue for the diff drawer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): use windmill-utils-internal 1.8.2 for migration diff drawer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nit

* nit

* fix: handle datatable migration renames on push and dedupe timestamps

* fix: reject rewriting an already-applied datatable migration on upsert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): add missing @emnapi/core and @emnapi/runtime lockfile entries

Resolves npm ci EUSAGE failure: the optional cpu:wasm32 @rolldown/binding-wasm32-wasi
declares deps on @emnapi/core@1.11.2 and @emnapi/runtime@1.11.2 that had no resolved
lockfile entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): datatable migrate up/down default to main datatable, not all

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: fail closed when applied status unreadable on datatable migration rewrite

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: surface full error detail in Database Manager DDL/query errors

* "See migration" button in the toast

* feat: add Enter shortcut to Create-a-migration in the DDL guard

* fix(frontend): warn before running a newly-created datatable migration out of order

The row-level Run action warns when earlier migrations are still pending, but
the create-and-run paths ran a just-created migration with `only` directly,
applying it ahead of older pending migrations without that confirmation.

Reuse the same "Run migration out of order" confirmation across all
create-and-run paths via a shared helper (datatableMigrationUtils):
- NewDataTableMigrationModal "Create and run" (and the DDL guard path)
- DatatableSchemaDiff fork→parent merge
- dbOps schema ops (DB manager create/alter/drop) — the pure factory throws a
  MigrationRunCancelled sentinel on decline, which DBTableEditor treats as a
  silent cancel

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep renamed datatable migrations visible in compare view

* fix: record per-migration deployment on datatable migrations disable

* fix(cli): run deployed datatable migrations after workspace merge

The merge command upserted datatable_migration definitions into the target
workspace and reported the item as successfully deployed, but never ran the
migrations. For forked datatables backed by separate databases, this left the
target schema unchanged until someone manually ran `wmill datatable migrate up`,
while the CLI reported a successful merge.

Collect the datatable migrations deployed (not deleted) into the target and,
after the deploy loop, offer to run them via the existing offerToRunNewMigrations
helper — the same post-deploy run prompt the push/sync path uses (interactive
only; `--yes`/non-TTY skip the mutating run, matching push behavior). Export
parseDatatableMigrationDeployPath so the merge path can parse the deployed items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): serialize datatable migration edits/deletes with the run lock

A migration run snapshots a migration's code_up from datatable_migrations and
only records its version in the data table's _wm_migrations after the job
succeeds. upsert_datatable_migration checked _wm_migrations before allowing an
edit but took no lock, so a concurrent edit could read "not applied yet",
rewrite code_up/code_down, and then the in-flight run would record the version
for the old SQL — leaving _wm_migrations pointing at SQL that was never applied
(migrate up then skips it; rollback runs a down that doesn't match).

Serialize definition rewrites and deletes with the same per-database advisory
lock the run/rollback paths use:
- Factor the connect+advisory-lock into lock_datatable_migration_runs and the
  applied-versions read into read_applied_versions_on_client.
- run_datatable_migrations now snapshots the definitions AFTER taking the lock,
  so code_up can't change between snapshot and version-record.
- upsert (when changing an existing def) and delete take the lock across the
  applied-check and the write; delete now rejects deleting an already-applied
  migration (would orphan its _wm_migrations record), symmetric with upsert.
  Both fail closed if the data table database is unreachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): stack the out-of-order migration confirm above the DB editor preview

Creating a table on a migrations-enabled data table opened the DB table editor's
"Confirm running the following" preview modal, whose confirm triggers applyDdl,
which then asks for out-of-order confirmation. Both are ConfirmationModals with a
hardcoded z-[9999]; the out-of-order one lives in DBManagerContent (mounted before
the editor), so it rendered behind the still-open preview modal.

Add an optional zIndexClass prop to ConfirmationModal (default z-[9999],
backward-compatible) and give the DB-manager out-of-order confirm z-[10000] so it
stacks on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 27672e37df5d9dfde94f19963d5ffcdf8dd5448c

This commit updates the EE repository reference after PR #623 was merged in windmill-ee-private.

Previous ee-repo-ref: 6c287041cd7edd4a77a4bc07ad0e156cec32cce4

New ee-repo-ref: 27672e37df5d9dfde94f19963d5ffcdf8dd5448c

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-07 08:25:16 +00:00

2299 lines
67 KiB
Svelte

<script module>
import '@codingame/monaco-vscode-standalone-languages'
import '@codingame/monaco-vscode-standalone-typescript-language-features'
import { typescriptDefaults } from '@codingame/monaco-vscode-standalone-typescript-language-features'
</script>
<script lang="ts">
import { BROWSER } from 'esm-env'
import { buildWsUrl } from '$lib/wsUrl'
import { sendUserToast } from '$lib/toast'
import { createEventDispatcher, onDestroy, onMount, untrack } from 'svelte'
// import libStdContent from '$lib/es6.d.ts.txt?raw'
// import domContent from '$lib/dom.d.ts.txt?raw'
// import denoFetchContent from '$lib/deno_fetch.d.ts.txt?raw'
import * as vscode from 'vscode'
// import '@codingame/monaco-vscode-typescript-basics-default-extension'
// import '@codingame/monaco-vscode-typescript-language-features-default-extension'
// import 'vscode/localExtensionHost'
import { MonacoLanguageClient } from 'monaco-languageclient'
import { toSocket, WebSocketMessageReader, WebSocketMessageWriter } from 'vscode-ws-jsonrpc'
import { CloseAction, ErrorAction, RequestType } from 'vscode-languageclient'
import type { DocumentUri, MessageTransports } from 'vscode-languageclient'
import { MonacoBinding } from 'y-monaco'
import {
dbSchemas,
type DBSchema,
codeCompletionSessionEnabled,
lspTokenStore,
formatOnSave,
vimMode,
relativeLineNumbers
} from '$lib/stores'
import { editorConfig, registerWebviewPaste, updateOptions } from '$lib/editorUtils'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { createHash as randomHash } from '$lib/editorLangUtils'
import { workspaceStore } from '$lib/stores'
import DdlMigrationGuard from './DdlMigrationGuard.svelte'
import {
type Preview,
ResourceService,
type ScriptLang,
UserService,
WorkspaceService
} from '$lib/gen'
import type { Text } from 'yjs'
import {
initializeVscode,
keepModelAroundToAvoidDisposalOfWorkers,
MONACO_Y_PADDING
} from '$lib/components/vscode'
// import { initializeMode } from 'monaco-graphql/esm/initializeMode.js'
// import type { MonacoGraphQLAPI } from 'monaco-graphql/esm/api.js'
import {
editor as meditor,
languages,
KeyCode,
KeyMod,
Uri as mUri,
type IRange,
type IDisposable
} from 'monaco-editor'
import EditorTheme from './EditorTheme.svelte'
import {
BIGQUERY_TYPES,
DUCKDB_TYPES,
MSSQL_TYPES,
MYSQL_TYPES,
ORACLEDB_TYPES,
POSTGRES_TYPES,
SNOWFLAKE_TYPES
} from '$lib/consts'
import { setupTypeAcquisition, type DepsToGet } from '$lib/ata/index'
import { initWasmTs, type InferAssetsSqlQueryDetails } from '$lib/infer'
import { initVim } from './monaco_keybindings'
import { updateSqlQueriesInWorker, waitForWorkerInitialization } from './sqlTypeService'
import { parseTypescriptDeps } from '$lib/relative_imports'
import { scriptLangToEditorLang } from '$lib/scripts'
import {
listWorkspaceMacrosCached,
macroDefinitionSql
} from '$lib/components/assets/workspaceMacros'
import {
fetchLatestSchema,
normalizeAssetPath,
type ContractMarker
} from '$lib/components/assets/AssetGraph/schemaContracts'
import * as htmllang from '$lib/svelteMonarch'
import { conf, language } from '$lib/vueMonarch'
import { Autocompletor } from './copilot/autocomplete/Autocompletor'
import { AIChatEditorHandler, type ReviewChangesOpts } from './copilot/chat/monaco-adapter'
import GlobalReviewButtons from './copilot/chat/GlobalReviewButtons.svelte'
import AIChatInlineWidget from './copilot/chat/AIChatInlineWidget.svelte'
import { writable } from 'svelte/store'
import { formatResourceTypes } from './copilot/chat/script/core'
import type { ScriptLintResult } from './copilot/chat/shared'
import FakeMonacoPlaceHolder from './FakeMonacoPlaceHolder.svelte'
import { editorPositionMap } from '$lib/utils'
import { extToLang, langToExt } from '$lib/editorLangUtils'
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
import type { Selection } from 'monaco-editor'
import { canHavePreprocessor, getPreprocessorModuleCode } from '$lib/script_helpers'
import { setMonacoTypescriptOptions } from './monacoLanguagesOptions'
import { copilotInfo } from '$lib/aiStore'
import { getDbSchemas } from './apps/components/display/dbtable/metadata'
import { rawAppLintStore, type MonacoLintError } from './raw_apps/lintStore'
import { MarkerSeverity } from 'monaco-editor'
import { resource, useDebounce, watch } from 'runed'
// import EditorTheme from './EditorTheme.svelte'
let divEl: HTMLDivElement | null = $state(null)
let editor: meditor.IStandaloneCodeEditor | null = $state(null)
let pasteCleanup: (() => void) | undefined = undefined
interface Props {
code?: string
cmdEnterAction?: (() => void) | undefined
formatAction?: (() => void) | undefined
automaticLayout?: boolean
websocketAlive?: any
shouldBindKey?: boolean
fixedOverflowWidgets?: boolean
path?: string | undefined
yContent?: Text | undefined
awareness?: any | undefined
folding?: boolean
args?: Record<string, any> | undefined
useWebsockets?: boolean
small?: boolean
scriptLang: Preview['language'] | 'bunnative' | 'tsx' | 'jsx' | 'json' | undefined
workflowAsCode?: boolean
disabled?: boolean
lineNumbersMinChars?: number
files?: Record<string, { code: string; readonly?: boolean }> | undefined
extraLib?: string | undefined
/** Trailing debounce window (ms) on Monaco's onDidChangeModelContent.
* Each keystroke schedules (or reschedules) an `updateCode` call this
* far in the future. */
changeTimeout?: number
/** Hard ceiling (ms) on how long `updateCode` can be deferred while
* the user is typing continuously — measured from the FIRST
* keystroke of the burst (the leading fire). Without this cap,
* uninterrupted typing would hold the bindable `code` prop stale
* indefinitely and downstream consumers (autosave, lint, live
* preview) would never see the latest text. */
maxChangeTimeout?: number
loadAsync?: boolean
key?: string | undefined
class?: string | undefined
moduleId?: string
enablePreprocessorSnippet?: boolean
/** When set, enables raw app lint collection mode and reports Monaco markers to the lint store under this key */
rawAppRunnableKey?: string | undefined
// Used to provide typed queries in TypeScript when detecting assets
preparedAssetsSqlQueries?: InferAssetsSqlQueryDetails[] | undefined
// To execute preview scripts with the right worker group
customTag?: string
// Live schema-contract diagnostics (pipelines gap #2b): owner-scoped
// warning markers computed by the caller (ScriptEditor's contract
// mirror) from the buffer's asset refs vs captured producer schemas.
// Warning severity only — contracts never block; empty clears.
schemaContractMarkers?: ContractMarker[]
}
let {
code = $bindable(),
cmdEnterAction = undefined,
formatAction = undefined,
automaticLayout = true,
websocketAlive = $bindable(),
shouldBindKey = true,
fixedOverflowWidgets = true,
path = undefined,
yContent = undefined,
awareness = undefined,
folding = false,
args = undefined,
useWebsockets = true,
small = false,
scriptLang,
workflowAsCode = false,
disabled = false,
lineNumbersMinChars = 3,
files = {},
extraLib = undefined,
changeTimeout = 500,
maxChangeTimeout = 1000,
loadAsync = false,
key = undefined,
class: clazz = undefined,
moduleId = undefined,
enablePreprocessorSnippet = false,
rawAppRunnableKey = undefined,
preparedAssetsSqlQueries,
customTag,
schemaContractMarkers = []
}: Props = $props()
$effect.pre(() => {
if (websocketAlive == undefined) {
websocketAlive = {
pyright: false,
ruff: false,
deno: false,
go: false,
shellcheck: false
}
}
})
let lang = $state(scriptLangToEditorLang(untrack(() => scriptLang)))
// On a postgres script targeting a datatable, DDL statements are intercepted
// on run (cmd+enter) and offered as migrations instead.
let datatableForMigrations = $derived(
scriptLang === 'postgresql' &&
typeof args?.database === 'string' &&
args.database.startsWith('datatable://')
? args.database.slice('datatable://'.length).split('/')[0]
: undefined
)
let ddlGuard = $state<DdlMigrationGuard | undefined>(undefined)
// Run the DDL migration guard against the current code. Returns false when the
// user cancels (the run must be aborted); may rewrite the code (migrated
// statements stripped). Exported so run paths that bypass the Monaco
// Cmd+Enter binding (e.g. the Test button) can guard too.
export async function guardDdlBeforeRun(): Promise<boolean> {
if (datatableForMigrations && ddlGuard) {
const res = await ddlGuard.guard(getCode())
if (!res.proceed) return false
if (res.code !== getCode()) setCode(res.code)
}
return true
}
async function runCmdEnterWithDdlGuard() {
if (!(await guardDdlBeforeRun())) return
cmdEnterAction?.()
}
let filePath = $state(computePath(untrack(() => path)))
let initialPath: string | undefined = $state(untrack(() => path))
let websockets: WebSocket[] = []
let languageClients: MonacoLanguageClient[] = []
let websocketInterval: number | undefined
let lastWsAttempt: Date = new Date()
let nbWsAttempt = 0
let disposeMethod: (() => void) | undefined
const absolutePathExtraLibs = new Map<string, { dispose: () => void }>()
const dispatch = createEventDispatcher()
// let graphqlService: MonacoGraphQLAPI | undefined = undefined
let dbSchema: DBSchema | undefined = $state(undefined)
let destroyed = false
const uri = computeUri(
untrack(() => filePath),
untrack(() => scriptLang)
)
console.log('uri', uri)
function computeUri(filePath: string, scriptLang: string | undefined) {
let file
if (filePath.includes('.')) {
file = filePath
} else {
file = `${filePath}.${scriptLang == 'tsx' ? 'tsx' : langToExt(lang)}`
}
if (file.startsWith('/')) {
file = file.slice(1)
}
return !['deno', 'go', 'python3'].includes(scriptLang ?? '')
? `file:///${file}`
: `file:///tmp/monaco/${file}`
}
function computePath(path: string | undefined): string {
if (
['deno', 'go', 'python3'].includes(scriptLang ?? '') ||
path == '' ||
path == undefined //||path.startsWith('/')
) {
return randomHash()
} else {
// console.log('path', path)
return path as string
}
}
export function switchToFile(path: string, value: string, lang: string) {
if (editor) {
const uri = mUri.parse(path)
console.log('switching to file', path, lang)
// vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(value))
let nmodel = meditor.getModel(uri)
if (nmodel) {
console.log('using existing model', path)
editor.setModel(nmodel)
} else {
console.log('creating model', path)
nmodel = meditor.createModel(value, lang, uri)
editor.setModel(nmodel)
}
model = nmodel
setTypescriptExtraLibs()
}
}
let valueAfterDispose: string | undefined = undefined
export function getCode(): string {
if (valueAfterDispose != undefined) {
return valueAfterDispose
}
return editor?.getValue() ?? ''
}
export function getModel(): meditor.IEditorModel | undefined {
return editor?.getModel() ?? undefined
}
export function insertAtCursor(code: string): void {
if (editor) {
editor.trigger('keyboard', 'type', { text: code })
}
}
export function insertAtCurrentLine(code: string): void {
if (editor) {
insertAtLine(code, editor.getPosition()?.lineNumber ?? 0)
}
}
export function arrowDown(): void {
if (editor) {
let pos = editor.getPosition()
if (pos) {
editor.setPosition({ lineNumber: pos.lineNumber + 1, column: pos.column })
}
}
}
export function backspace(): void {
if (editor) {
editor.trigger('keyboard', 'deleteLeft', {})
}
}
export function insertAtBeginning(code: string): void {
if (editor) {
const range = { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }
const op = { range: range, text: code, forceMoveMarkers: true }
editor.executeEdits('external', [op])
}
}
export function insertAtLine(code: string, line: number): void {
if (editor) {
const range = { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 }
const op = { range: range, text: code, forceMoveMarkers: true }
editor.executeEdits('external', [op])
}
}
export function getSelectedLines(): string | undefined {
if (editor) {
const selection = editor.getSelection()
if (selection) {
const range: IRange = {
startLineNumber: selection.startLineNumber,
startColumn: 1,
endLineNumber: selection.endLineNumber + 1,
endColumn: 1
}
return editor.getModel()?.getValueInRange(range)
}
}
}
export function onDidChangeCursorSelection(f: (e: meditor.ICursorSelectionChangedEvent) => void) {
if (editor) {
return editor.onDidChangeCursorSelection(f)
}
}
export function show(): void {
divEl?.classList.remove('hidden')
}
export function hide(): void {
divEl?.classList.add('hidden')
}
export function setCode(ncode: string, noHistory: boolean = false): void {
// Track whether the code actually changed before updating.
const changed = code != ncode
if (changed) {
code = ncode
}
// setCode is an authoritative overwrite (reset, AI apply, module switch).
// Cancel any in-flight keystroke debounce first: otherwise alignCodeWithEditor
// skips on the `timeoutModel` guard (leaving Monaco stale), and the pending
// updateCode later reads the old buffer and writes it back over `ncode`.
cancelPendingChanges()
alignCodeWithEditor(!noHistory)
// Dispatch change immediately when code actually changed. This ensures
// callers like the Reset button and copilot trigger on:change handlers.
// The debounced onDidChangeModelContent handler will no-op since code
// will already match by the time it fires.
if (changed) {
dispatch('change', ncode)
}
updateRawAppLintDiagnostics()
}
/** Collect Monaco markers and update the raw app lint store */
function updateRawAppLintDiagnostics(): void {
if (!rawAppRunnableKey || !model) return
const markers = meditor.getModelMarkers({ resource: model.uri })
const lintErrors: MonacoLintError[] = markers
.filter((m) => m.severity === MarkerSeverity.Error || m.severity === MarkerSeverity.Warning)
.map((m) => ({
message: m.message,
severity: m.severity === MarkerSeverity.Error ? 'error' : 'warning',
startLineNumber: m.startLineNumber,
startColumn: m.startColumn,
endLineNumber: m.endLineNumber,
endColumn: m.endColumn
}))
rawAppLintStore.setDiagnostics(rawAppRunnableKey, lintErrors)
}
function updateCode() {
const ncode = getCode()
if (code == ncode) {
return
}
code = ncode
lastEditorCode = ncode
dispatch('change', ncode)
}
/** Force-materialize the latest Monaco content into the bindable
* `code` prop right now, bypassing the trailing debounce. Use for
* explicit "save now" shortcuts (Ctrl/Cmd+S) — without this, anything
* the user typed within the last `changeTimeout` ms is still sitting
* in Monaco's buffer and downstream consumers (autosave, lint) won't
* see it. Clears the chain state so the next keystroke after this
* flush is a fresh leading fire. */
export function flushPendingChanges(): void {
cancelPendingChanges()
updateCode()
}
/** Discard any in-flight keystroke debounce without materializing it, so a
* deferred updateCode can't fire later. Resets chain state to a fresh leading
* fire on the next keystroke. */
function cancelPendingChanges(): void {
if (timeoutModel !== undefined) {
clearTimeout(timeoutModel)
timeoutModel = undefined
}
changeChainStart = undefined
}
export function append(code: string): void {
if (editor) {
const lineCount = editor.getModel()?.getLineCount() || 0
const lastLineLength = editor.getModel()?.getLineLength(lineCount) || 0
const range: IRange = {
startLineNumber: lineCount,
startColumn: lastLineLength + 1,
endLineNumber: lineCount,
endColumn: lastLineLength + 1
}
editor.executeEdits('append', [
{
range,
text: code,
forceMoveMarkers: true
}
])
editor.revealLine(lineCount)
}
}
export async function format() {
if (editor) {
updateCode()
if (lang != 'shell' && lang != 'nu') {
if ($formatOnSave != false) {
if (scriptLang == 'deno' && languageClients.length > 0) {
languageClients.forEach(async (x) => {
let edits = await x.sendRequest(new RequestType('textDocument/formatting'), {
textDocument: { uri },
options: {
tabSize: 2,
insertSpaces: true
}
})
console.debug(edits)
if (Array.isArray(edits)) {
edits = edits.map((edit) =>
edit.range.start != undefined &&
edit.range.end != undefined &&
edit.newText != undefined
? {
range: {
startLineNumber: edit.range.start.line + 1,
startColumn: edit.range.start.character + 1,
endLineNumber: edit.range.end.line + 1,
endColumn: edit.range.end.character + 1
},
text: edit.newText
}
: {}
)
//@ts-ignore
editor?.executeEdits('fmt', edits)
}
})
} else {
await editor?.getAction('editor.action.formatDocument')?.run()
}
}
updateCode()
}
if (formatAction) {
formatAction()
}
}
}
export function getScriptLang(): string | undefined {
return scriptLang
}
export function getEditor(): meditor.IStandaloneCodeEditor | null {
return editor
}
/** Get lint errors and warnings from the Monaco editor */
export function getLintErrors(): ScriptLintResult {
if (!model) {
return { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
}
const markers = meditor.getModelMarkers({ resource: model.uri })
const errors = markers.filter((m) => m.severity === MarkerSeverity.Error)
const warnings = markers.filter((m) => m.severity === MarkerSeverity.Warning)
return {
errorCount: errors.length,
warningCount: warnings.length,
errors,
warnings
}
}
let command: IDisposable | undefined = undefined
let sqlTypeCompletor: IDisposable | undefined = $state(undefined)
let resultCollectionCompletor: IDisposable | undefined = $state(undefined)
function addSqlTypeCompletions() {
sqlTypeCompletor?.dispose()
resultCollectionCompletor?.dispose()
resultCollectionCompletor = languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['='],
provideCompletionItems: function (model, position) {
const lineContent = model.getLineContent(position.lineNumber)
const match = lineContent.match(/^--\s*result_collection=/)
if (!match) {
return { suggestions: [] }
}
const word = model.getWordUntilPosition(position)
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
}
const suggestions = [
'last_statement_all_rows',
'last_statement_first_row',
'last_statement_all_rows_scalar',
'last_statement_first_row_scalar',
'all_statements_all_rows',
'all_statements_first_row',
'all_statements_all_rows_scalar',
'all_statements_first_row_scalar'
].map((label) => ({
label: label,
kind: languages.CompletionItemKind.Function,
insertText: label,
range,
sortText: 'a'
}))
return { suggestions }
}
})
sqlTypeCompletor = languages.registerCompletionItemProvider('sql', {
triggerCharacters: scriptLang === 'postgresql' ? [':'] : ['('],
provideCompletionItems: function (model, position) {
const lineUntilPosition = model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column
})
let suggestions: languages.CompletionItem[] = []
if (
scriptLang === 'postgresql'
? lineUntilPosition.endsWith('::')
: lineUntilPosition.match(/^-- .* \(/)
) {
const word = model.getWordUntilPosition(position)
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
}
suggestions = (
scriptLang === 'postgresql'
? POSTGRES_TYPES
: scriptLang === 'mysql'
? MYSQL_TYPES
: scriptLang === 'snowflake'
? SNOWFLAKE_TYPES
: scriptLang === 'bigquery'
? BIGQUERY_TYPES
: scriptLang === 'mssql'
? MSSQL_TYPES
: scriptLang === 'oracledb'
? ORACLEDB_TYPES
: scriptLang === 'duckdb'
? DUCKDB_TYPES
: []
).map((t) => ({
label: t,
kind: languages.CompletionItemKind.Function,
insertText: t,
range: range,
sortText: 'a'
}))
}
return {
suggestions
}
}
})
}
let workspaceMacroCompletor: IDisposable | undefined = undefined
// Workspace DuckDB macros (deployed `// macros` libraries): suggest each
// macro while typing an identifier, with its signature + body as docs and
// a snippet insert that parks the cursor inside the call parens. Fetched
// via a short-TTL cache — macros are late-bound, so mild staleness is fine.
async function addWorkspaceMacroCompletions() {
workspaceMacroCompletor?.dispose()
const workspace = $workspaceStore
if (!workspace) return
let macros: Awaited<ReturnType<typeof listWorkspaceMacrosCached>> = []
try {
macros = await listWorkspaceMacrosCached(workspace)
} catch (e) {
console.error('error listing workspace macros', e)
return
}
if (macros.length === 0) return
workspaceMacroCompletor = languages.registerCompletionItemProvider('sql', {
provideCompletionItems: function (model, position) {
const word = model.getWordUntilPosition(position)
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
}
const suggestions = macros.map((m) => ({
label: `${m.name}(${m.params})`,
kind: m.is_table
? languages.CompletionItemKind.Interface
: languages.CompletionItemKind.Function,
detail: `${m.is_table ? 'table macro' : 'macro'} · ${m.provider_path}`,
documentation: {
value: '```sql\n' + macroDefinitionSql(m) + '\n```'
},
insertText: `${m.name}($0)`,
insertTextRules: languages.CompletionItemInsertTextRule.InsertAsSnippet,
filterText: m.name,
range,
sortText: 'b' + m.name
}))
return { suggestions }
}
})
}
let schemaContractCompletor: IDisposable | undefined = undefined
// Column-name completion for pipeline annotation refs (`// column out <-
// ducklake://lake/orders.|`, `// data_test relationships col ->
// ducklake://lake/customers.|`): suggests the referenced asset's *captured*
// columns (with types) so a broken ref never gets typed — the prevention
// side of the schema-contract check. Only fires on annotation comment lines
// with a ducklake URI right before the cursor's `.`; schemas come from the
// short-TTL contract cache, so per-keystroke cost is a map lookup.
function addSchemaContractCompletions() {
schemaContractCompletor?.dispose()
schemaContractCompletor = languages.registerCompletionItemProvider(lang, {
triggerCharacters: ['.'],
provideCompletionItems: async function (model, position) {
// Read the store per request, not at registration — the provider
// outlives a workspace switch.
const workspace = $workspaceStore
if (!workspace) return { suggestions: [] }
const before = model.getLineContent(position.lineNumber).slice(0, position.column - 1)
if (!/^\s*(\/\/|--|#)\s*(column|data_test|on|materialize)\b/.test(before)) {
return { suggestions: [] }
}
const uri = before.match(/ducklake:\/\/([\w/.{}-]+?)\.$/)
if (!uri) return { suggestions: [] }
const schema = await fetchLatestSchema(workspace, normalizeAssetPath(uri[1]))
if (!schema) return { suggestions: [] }
const word = model.getWordUntilPosition(position)
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
}
return {
suggestions: schema.columns.map((c) => ({
label: c.name,
kind: languages.CompletionItemKind.Field,
detail: `${c.type} · captured schema v${schema.version}`,
insertText: c.name,
range,
sortText: 'a' + c.name
}))
}
}
})
}
let sqlSchemaCompletor: IDisposable | undefined = undefined
async function updateSchema(newSchemaRes: string | undefined) {
if (typeof newSchemaRes === 'string') {
const resourcePath = newSchemaRes.replace('$res:', '')
dbSchema = $dbSchemas[resourcePath]
if (dbSchema === undefined) {
$dbSchemas[resourcePath] = await getDbSchemas(
lang === 'graphql' ? 'graphql' : (scriptLang ?? ''),
resourcePath,
$workspaceStore,
(e) => console.error(`error getting ${lang} (${scriptLang}) db schema`, e),
{ customTag }
)
}
dbSchema = $dbSchemas[resourcePath]
} else {
dbSchema = undefined
}
}
function disposeSqlSchemaCompletor() {
sqlSchemaCompletor?.dispose()
}
function disposeGaphqlService() {
// graphqlService = undefined
}
function addDBSchemaCompletions() {
const { lang: schemaLang, schema } = dbSchema || {}
if (!schemaLang || !schema) {
return
}
console.log('adding db schema completions', schemaLang)
if (schemaLang === 'graphql') {
//graphql depreciated until https://github.com/graphql/graphiql/issues/4104 is fixed with monaco > 0.52.2
// languages.register({ id: 'graphql' })
// graphqlService ||= initializeMode()
// console.log('setting schema config', schema)
// graphqlService?.setSchemaConfig([
// {
// uri: 'my-schema.graphql',
// introspectionJSON: schema
// }
// ])
} else {
if (sqlSchemaCompletor) {
sqlSchemaCompletor.dispose()
}
sqlSchemaCompletor = languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.', ' ', '('],
provideCompletionItems: function (model, position) {
const textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column
})
const word = model.getWordUntilPosition(position)
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
}
let suggestions: languages.CompletionItem[] = []
const noneMatch = textUntilPosition.match(/(?:add|create table)\s/i)
if (noneMatch) {
return {
suggestions
}
}
for (const schemaKey in schema) {
suggestions.push({
label: schemaKey,
detail: 'schema',
kind: languages.CompletionItemKind.Function,
insertText: schemaKey,
range: range,
sortText: 'z'
})
for (const tableKey in schema[schemaKey]) {
suggestions.push({
label: tableKey,
detail: `table (${schemaKey})`,
kind: languages.CompletionItemKind.Function,
insertText: tableKey,
range: range,
sortText: 'y'
})
const noColsMatch = textUntilPosition.match(
/(?:from|insert into|update|table)\s(?![\s\S]*(\b(where|order by|group by|values|set|column)\b|\())/i
)
if (!noColsMatch) {
for (const columnKey in schema[schemaKey][tableKey]) {
suggestions.push({
label: columnKey,
detail: `${schema[schemaKey][tableKey][columnKey]['type']} (${schemaKey}.${tableKey})`,
kind: languages.CompletionItemKind.Function,
insertText: columnKey,
range: range,
sortText: 'x'
})
}
}
if (textUntilPosition.match(new RegExp(`${tableKey}.$`, 'i'))) {
suggestions = suggestions.filter((x) =>
x.detail?.includes(`(${schemaKey}.${tableKey})`)
)
return {
suggestions
}
}
}
if (textUntilPosition.match(new RegExp(`${schemaKey}.$`, 'i'))) {
suggestions = suggestions.filter((x) => x.detail === `table (${schemaKey})`)
return {
suggestions
}
}
}
return {
suggestions
}
}
})
}
}
let preprocessorCompletor: IDisposable | undefined = undefined
function addPreprocessorCompletions(lang: string) {
if (preprocessorCompletor) {
preprocessorCompletor.dispose()
}
const windmillLang = lang === 'typescript' ? 'deno' : lang === 'python' ? 'python3' : lang
const preprocessorCode = getPreprocessorModuleCode(windmillLang as ScriptLang)
if (!preprocessorCode) {
return
}
preprocessorCompletor = languages.registerCompletionItemProvider(lang, {
provideCompletionItems: function (model, position) {
const word = model.getWordUntilPosition(position)
if (word.word.length >= 3 && 'preprocessor'.startsWith(word.word)) {
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
}
return {
suggestions: [
{
label: 'preprocessor (windmill)',
kind: languages.CompletionItemKind.Function,
insertTextRules: languages.CompletionItemInsertTextRule.InsertAsSnippet,
insertText: preprocessorCode,
range,
additionalTextEdits: [
{
range: {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: 0,
endColumn: word.startColumn
},
text: ''
}
]
}
]
}
}
return {
suggestions: []
}
}
})
}
let reviewingChanges = $state(writable(false))
let aiChatEditorHandler: AIChatEditorHandler | undefined = $state(undefined)
// Inline ai chat widget
let showInlineAIChat = $state(false)
let inlineAIChatSelection: Selection | null = $state(null)
let selectedCode = $state('')
export async function reviewAndApplyCode(code: string, opts?: ReviewChangesOpts) {
await aiChatEditorHandler?.reviewChanges(code, opts)
}
export async function reviewAppliedCode(
originalCode: string,
opts?: { onFinishedReview?: () => void }
) {
await aiChatEditorHandler?.reviewChanges(originalCode, {
mode: 'revert',
onFinishedReview: opts?.onFinishedReview
})
}
export function getAiChatEditorHandler() {
return aiChatEditorHandler
}
function addChatHandler(editor: meditor.IStandaloneCodeEditor) {
try {
aiChatEditorHandler = new AIChatEditorHandler(editor)
reviewingChanges = aiChatEditorHandler.reviewingChanges
} catch (err) {
console.error('Could not add chat handler', err)
}
}
let autocompletor: Autocompletor | undefined = $state(undefined)
function addAutoCompletor(
editor: meditor.IStandaloneCodeEditor,
scriptLang: ScriptLang | 'bunnative' | 'jsx' | 'tsx' | 'json',
workflowAsCode: boolean
) {
if (autocompletor) {
autocompletor.dispose()
}
autocompletor = new Autocompletor(editor, scriptLang, { workflowAsCode })
}
const outputChannel = {
name: 'Language Server Client',
appendLine: (msg: string) => {
console.log(msg)
},
append: (msg: string) => {
console.log(msg)
},
clear: () => {},
replace: () => {},
show: () => {},
hide: () => {},
dispose: () => {}
}
export async function reloadWebsocket() {
await closeWebsockets()
if (
!useWebsockets ||
!(
(lang == 'typescript' && scriptLang === 'deno') ||
lang == 'python' ||
lang == 'go' ||
lang == 'shell'
)
) {
return
}
console.log('reloadWebsocket')
function createLanguageClient(
transports: MessageTransports,
name: string,
initializationOptions: any,
middlewareOptions: ((params, token, next) => any) | undefined
) {
const client = new MonacoLanguageClient({
name: name,
messageTransports: transports,
clientOptions: {
outputChannel,
documentSelector: [lang],
errorHandler: {
error: () => ({ action: ErrorAction.Continue }),
closed: () => ({
action: CloseAction.Restart
})
},
markdown: {
isTrusted: true
},
workspaceFolder:
name != 'deno'
? {
uri: vscode.Uri.parse(uri),
name: 'windmill',
index: 0
}
: undefined,
initializationOptions,
middleware: {
workspace: {
configuration:
middlewareOptions ??
((params, token, next) => {
return [{ enabled: true }]
})
}
}
}
})
return client
}
async function connectToLanguageServer(
url: string,
name: string,
initOptions: any,
middlewareOptions: any
) {
try {
const webSocket = new WebSocket(url)
websockets.push(webSocket)
webSocket.onopen = async () => {
const socket = toSocket(webSocket)
const reader = new WebSocketMessageReader(socket)
const writer = new WebSocketMessageWriter(socket)
const languageClient = createLanguageClient(
{ reader, writer },
name,
initOptions,
middlewareOptions
)
// if (middlewareOptions != undefined) {
// languageClient.registerNotUsedFeatures()
// }
const om = webSocket.onmessage
webSocket.onmessage = (e) => {
om && om.apply(webSocket, [e])
if (destroyed) {
webSocket.close()
console.log('Stopping client early because of mismatch')
}
}
languageClients.push(languageClient)
// HACK ALERT: for some reasons, the client need to be restarted to take into account the 'go get <dep>' command
// the only way I could figure out to listen for this event is this. I'm sure there is a better way to do this
if (name == 'go') {
const om = webSocket.onmessage
webSocket.onmessage = (e) => {
om && om.apply(webSocket, [e])
const js = JSON.parse(e.data)
if (js.method == 'window/showMessage' && js.params.message == 'completed') {
console.log('reloading websocket after go get')
reloadWebsocket()
}
}
}
reader.onClose(async () => {
try {
console.log('CLOSE')
websocketAlive[name] = false
await languageClient.stop()
} catch (err) {
console.error(err)
}
})
socket.onClose((_code, _reason) => {
websocketAlive[name] = false
})
try {
console.log('starting client')
await languageClient.start()
// for python we want to use the pyright client for signature help, not ruff
if (lang !== 'python' || (lang === 'python' && name == 'pyright')) {
autocompletor?.setLanguageClient(languageClient)
}
console.log('started client')
} catch (err) {
console.log('err at client')
console.error(err)
return
}
lastWsAttempt = new Date()
nbWsAttempt = 0
if (name == 'deno') {
command && command.dispose()
command = undefined
try {
command = vscode.commands.registerCommand(
'deno.cache',
(uris: DocumentUri[] = []) => {
languageClient.sendRequest(new RequestType('deno/cache'), {
referrer: { uri },
uris: uris.map((uri) => ({ uri }))
})
}
)
} catch (err) {
console.warn(err)
}
}
websocketAlive[name] = true
}
} catch (err) {
console.error(`connection to ${name} language server failed`)
}
}
const hostname = getHostname()
let encodedImportMap = ''
if (useWebsockets) {
if (lang == 'typescript' && scriptLang === 'deno') {
ata = undefined
let root = await genRoot(hostname)
const importMap = {
imports: {
'file:///': root + '/'
}
}
if (filePath && filePath.split('/').length > 2) {
let path_splitted = filePath.split('/')
for (let c = 0; c < path_splitted.length; c++) {
let key = 'file://./'
for (let i = 0; i < c; i++) {
key += '../'
}
let url = path_splitted.slice(0, -c - 1).join('/')
let ending = c == path_splitted.length - 1 ? '' : '/'
importMap['imports'][key] = `${root}/${url}${ending}`
}
}
encodedImportMap = 'data:text/plain;base64,' + btoa(JSON.stringify(importMap))
await connectToLanguageServer(
buildWsUrl('/ws/deno'),
'deno',
{
certificateStores: null,
enablePaths: [],
config: null,
importMap: encodedImportMap,
internalDebug: false,
lint: false,
path: null,
tlsCertificate: null,
unsafelyIgnoreCertificateErrors: null,
unstable: true,
enable: true,
codeLens: {
implementations: true,
references: true,
referencesAllFunction: false
},
suggest: {
autoImports: true,
completeFunctionCalls: false,
names: true,
paths: true,
imports: {
autoDiscover: true,
hosts: {
'https://deno.land': true
}
}
}
},
() => {
return [
{
enable: true
}
]
}
)
} else if (lang === 'python') {
await connectToLanguageServer(
buildWsUrl('/ws/pyright'),
'pyright',
{},
(params, token, next) => {
if (params.items.find((x) => x.section === 'python')) {
return [
{
analysis: {
useLibraryCodeForTypes: true,
autoImportCompletions: true,
diagnosticSeverityOverrides: { reportMissingImports: 'none' },
typeCheckingMode: 'basic'
}
}
]
}
if (params.items.find((x) => x.section === 'python.analysis')) {
return [
{
useLibraryCodeForTypes: true,
autoImportCompletions: true,
diagnosticSeverityOverrides: { reportMissingImports: 'none' },
typeCheckingMode: 'basic'
}
]
}
return next(params, token)
}
)
connectToLanguageServer(buildWsUrl('/ws/ruff'), 'ruff', {}, undefined)
} else if (lang === 'go') {
connectToLanguageServer(
buildWsUrl('/ws/go'),
'go',
{
'build.allowImplicitNetworkAccess': true
},
undefined
)
} else if (lang === 'shell') {
connectToLanguageServer(
buildWsUrl('/ws/diagnostic'),
'shellcheck',
{
linters: {
shellcheck: {
command: 'shellcheck',
debounce: 100,
args: ['--format=gcc', '-'],
offsetLine: 0,
offsetColumn: 0,
sourceName: 'shellcheck',
formatLines: 1,
formatPattern: [
'^[^:]+:(\\d+):(\\d+):\\s+([^:]+):\\s+(.*)$',
{
line: 1,
column: 2,
message: 4,
security: 3
}
],
securities: {
error: 'error',
warning: 'warning',
note: 'info'
}
}
},
filetypes: {
shell: 'shellcheck'
}
},
undefined
)
} else {
closeWebsockets()
}
websocketInterval && clearInterval(websocketInterval)
websocketInterval = setInterval(() => {
if (document.visibilityState == 'visible') {
if (
!lastWsAttempt ||
(new Date().getTime() - lastWsAttempt.getTime() > 60000 && nbWsAttempt < 2)
) {
if (
!websocketAlive.deno &&
!websocketAlive.pyright &&
!websocketAlive.go &&
!websocketAlive.shellcheck &&
!websocketAlive.ruff &&
scriptLang != 'bun' &&
scriptLang != 'tsx'
) {
console.log('reconnecting to language servers')
lastWsAttempt = new Date()
nbWsAttempt++
reloadWebsocket()
} else {
if (nbWsAttempt >= 2) {
sendUserToast('Giving up on establishing smart assistant connection', true)
clearInterval(websocketInterval)
}
}
}
}
}, 5000)
}
}
let pathTimeout: number | undefined = undefined
let yPadding = MONACO_Y_PADDING
function getHostname() {
return BROWSER ? window.location.protocol + '//' + window.location.host : 'SSR'
}
function handlePathChange() {
console.log('path changed, reloading language server', initialPath, path)
initialPath = path
pathTimeout && clearTimeout(pathTimeout)
ata = undefined
pathTimeout = setTimeout(reloadWebsocket, 1000)
}
async function closeWebsockets() {
command && command.dispose()
command = undefined
console.debug(`disposing ${websockets.length} language clients and closing websockets`)
for (const x of languageClients) {
try {
await x.dispose()
} catch (err) {
console.debug('error disposing language client', err)
}
}
languageClients = []
for (const x of websockets) {
try {
await x.close()
} catch (err) {
console.debug('error closing websocket', err)
}
}
console.debug('done closing websockets')
websockets = []
websocketInterval && clearInterval(websocketInterval)
}
// let widgets: HTMLElement | undefined = document.getElementById('monaco-widgets-root') ?? undefined
let model: meditor.ITextModel | undefined = $state(undefined)
let monacoBinding: MonacoBinding | undefined = $state(undefined)
let initialized = $state(false)
let ata: ((s: string | DepsToGet) => void) | undefined = undefined
let statusDiv: Element | null = $state(null)
function saveDraft() {
dispatch('saveDraft', code)
}
let vimDisposable: IDisposable | undefined = $state(undefined)
function onVimDisable() {
vimDisposable?.dispose()
}
function onVimMode() {
if (editor && statusDiv) {
vimDisposable = initVim(editor, statusDiv, saveDraft)
}
}
let svelteRegistered = false
let vueRegistered = false
function onFileChanges() {
if (files && Object.keys(files).find((x) => x.endsWith('.svelte')) != undefined) {
if (!svelteRegistered) {
svelteRegistered = true
languages.register({
id: 'svelte',
extensions: ['.svelte'],
aliases: ['Svelte', 'svelte'],
mimetypes: ['application/svelte']
})
languages.setLanguageConfiguration('svelte', htmllang.conf as any)
languages.setMonarchTokensProvider('svelte', htmllang.language as any)
}
}
if (files && Object.keys(files).find((x) => x.endsWith('.vue')) != undefined) {
if (!vueRegistered) {
vueRegistered = true
languages.register({
id: 'vue',
extensions: ['.vue'],
aliases: ['Vue', 'Vue'],
mimetypes: ['application/svelte']
})
languages.setLanguageConfiguration('vue', conf as any)
languages.setMonarchTokensProvider('vue', language as any)
}
}
if (files && model) {
for (const [path, { code, readonly }] of Object.entries(files)) {
const luri = mUri.file(path)
if (luri.toString() != model.uri.toString()) {
let nmodel = meditor.getModel(luri)
if (nmodel == undefined) {
const lmodel = meditor.createModel(code, extToLang(path?.split('.')?.pop()!), luri)
if (readonly) {
lmodel.onDidChangeContent((evt) => {
// This will effectively undo any new edits
if (lmodel.getValue() != code && code) {
lmodel.setValue(code)
}
})
}
} else {
const lmodel = meditor.getModel(luri)
if (lmodel && code) {
lmodel.setValue(code)
}
}
}
}
}
}
let timeoutModel: number | undefined = undefined
/** Wall-clock start (ms) of the current debounce chain. Reset whenever
* the trailing fire lands — so a typing burst → pause → typing burst
* gets a fresh leading fire instead of inheriting the previous cap. */
let changeChainStart: number | undefined = undefined
async function loadMonaco() {
setMonacoTypescriptOptions()
console.log('path', uri)
try {
console.log("Loading Monaco's language client")
await initializeVscode('editor', divEl!)
console.log('done loading Monaco and vscode')
} catch (e) {
console.log('error initializing services', e)
}
// vscode.languages.registerDefinitionProvider('*', {
// provideDefinition(document, position, token) {
// // Get the word under the cursor (this will be the import or function being clicked)
// const wordRange = document.getWordRangeAtPosition(position)
// const word = document.getText(wordRange)
// // Do something with the word (for example, log it or handle it)
// console.log('Clicked on import or symbol:', word)
// // Optionally, you can also return a definition location
// return null // If you don't want to override the default behavior
// }
// })
// console.log('bef ready')
// console.log('af ready')
initialized = true
try {
model = meditor.createModel(code ?? '', lang == 'nu' ? 'python' : lang, mUri.parse(uri))
} catch (err) {
console.log('model already existed', err)
const nmodel = meditor.getModel(mUri.parse(uri))
if (!nmodel) {
throw err
}
model = nmodel
}
model.updateOptions(lang == 'python' ? { tabSize: 4, insertSpaces: true } : updateOptions)
onFileChanges()
try {
editor = meditor.create(divEl as HTMLDivElement, {
...editorConfig(
code ?? '',
lang,
automaticLayout,
fixedOverflowWidgets,
$relativeLineNumbers
),
model,
fontSize: small ? editorFontSize.small : editorFontSize.regular,
lineNumbersMinChars,
// overflowWidgetsDomNode: widgets,
tabSize: lang == 'python' ? 4 : 2,
folding,
padding: { bottom: yPadding, top: yPadding }
})
if (key && editorPositionMap?.[key]) {
editor.setPosition(editorPositionMap[key])
editor.revealPositionInCenterIfOutsideViewport(editorPositionMap[key])
}
} catch (e) {
console.error('Error loading monaco:', e)
return
}
keepModelAroundToAvoidDisposalOfWorkers()
// In VSCode webview (iframe), clipboard operations need special handling
// because the webview has restricted clipboard API access
if (window.parent !== window) {
editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyC, function () {
document.execCommand('copy')
})
editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyX, function () {
document.execCommand('cut')
})
// Paste is scoped to this editor's container instead of a global
// Ctrl+V keybinding, which would leak across editor instances.
pasteCleanup?.()
pasteCleanup = registerWebviewPaste(divEl, () => editor)
}
// updateEditorKeybindingsMode(editor, 'vim', undefined)
// Raw app lint collection: listen for marker changes and report to store
let markerChangeDisposable: IDisposable | undefined = undefined
if (rawAppRunnableKey && model) {
markerChangeDisposable = meditor.onDidChangeMarkers((uris) => {
if (!model || !rawAppRunnableKey) return
const modelUri = model.uri.toString()
if (uris.some((u) => u.toString() === modelUri)) {
updateRawAppLintDiagnostics()
}
})
// Initial lint diagnostics collection
updateRawAppLintDiagnostics()
}
let ataModel: number | undefined = undefined
editor?.onDidChangeModelContent((event) => {
// Leading fire on the first keystroke of a burst: every
// downstream consumer (autosave's 1.5s debouncer, the
// `bind:code` chain, change listeners) sees text within the
// same tick instead of after `changeTimeout` ms of silence.
// Subsequent keystrokes within the burst are trailing-only
// (debounced by `changeTimeout`), with a hard ceiling at
// `chainStart + maxChangeTimeout` so continuous typing still
// materializes at least once per `maxChangeTimeout` window.
const now = Date.now()
if (changeChainStart === undefined) {
updateCode()
changeChainStart = now
}
timeoutModel && clearTimeout(timeoutModel)
const fireAt = Math.min(now + changeTimeout, changeChainStart + maxChangeTimeout)
timeoutModel = setTimeout(
() => {
updateCode()
timeoutModel = undefined
changeChainStart = undefined
},
Math.max(0, fireAt - now)
)
ataModel && clearTimeout(ataModel)
ataModel = setTimeout(() => {
if (scriptLang == 'bun' || scriptLang == 'bunnative') {
ata?.(getCode())
}
}, 1000)
})
editor?.onDidBlurEditorText(() => {
dispatch('blur')
})
editor?.onDidChangeCursorPosition((event) => {
if (key) editorPositionMap[key] = event.position
})
editor?.onDidFocusEditorText(() => {
dispatch('focus')
// for escape we use onkeydown instead of addCommand because addCommand on escape specifically prevents default behavior (like autocomplete cancellation)
editor?.onKeyDown((e) => {
if (e.keyCode === KeyCode.Escape) {
if (showInlineAIChat) {
closeAIInlineWidget()
}
aiChatEditorHandler?.rejectAll()
}
})
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.DownArrow, function () {
if (aiChatManager.pendingNewCode) {
aiChatManager.scriptEditorApplyCode?.(aiChatManager.pendingNewCode)
if (showInlineAIChat) {
closeAIInlineWidget()
}
}
})
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () {
updateCode()
shouldBindKey && format && format()
// Monaco swallows the keydown (addCommand prevents default and
// stops propagation), so page-level Ctrl/Cmd+S handlers never
// see it. Re-broadcast as a window event so editors that flush
// a draft on the shortcut (raw apps) can react regardless of
// which Monaco has focus.
window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut'))
})
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () {
updateCode()
if (!shouldBindKey || !cmdEnterAction) return
void runCmdEnterWithDdlGuard()
})
editor?.addCommand(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit7, function () {
// CMD + slash (toggle comment) on some EU keyboards
editor?.trigger('keyboard', 'editor.action.commentLine', {})
})
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyL, function () {
const selectedLines = getSelectedLines()
const selection = editor?.getSelection()
const hasSelection =
selection &&
(selection.startLineNumber !== selection.endLineNumber ||
selection.startColumn !== selection.endColumn)
if (hasSelection && selectedLines) {
aiChatManager.addSelectedLinesToContext(
selectedLines,
selection.startLineNumber,
selection.endLineNumber,
moduleId
)
} else {
aiChatManager.toggleOpen()
aiChatManager.focusInput()
}
})
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyK, function () {
if ($copilotInfo.enabled) {
aiChatEditorHandler?.rejectAll()
if (showInlineAIChat) {
closeAIInlineWidget()
} else {
showAIInlineWidget()
}
}
})
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyU, function () {
dispatch('toggleTestPanel')
})
if (
!websocketAlive.deno &&
!websocketAlive.pyright &&
!websocketAlive.ruff &&
!websocketAlive.shellcheck &&
!websocketAlive.go &&
!websocketInterval
) {
reloadWebsocket()
}
})
reloadWebsocket()
setTypescriptExtraLibs()
setTypescriptRTNamespace()
return () => {
console.log('disposing editor')
ata = undefined
try {
closeWebsockets()
vimDisposable?.dispose()
closeAIInlineWidget()
markerChangeDisposable?.dispose()
// Note: We don't clear lint diagnostics on dispose - they persist across runnable switches
// Diagnostics are only updated when Monaco reports new markers for this runnable
console.log('disposing editor')
model?.dispose()
editor && editor.dispose()
console.log('disposed editor')
} catch (err) {
console.log('error disposing editor', err)
}
}
}
export async function fetchPackageDeps(deps: DepsToGet) {
ata?.(deps)
}
let customTsTypesData = resource([() => lang], async () => {
if (lang !== 'typescript') return undefined
let datatables = (
await WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' })
).map((d) => d.name)
let ducklakes = await WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? '' })
return { datatables, ducklakes }
})
function setTypescriptCustomTypes() {
if (!customTsTypesData.current) return
if (lang !== 'typescript') return
const ducklakeNames = customTsTypesData.current.ducklakes
const datatableNames = customTsTypesData.current.datatables
const ducklakeNameType = ducklakeNames.length
? ducklakeNames.map((name) => JSON.stringify(name)).join(' | ')
: 'string'
const datatableNameType = datatableNames.length
? datatableNames.map((name) => JSON.stringify(name)).join(' | ')
: 'string'
const isDucklakeOptional = ducklakeNames.includes('main')
const isDataTableOptional = datatableNames.includes('main')
let disposeTs = typescriptDefaults.addExtraLib(
`export {};
declare module 'windmill-client' {
import { type DatatableSqlTemplateFunction, type SqlTemplateFunction } from 'windmill-client';
export function ducklake(name${isDucklakeOptional ? '?' : ''}: ${ducklakeNameType}): SqlTemplateFunction;
export function datatable(name${isDataTableOptional ? '?' : ''}: ${datatableNameType}): DatatableSqlTemplateFunction;
}`,
'file:///custom_wmill_types.d.ts'
)
return () => {
disposeTs.dispose()
}
}
async function setTypescriptRTNamespace() {
if (
scriptLang &&
(scriptLang === 'bun' ||
scriptLang === 'deno' ||
scriptLang === 'bunnative' ||
scriptLang === 'nativets')
) {
const resourceTypes = await ResourceService.listResourceType({
workspace: $workspaceStore ?? ''
})
const namespace = formatResourceTypes(
resourceTypes,
scriptLang === 'bunnative' ? 'bun' : scriptLang
)
typescriptDefaults.addExtraLib(namespace, 'rt.d.ts')
}
}
async function setTypescriptExtraLibs() {
if (extraLib) {
const uri = mUri.parse('file:///extraLib.d.ts')
typescriptDefaults.addExtraLib(extraLib, uri.toString())
}
if (
lang === 'typescript' &&
(scriptLang == 'bun' || scriptLang == 'tsx' || scriptLang == 'bunnative') &&
ata == undefined
) {
absolutePathExtraLibs.forEach((d) => d.dispose())
absolutePathExtraLibs.clear()
const hostname = getHostname()
const addLibraryToRuntime = async (code: string, _path: string) => {
const path = 'file://' + _path
let uri = mUri.parse(path)
console.log('adding library to runtime', path)
typescriptDefaults.addExtraLib(code, path)
try {
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(code))
} catch (e) {
console.log('error writing file', e)
}
}
const addLocalFile = async (code: string, _path: string) => {
if (destroyed) return
let p = new URL(_path, uri).href
let nuri = mUri.parse(p)
console.log('adding local file', _path, nuri.toString())
// Monaco's TS service resolves relative imports against the importer's URI (finding the
// model), but absolute paths like "/u/admin/foo" are looked up as raw paths and miss the
// `file://` model. Register them as extra libs so TS can resolve them.
if (_path.startsWith('/')) {
absolutePathExtraLibs.get(_path)?.dispose()
absolutePathExtraLibs.set(_path, typescriptDefaults.addExtraLib(code, _path))
}
if (editor) {
let localModel = meditor.getModel(nuri)
if (localModel) {
localModel.setValue(code)
} else {
meditor.createModel(code, 'typescript', nuri)
}
try {
if (model) {
model?.setValue(model.getValue())
}
} catch (e) {
console.log('error resetting model', e)
}
}
}
await initWasmTs()
const root = await genRoot(hostname)
console.log('SETUP TYPE ACQUISITION', { root, path })
ata = setupTypeAcquisition({
projectName: 'Windmill',
depsParser: (c) => {
return parseTypescriptDeps(c)
},
root,
scriptPath: path,
logger: console,
delegate: {
receivedFile: addLibraryToRuntime,
localFile: addLocalFile,
progress: (downloaded: number, total: number) => {
// console.log({ dl, ttl })
},
started: () => {
console.log('ATA start')
},
finished: (f) => {
console.log('ATA done')
}
}
})
if (scriptLang == 'bun') {
ata?.('import "bun-types"')
}
if (scriptLang == 'bunnative' || scriptLang == 'bun') {
ata?.(code ?? '')
}
dispatch('ataReady')
}
}
export function addAction(
id: string,
label: string,
callback: (editor: meditor.IStandaloneCodeEditor) => void,
keybindings: number[] = []
) {
editor?.addAction({
id,
label,
keybindings,
contextMenuGroupId: 'navigation',
run: function (editor: meditor.IStandaloneCodeEditor) {
callback(editor)
}
})
}
function showAIInlineWidget() {
if (!editor) return
inlineAIChatSelection = editor.getSelection()
if (!inlineAIChatSelection) {
return
}
const model = editor.getModel()
selectedCode = ''
if (model) {
selectedCode = model.getValueInRange(inlineAIChatSelection)
}
showInlineAIChat = true
aiChatInlineWidget?.focusInput()
}
function closeAIInlineWidget() {
showInlineAIChat = false
inlineAIChatSelection = null
selectedCode = ''
}
let aiChatInlineWidget: AIChatInlineWidget | null = $state(null)
$effect(() => {
const fontSize = small ? editorFontSize.small : editorFontSize.regular
if (editor) {
editor.updateOptions({ fontSize })
}
})
let loadTimeout: number | undefined = undefined
onMount(async () => {
if (BROWSER) {
if (loadAsync) {
loadTimeout = setTimeout(() => loadMonaco().then((x) => (disposeMethod = x)), 0)
} else {
let m = await loadMonaco()
disposeMethod = m
}
}
})
onDestroy(() => {
console.log('destroying editor')
valueAfterDispose = getCode()
pasteCleanup?.()
destroyed = true
disposeMethod && disposeMethod()
websocketInterval && clearInterval(websocketInterval)
sqlSchemaCompletor && sqlSchemaCompletor.dispose()
autocompletor && autocompletor.dispose()
sqlTypeCompletor && sqlTypeCompletor.dispose()
resultCollectionCompletor && resultCollectionCompletor.dispose()
workspaceMacroCompletor && workspaceMacroCompletor.dispose()
schemaContractCompletor && schemaContractCompletor.dispose()
preprocessorCompletor && preprocessorCompletor.dispose()
timeoutModel && clearTimeout(timeoutModel)
changeChainStart = undefined
loadTimeout && clearTimeout(loadTimeout)
aiChatEditorHandler?.clear()
absolutePathExtraLibs.forEach((d) => d.dispose())
absolutePathExtraLibs.clear()
})
async function genRoot(hostname: string) {
let token = $lspTokenStore
if (!token) {
let expiration = new Date()
expiration.setHours(expiration.getHours() + 72)
const newToken = await UserService.createToken({
requestBody: { label: 'Ephemeral lsp token', expiration: expiration.toISOString() }
})
$lspTokenStore = newToken
token = newToken
}
let root = hostname + '/api/scripts_u/tokened_raw/' + $workspaceStore + '/' + token
return root
}
function acceptCodeChanges() {
const mode = aiChatEditorHandler?.getReviewMode?.()
if (mode === 'revert') {
aiChatEditorHandler?.keepAll()
} else {
aiChatEditorHandler?.acceptAll()
}
}
function rejectCodeChanges() {
const mode = aiChatEditorHandler?.getReviewMode?.()
if (mode === 'revert') {
aiChatEditorHandler?.revertAll()
} else {
aiChatEditorHandler?.rejectAll()
}
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showInlineAIChat) {
closeAIInlineWidget()
}
rejectCodeChanges()
} else if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowDown' && aiChatManager.pendingNewCode) {
acceptCodeChanges()
if (showInlineAIChat) {
closeAIInlineWidget()
}
}
}
$effect(() => {
lang = scriptLangToEditorLang(scriptLang)
})
$effect(() => {
filePath = computePath(path)
})
$effect(() => {
path != initialPath &&
(scriptLang == 'deno' || scriptLang == 'bun' || scriptLang == 'bunnative') &&
untrack(() => {
handlePathChange()
})
})
$effect(() => {
initialized && lang === 'sql' && scriptLang
? untrack(() => addSqlTypeCompletions())
: (sqlTypeCompletor?.dispose(), resultCollectionCompletor?.dispose())
})
$effect(() => {
initialized && lang === 'sql' && scriptLang === 'duckdb'
? untrack(() => {
addWorkspaceMacroCompletions()
})
: workspaceMacroCompletor?.dispose()
})
// Pipeline annotation grammar is language-agnostic (`//` / `--` / `#`
// comment headers), so contract-ref completions register for every script
// language that can be a pipeline member. The provider line-gates itself,
// so it is inert outside annotation lines.
$effect(() => {
initialized && ['duckdb', 'python3', 'bun', 'deno', 'nativets'].includes(scriptLang ?? '')
? untrack(() => addSchemaContractCompletions())
: schemaContractCompletor?.dispose()
})
// Schema-contract markers arrive as a prop because the mirror can finish
// computing before Monaco initializes on mount — reacting to `initialized`
// re-applies the pending set once the model exists. The ever-set latch
// keeps unrelated editors from calling setModelMarkers with [] forever.
let contractMarkersEverSet = false
$effect(() => {
const ms = schemaContractMarkers
if (!initialized || (ms.length === 0 && !contractMarkersEverSet)) return
contractMarkersEverSet = true
untrack(() => {
const model = editor?.getModel()
if (!model) return
meditor.setModelMarkers(
model,
'schema-contracts',
ms.map((m) => ({ ...m, severity: MarkerSeverity.Warning }))
)
})
})
$effect(() => {
initialized && canHavePreprocessor(lang) && enablePreprocessorSnippet
? untrack(() => addPreprocessorCompletions(lang))
: preprocessorCompletor?.dispose()
})
let lastArg = undefined
$effect(() => {
let newArg = lang === 'graphql' ? args?.api : args?.database
if (newArg !== lastArg) {
lastArg = newArg
$dbSchemas && untrack(() => updateSchema(newArg))
}
})
$effect(() => {
console.log('updating db schema completions', dbSchema, lang)
initialized &&
dbSchema &&
['sql', 'graphql'].includes(lang) &&
untrack(() => addDBSchemaCompletions())
})
$effect(() => {
;(!dbSchema || lang !== 'sql') && untrack(() => disposeSqlSchemaCompletor())
})
$effect(() => {
;(!dbSchema || lang !== 'graphql') && untrack(() => disposeGaphqlService())
})
$effect(() => {
const currentWorkflowAsCode = workflowAsCode
$copilotInfo.enabled &&
$codeCompletionSessionEnabled &&
Autocompletor.isProviderModelSupported($copilotInfo.codeCompletionModel) &&
initialized &&
editor &&
scriptLang &&
untrack(() => editor && addAutoCompletor(editor, scriptLang, currentWorkflowAsCode))
})
$effect(() => {
$copilotInfo.enabled && initialized && editor && untrack(() => editor && addChatHandler(editor))
})
$effect(() => {
;(!$codeCompletionSessionEnabled || !$copilotInfo.enabled) && autocompletor?.dispose()
})
$effect(() => {
if (yContent && awareness && model && editor) {
untrack(() => {
monacoBinding && monacoBinding.destroy()
monacoBinding = new MonacoBinding(
yContent,
model!,
new Set([editor as meditor.IStandaloneCodeEditor]),
awareness
)
})
}
})
$effect(() => {
editor && $vimMode && statusDiv && untrack(() => onVimMode())
})
$effect(() => {
!$vimMode && vimDisposable && untrack(() => onVimDisable())
})
$effect(() => {
files && model && untrack(() => onFileChanges())
})
$effect(() => {
editor?.updateOptions({
lineNumbers: $relativeLineNumbers ? 'relative' : 'on'
})
})
let applyExternalCode = useDebounce(() => alignCodeWithEditor(true), 800)
// Last `code` value the editor itself produced or aligned to. Used to tell an
// echo (the bindable changed because the user typed — Monaco is already
// ahead) from a genuine external write. Without this, a typing burst longer
// than the debounce window would sync the lagging `code` back over newer
// keystrokes. Must be kept in step with every editor↔`code` sync point.
let lastEditorCode = code
function alignCodeWithEditor(history: boolean) {
const ed = editor
if (!ed) return
const next = code ?? ''
const value = ed.getValue()
const model = ed.getModel()
// Some keystrokes are still being debounced, don't overwrite them.
// When the debounce is done, updateCode will be called and the code will be aligned with the editor.
if (timeoutModel !== undefined) return
if (!model) return
lastEditorCode = next
if (value === next) return
if (history) {
ed.pushUndoStop()
ed.executeEdits('external', [{ range: model.getFullModelRange(), text: next }])
ed.pushUndoStop()
} else {
ed.setValue(next)
}
}
// External `code` prop changes should flow into the Monaco editor. Skip
// echoes: when `code` matches what the editor last produced (`updateCode`)
// or aligned to, the change came from the editor itself, so syncing back
// would clobber input typed since. Only genuine external writes — where
// `code` diverges from `lastEditorCode` — schedule a sync. The `untrack`
// block reads/writes Monaco without subscribing, so we don't loop.
$effect(() => {
;[code, editor]
if (!editor) return
untrack(() => {
if (code === lastEditorCode) return
applyExternalCode()
})
})
let isTsWorkerInitialized = resource([() => lang, () => initialized], async () => {
if (lang !== 'typescript' || !initialized) return false
// Use the stable model URI (computed once at mount), not filePath which changes on rename
await waitForWorkerInitialization(uri)
return true
})
// Update SQL query type information in the TypeScript worker
// This enables TypeScript to show proper types for SQL template literals
let handleSqlTypingInTs = useDebounce(function handleSqlTypingInTs() {
if (lang !== 'typescript' || !isTsWorkerInitialized.current) return
if (!preparedAssetsSqlQueries || preparedAssetsSqlQueries.length === 0) {
// Clear SQL queries if none exist
updateSqlQueriesInWorker(uri, [])
return
}
// Send SQL query information to the custom TypeScript worker
// The worker will inject type parameters into the code that TypeScript analyzes
// Worker async function call freezes if we pass a Proxy, $state.snapshot() is very important here
// Filter out queries with raw interpolations — they can't be type-checked
let queriesToSend = $state
.snapshot(preparedAssetsSqlQueries)
.filter((q) => !q.has_raw_interpolation)
updateSqlQueriesInWorker(uri, queriesToSend)
}, 250)
watch([() => preparedAssetsSqlQueries, () => lang, () => isTsWorkerInitialized.current], () => {
handleSqlTypingInTs()
})
watch([() => customTsTypesData.current], setTypescriptCustomTypes)
</script>
<svelte:window onkeydown={onKeyDown} />
<EditorTheme />
{#if datatableForMigrations && $workspaceStore}
<DdlMigrationGuard
bind:this={ddlGuard}
workspace={$workspaceStore}
datatable={datatableForMigrations}
/>
{/if}
{#if !editor}
<div class="inset-0 absolute overflow-clip">
<FakeMonacoPlaceHolder {code} lineNumbersWidth={51} />
</div>
{/if}
<div bind:this={divEl} class="{clazz} editor {disabled ? 'disabled' : ''}"></div>
{#if $vimMode}
<div class="fixed bottom-0 z-30" bind:this={statusDiv}></div>
{/if}
{#if $reviewingChanges}
<GlobalReviewButtons onAcceptAll={acceptCodeChanges} onRejectAll={rejectCodeChanges} />
{/if}
{#if editor && $copilotInfo.enabled && aiChatEditorHandler}
<AIChatInlineWidget
bind:this={aiChatInlineWidget}
bind:show={showInlineAIChat}
{editor}
editorHandler={aiChatEditorHandler}
selection={inlineAIChatSelection}
{selectedCode}
/>
{/if}
<style global lang="postcss">
.editor {
@apply p-0;
}
.yRemoteSelection {
background-color: rgb(250, 129, 0, 0.5);
}
.yRemoteSelectionHead {
position: absolute;
border-left: orange solid 2px;
border-top: orange solid 2px;
border-bottom: orange solid 2px;
height: 100%;
box-sizing: border-box;
}
.yRemoteSelectionHead::after {
position: absolute;
content: ' ';
border: 3px solid orange;
border-radius: 4px;
left: -4px;
top: -5px;
}
</style>