mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 08:02:26 +00:00
e47aedac0a
* 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>
2299 lines
67 KiB
Svelte
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>
|