mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-26 08:02:31 +00:00
refactor(ai): own headless lint models behind a host, read the editor when present
Introduces HeadlessModelHost as the single owner of Monaco models for linting. Owned models live under a reserved `__wmlint__/<workspace>/…` namespace disjoint from every editor URI, keyed by the draft cell (workspace, itemKind, storagePath) plus a sub-path for a flow module or app runnable — so a headless model can never adopt, overwrite, or dispose one an editor owns, and a fork and its parent never share a model. When an editor is already showing the item in the same workspace, the lint reads its markers instead of creating anything; those markers are settled first so a lint right after an edit can't read a stale clean. The editor registers itself on mount. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8ac43ed7e8
commit
f92ece776b
@@ -107,6 +107,7 @@
|
||||
} from './lint/typescriptExtraLibs'
|
||||
import { createWindmillAta, genAtaRoot } from './lint/typescriptAta'
|
||||
import { readModelMarkers } from './lint/markers'
|
||||
import { registerEditor as registerLintEditor } from './lint/headlessModelHost'
|
||||
import { buildDenoImportMap, hasLanguageServers, lspServersFor } from './lint/lspLanguageConfig'
|
||||
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
|
||||
import type { Selection } from 'monaco-editor'
|
||||
@@ -1683,8 +1684,16 @@
|
||||
})
|
||||
|
||||
let loadTimeout: number | undefined = undefined
|
||||
// Announce this editor to the headless lint host: when the AI chat lints the same item
|
||||
// in the same workspace, it reads these markers instead of touching this model.
|
||||
let unregisterFromLintHost: (() => void) | undefined = undefined
|
||||
|
||||
onMount(async () => {
|
||||
if (BROWSER) {
|
||||
unregisterFromLintHost = registerLintEditor(uri, $workspaceStore ?? '', () => ({
|
||||
markers: getLintErrors(),
|
||||
content: getCode()
|
||||
}))
|
||||
if (loadAsync) {
|
||||
loadTimeout = setTimeout(() => loadMonaco().then((x) => (disposeMethod = x)), 0)
|
||||
} else {
|
||||
@@ -1696,6 +1705,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
console.log('destroying editor')
|
||||
unregisterFromLintHost?.()
|
||||
valueAfterDispose = getCode()
|
||||
pasteCleanup?.()
|
||||
destroyed = true
|
||||
|
||||
@@ -3997,7 +3997,14 @@ type LintTargetArgs = {
|
||||
async function resolveLintTarget(
|
||||
args: LintTargetArgs,
|
||||
workspace: string
|
||||
): Promise<{ content: string; language: string; lintPath: string; label: string }> {
|
||||
): Promise<{
|
||||
content: string
|
||||
language: string
|
||||
itemKind: string
|
||||
storagePath: string
|
||||
subPath?: string
|
||||
label: string
|
||||
}> {
|
||||
if (args.kind === 'script') {
|
||||
const script = await loadScriptForEdit(args.path, workspace).catch(() => {
|
||||
throw new Error(`No draft or deployed script found at "${args.path}".`)
|
||||
@@ -4005,7 +4012,8 @@ async function resolveLintTarget(
|
||||
return {
|
||||
content: script.content,
|
||||
language: script.language,
|
||||
lintPath: args.path,
|
||||
itemKind: 'script',
|
||||
storagePath: args.path,
|
||||
label: `script "${args.path}"`
|
||||
}
|
||||
}
|
||||
@@ -4027,8 +4035,11 @@ async function resolveLintTarget(
|
||||
return {
|
||||
content: module.value.content,
|
||||
language: module.value.language,
|
||||
// The editor mounts flow modules at "<flow path>/<module id>"; lint the same URI.
|
||||
lintPath: `${args.path}/${args.module_id}`,
|
||||
// The editor mounts a flow module at "<flow path>/<module id>"; the owned model
|
||||
// mirrors that under the flow's draft cell.
|
||||
itemKind: 'flow',
|
||||
storagePath: args.path,
|
||||
subPath: args.module_id,
|
||||
label: `module "${args.module_id}" of flow "${args.path}"`
|
||||
}
|
||||
}
|
||||
@@ -4050,7 +4061,9 @@ async function resolveLintTarget(
|
||||
return {
|
||||
content: runnable.inlineScript.content ?? '',
|
||||
language: runnable.inlineScript.language,
|
||||
lintPath: `${args.path}/${runnableKey}`,
|
||||
itemKind: 'app',
|
||||
storagePath: args.path,
|
||||
subPath: runnableKey,
|
||||
label: `runnable "${runnableKey}" of app "${args.path}"`
|
||||
}
|
||||
}
|
||||
@@ -4153,8 +4166,10 @@ async function getLintErrors(args: LintTargetArgs, ctx: WriteDraftCtx): Promise<
|
||||
const outcome = await lintCode({
|
||||
content: target.content,
|
||||
scriptLang: target.language,
|
||||
path: target.lintPath,
|
||||
workspace
|
||||
workspace,
|
||||
itemKind: target.itemKind,
|
||||
storagePath: target.storagePath,
|
||||
subPath: target.subPath
|
||||
})
|
||||
const result = outcome.result
|
||||
const incomplete = outcome.status === 'incomplete'
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
setMonacoTypescriptOptions,
|
||||
TS_DIAGNOSTIC_CODES_TO_IGNORE
|
||||
} from '../monacoLanguagesOptions'
|
||||
import { computeModelPath, computeModelUri } from './monacoUri'
|
||||
import { computeModelUri, computeOwnedUri } from './monacoUri'
|
||||
import { readModelMarkers } from './markers'
|
||||
import { acquireOwnedModel, readThrough, releaseOwnedModel } from './headlessModelHost'
|
||||
import { ensureCustomWmillTypes, ensureResourceTypeNamespace } from './typescriptExtraLibs'
|
||||
import { createWindmillAta, genAtaRoot } from './typescriptAta'
|
||||
import { lintWithLsp } from './headlessLsp'
|
||||
@@ -35,15 +36,20 @@ export function canLintHeadless(scriptLang: string | undefined): boolean {
|
||||
export interface HeadlessLintRequest {
|
||||
content: string
|
||||
scriptLang: string
|
||||
/** Canonical editor path without extension: script path, `<flow>/<moduleId>`, `<app>/<runnableKey>`. */
|
||||
path: string
|
||||
workspace: string
|
||||
/** The draft cell this code lives in — its identity, keyed like UserDraft. */
|
||||
itemKind: string
|
||||
storagePath: string
|
||||
/** A unit within the cell: a flow module id or an app runnable key; absent for a whole script. */
|
||||
subPath?: string
|
||||
}
|
||||
|
||||
// Models we created ourselves, least recently used first.
|
||||
const ownedModels: string[] = []
|
||||
const MAX_OWNED_MODELS = 4
|
||||
/** The editor path this request maps to — where a mounted editor would put its model. */
|
||||
function editorPathOf(req: HeadlessLintRequest): string {
|
||||
return req.subPath ? `${req.storagePath}/${req.subPath}` : req.storagePath
|
||||
}
|
||||
|
||||
const MAX_ATA_INSTANCES = 4
|
||||
const absolutePathExtraLibs = new Map<string, { dispose: () => void }>()
|
||||
const ataByKey = new Map<string, (source: string) => Promise<void>>()
|
||||
const pendingByUri = new Map<string, Promise<LintOutcome>>()
|
||||
@@ -52,31 +58,6 @@ function withDeadline(promise: Promise<unknown>, ms: number): Promise<unknown> {
|
||||
return Promise.race([promise, new Promise((resolve) => setTimeout(resolve, ms))])
|
||||
}
|
||||
|
||||
function touchOwnedModel(uriString: string) {
|
||||
const at = ownedModels.indexOf(uriString)
|
||||
if (at >= 0) ownedModels.splice(at, 1)
|
||||
ownedModels.push(uriString)
|
||||
}
|
||||
|
||||
function evictOwnedModels(keepUri: string) {
|
||||
while (ownedModels.length > MAX_OWNED_MODELS) {
|
||||
const candidate = ownedModels.shift()
|
||||
if (!candidate) continue
|
||||
if (candidate === keepUri) {
|
||||
// Still in use: keep tracking it, or a later lint would mistake it for a model
|
||||
// owned by an editor and refuse to update its content.
|
||||
ownedModels.push(candidate)
|
||||
continue
|
||||
}
|
||||
const model = meditor.getModel(Uri.parse(candidate))
|
||||
// An editor may have adopted it in the meantime; disposing it would close the
|
||||
// user's buffer.
|
||||
if (model && !model.isAttachedToEditor()) {
|
||||
model.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function lintCode(
|
||||
req: HeadlessLintRequest,
|
||||
opts?: { timeoutMs?: number }
|
||||
@@ -94,26 +75,27 @@ export async function lintCode(
|
||||
scriptLang: req.scriptLang,
|
||||
editorLang,
|
||||
workspace: req.workspace,
|
||||
path: req.path,
|
||||
path: editorPathOf(req),
|
||||
timeoutMs: opts?.timeoutMs
|
||||
})
|
||||
}
|
||||
|
||||
const uriString = computeModelUri(
|
||||
computeModelPath(req.path, req.scriptLang),
|
||||
const ownedUri = computeOwnedUri(
|
||||
{ workspace: req.workspace, itemKind: req.itemKind, storagePath: req.storagePath },
|
||||
req.subPath,
|
||||
req.scriptLang,
|
||||
editorLang
|
||||
)
|
||||
|
||||
// Two lints of the same URI would race on model content; run them in order.
|
||||
const previous = pendingByUri.get(uriString) ?? Promise.resolve(undefined)
|
||||
// Two lints of the same owned model would race on its content; run them in order.
|
||||
const previous = pendingByUri.get(ownedUri) ?? Promise.resolve(undefined)
|
||||
const run = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => lintOne(req, uriString, editorLang, opts?.timeoutMs ?? 3000))
|
||||
.then(() => lintOne(req, ownedUri, editorLang, opts?.timeoutMs ?? 3000))
|
||||
pendingByUri.set(
|
||||
uriString,
|
||||
ownedUri,
|
||||
run.finally(() => {
|
||||
if (pendingByUri.get(uriString) === run) pendingByUri.delete(uriString)
|
||||
if (pendingByUri.get(ownedUri) === run) pendingByUri.delete(ownedUri)
|
||||
}) as Promise<LintOutcome>
|
||||
)
|
||||
return run
|
||||
@@ -121,7 +103,7 @@ export async function lintCode(
|
||||
|
||||
async function lintOne(
|
||||
req: HeadlessLintRequest,
|
||||
uriString: string,
|
||||
ownedUri: string,
|
||||
editorLang: string,
|
||||
timeoutMs: number
|
||||
): Promise<LintOutcome> {
|
||||
@@ -132,69 +114,72 @@ async function lintOne(
|
||||
await initializeVscode('headlessLint')
|
||||
keepModelAroundToAvoidDisposalOfWorkers()
|
||||
|
||||
const uri = Uri.parse(uriString)
|
||||
let model = meditor.getModel(uri)
|
||||
let contentMismatch = false
|
||||
if (model) {
|
||||
// Attachment decides, not who created it: an editor can adopt a model we made, and
|
||||
// its buffer is what the user sees and would save. Never write to it.
|
||||
if (model.isAttachedToEditor() || !ownedModels.includes(uriString)) {
|
||||
contentMismatch = model.getValue() !== req.content
|
||||
} else if (model.getValue() !== req.content) {
|
||||
model.setValue(req.content)
|
||||
// Markers from the previous content survive until revalidation finishes, and a
|
||||
// matching marker count would let the wait below settle on them.
|
||||
meditor.setModelMarkers(model, editorLang, [])
|
||||
touchOwnedModel(uriString)
|
||||
// If an editor in this workspace is showing the item, read its markers — never touch a
|
||||
// model it owns. Its markers still have to settle: right after an edit the buffer holds
|
||||
// the new content but the worker may not have revalidated yet.
|
||||
const editorUri = computeModelUri(editorPathOf(req), req.scriptLang, editorLang)
|
||||
const rt = readThrough(editorUri, req.workspace, req.content)
|
||||
if (rt) {
|
||||
const settled = await waitForMarkersToSettle(Uri.parse(editorUri), editorLang, timeoutMs)
|
||||
const result = rt.reread()
|
||||
return settled
|
||||
? { status: 'complete', result, contentMismatch: rt.contentMismatch }
|
||||
: {
|
||||
status: 'incomplete',
|
||||
result,
|
||||
missing: ['the TypeScript checker'],
|
||||
contentMismatch: rt.contentMismatch
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise lint an owned model.
|
||||
const uri = acquireOwnedModel(ownedUri, req.content, editorLang)
|
||||
try {
|
||||
// Bounded: these fetch over the network, and lints of one URI are serialized behind
|
||||
// each other, so one hung request would wedge the tool for that item indefinitely.
|
||||
if (editorLang === 'typescript') {
|
||||
await withDeadline(
|
||||
Promise.all([
|
||||
ensureResourceTypeNamespace(req.workspace, req.scriptLang),
|
||||
ensureCustomWmillTypes(req.workspace)
|
||||
]).catch((e) => console.error('headlessLint: extra libs unavailable', e)),
|
||||
timeoutMs
|
||||
)
|
||||
}
|
||||
} else {
|
||||
model = meditor.createModel(req.content, editorLang, uri)
|
||||
touchOwnedModel(uriString)
|
||||
evictOwnedModels(uriString)
|
||||
}
|
||||
|
||||
// Bounded: these fetch over the network, and lints of one URI are serialized behind
|
||||
// each other, so one hung request would wedge the tool for that item indefinitely.
|
||||
if (editorLang === 'typescript') {
|
||||
await withDeadline(
|
||||
Promise.all([
|
||||
ensureResourceTypeNamespace(req.workspace, req.scriptLang),
|
||||
ensureCustomWmillTypes(req.workspace)
|
||||
]).catch((e) => console.error('headlessLint: extra libs unavailable', e)),
|
||||
timeoutMs
|
||||
)
|
||||
}
|
||||
if (req.scriptLang === 'bun' || req.scriptLang === 'bunnative') {
|
||||
await withDeadline(
|
||||
acquireTypes(req, ownedUri).catch((e) =>
|
||||
console.error('headlessLint: type acquisition failed', e)
|
||||
),
|
||||
timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
if (req.scriptLang === 'bun' || req.scriptLang === 'bunnative') {
|
||||
await withDeadline(
|
||||
acquireTypes(req, uriString).catch((e) =>
|
||||
console.error('headlessLint: type acquisition failed', e)
|
||||
),
|
||||
timeoutMs
|
||||
)
|
||||
const settled = await waitForMarkersToSettle(uri, editorLang, timeoutMs)
|
||||
const result = readModelMarkers(uri)
|
||||
// A worker that never came up leaves the markers empty for lack of analysis; report
|
||||
// it as incomplete rather than let an empty set read as clean.
|
||||
return settled
|
||||
? { status: 'complete', result }
|
||||
: { status: 'incomplete', result, missing: ['the TypeScript checker'] }
|
||||
} finally {
|
||||
releaseOwnedModel(ownedUri)
|
||||
}
|
||||
|
||||
const settled = await waitForMarkersToSettle(uri, editorLang, timeoutMs)
|
||||
const result = readModelMarkers(uri)
|
||||
// A worker that never came up leaves the markers empty for lack of analysis; report it
|
||||
// as incomplete rather than let an empty set read as clean.
|
||||
return settled
|
||||
? { status: 'complete', result, contentMismatch }
|
||||
: { status: 'incomplete', result, missing: ['the TypeScript checker'], contentMismatch }
|
||||
}
|
||||
|
||||
async function acquireTypes(req: HeadlessLintRequest, uriString: string): Promise<void> {
|
||||
const key = `${req.workspace}:${uriString}`
|
||||
let ata = ataByKey.get(key)
|
||||
if (!ata) {
|
||||
while (ataByKey.size >= MAX_OWNED_MODELS) {
|
||||
while (ataByKey.size >= MAX_ATA_INSTANCES) {
|
||||
const oldest = ataByKey.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
ataByKey.delete(oldest)
|
||||
}
|
||||
ata = await createWindmillAta({
|
||||
root: await genAtaRoot(req.workspace),
|
||||
scriptPath: req.path,
|
||||
scriptPath: editorPathOf(req),
|
||||
modelUri: uriString,
|
||||
absolutePathExtraLibs
|
||||
})
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { editor as meditor, Uri } from 'monaco-editor'
|
||||
import type { ScriptLintResult } from '../copilot/chat/shared'
|
||||
|
||||
// The single owner of every model the headless linter touches. Two rules:
|
||||
// 1. When an editor is already showing the item (same URI and workspace), read ITS
|
||||
// markers — never create or write.
|
||||
// 2. Otherwise work in an owned model whose URI is disjoint from every editor's (see
|
||||
// OWNED_ROOT in monacoUri.ts), so an owned model can never be one an editor owns.
|
||||
// Nothing outside this module mutates Monaco's model registry for linting.
|
||||
|
||||
interface RegisteredEditor {
|
||||
/** Editor's own view of its markers and current buffer, read lazily. */
|
||||
read: () => { markers: ScriptLintResult; content: string }
|
||||
}
|
||||
|
||||
// keyed by workspace + editor URI: two editors on the same path in different workspaces
|
||||
// are distinct registrations, so a fork's editor never shadows the parent's.
|
||||
const registeredEditors = new Map<string, RegisteredEditor>()
|
||||
|
||||
// owned models we created, least-recently-used first; keyed by owned URI string
|
||||
const ownedModels: string[] = []
|
||||
const MAX_OWNED_MODELS = 4
|
||||
// owned models currently mid-lint (awaiting settle); the evictor must not dispose them
|
||||
const inFlight = new Set<string>()
|
||||
|
||||
function editorKey(workspace: string, uri: string): string {
|
||||
return `${workspace}\n${uri}`
|
||||
}
|
||||
|
||||
/**
|
||||
* An editor announces the URI it owns, its workspace, and how to read its markers/content.
|
||||
* Returns a disposer for unmount. This is what makes read-through workspace-correct: a
|
||||
* session linting a fork never reads a parent-workspace editor's markers.
|
||||
*/
|
||||
export function registerEditor(
|
||||
uri: string,
|
||||
workspace: string,
|
||||
read: () => { markers: ScriptLintResult; content: string }
|
||||
): () => void {
|
||||
const k = editorKey(workspace, uri)
|
||||
const entry: RegisteredEditor = { read }
|
||||
registeredEditors.set(k, entry)
|
||||
return () => {
|
||||
if (registeredEditors.get(k) === entry) registeredEditors.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReadThrough {
|
||||
/** The editor's buffer differs from the content we were asked to lint. */
|
||||
contentMismatch: boolean
|
||||
/** Re-read the editor's markers (call after waiting for them to settle). */
|
||||
reread: () => ScriptLintResult
|
||||
}
|
||||
|
||||
/**
|
||||
* If an editor in the same workspace owns `editorUri`, return a handle to its markers (and
|
||||
* whether its buffer differs from `requestedContent`). Never creates or writes a model.
|
||||
*/
|
||||
export function readThrough(
|
||||
editorUri: string,
|
||||
workspace: string,
|
||||
requestedContent: string
|
||||
): ReadThrough | undefined {
|
||||
const reg = registeredEditors.get(editorKey(workspace, editorUri))
|
||||
if (!reg) return undefined
|
||||
return {
|
||||
contentMismatch: reg.read().content !== requestedContent,
|
||||
reread: () => reg.read().markers
|
||||
}
|
||||
}
|
||||
|
||||
function touch(uriString: string) {
|
||||
const at = ownedModels.indexOf(uriString)
|
||||
if (at >= 0) ownedModels.splice(at, 1)
|
||||
ownedModels.push(uriString)
|
||||
}
|
||||
|
||||
function evict(keepUri: string) {
|
||||
// Bounded by the list length so a set of all-protected models can't spin forever.
|
||||
let guard = ownedModels.length
|
||||
while (ownedModels.length > MAX_OWNED_MODELS && guard-- > 0) {
|
||||
const candidate = ownedModels.shift()
|
||||
if (!candidate) continue
|
||||
// Protect the model this lint is using and any other lint's in-flight model — a
|
||||
// concurrent lint of a different item is still awaiting its markers.
|
||||
if (candidate === keepUri || inFlight.has(candidate)) {
|
||||
ownedModels.push(candidate)
|
||||
continue
|
||||
}
|
||||
const model = meditor.getModel(Uri.parse(candidate))
|
||||
// Owned models are never editor-attached (disjoint namespace); the guard is defence
|
||||
// in depth against a future change that violates that.
|
||||
if (model && !model.isAttachedToEditor()) model.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an owned model exists at `ownedUri` holding `content`, marking it in-flight so a
|
||||
* concurrent lint's eviction cannot dispose it. The caller must call `releaseOwnedModel`
|
||||
* when done. Clears stale markers on a content change so a later settle cannot succeed on
|
||||
* the previous version's markers.
|
||||
*/
|
||||
export function acquireOwnedModel(ownedUri: string, content: string, editorLang: string): Uri {
|
||||
inFlight.add(ownedUri)
|
||||
const uri = Uri.parse(ownedUri)
|
||||
const existing = meditor.getModel(uri)
|
||||
if (existing) {
|
||||
if (existing.getValue() !== content) {
|
||||
existing.setValue(content)
|
||||
meditor.setModelMarkers(existing, editorLang, [])
|
||||
}
|
||||
touch(ownedUri)
|
||||
} else {
|
||||
meditor.createModel(content, editorLang, uri)
|
||||
touch(ownedUri)
|
||||
}
|
||||
evict(ownedUri)
|
||||
return uri
|
||||
}
|
||||
|
||||
export function releaseOwnedModel(ownedUri: string) {
|
||||
inFlight.delete(ownedUri)
|
||||
}
|
||||
@@ -14,7 +14,8 @@ export function computeModelPath(path: string | undefined, scriptLang: string |
|
||||
return path
|
||||
}
|
||||
|
||||
export function computeModelUri(
|
||||
/** The file part (path + extension, no leading slash) shared by every URI scheme below. */
|
||||
function computeFilePart(
|
||||
filePath: string,
|
||||
scriptLang: string | undefined,
|
||||
editorLang: string
|
||||
@@ -25,10 +26,37 @@ export function computeModelUri(
|
||||
} else {
|
||||
file = `${filePath}.${scriptLang == 'tsx' ? 'tsx' : langToExt(editorLang)}`
|
||||
}
|
||||
if (file.startsWith('/')) {
|
||||
file = file.slice(1)
|
||||
}
|
||||
return file.startsWith('/') ? file.slice(1) : file
|
||||
}
|
||||
|
||||
export function computeModelUri(
|
||||
filePath: string,
|
||||
scriptLang: string | undefined,
|
||||
editorLang: string
|
||||
): string {
|
||||
const file = computeFilePart(filePath, scriptLang, editorLang)
|
||||
return !RANDOMIZED_PATH_LANGS.includes(scriptLang ?? '')
|
||||
? `file:///${file}`
|
||||
: `file:///tmp/monaco/${file}`
|
||||
}
|
||||
|
||||
// A reserved root segment no workspace name or script path can produce, so owned-model
|
||||
// URIs are disjoint from every editor URI. This is the invariant a headless lint relies on
|
||||
// to never adopt, overwrite, or dispose a model an editor owns; keep owned URIs under it.
|
||||
export const OWNED_ROOT = '__wmlint__'
|
||||
|
||||
/**
|
||||
* URI for a model the headless linter owns, keyed by the draft cell it reflects
|
||||
* (workspace + itemKind + storagePath) plus an optional sub-path (a flow module id or an
|
||||
* app file). One linted code unit ⇔ one owned model, in a namespace no editor can enter.
|
||||
*/
|
||||
export function computeOwnedUri(
|
||||
cell: { workspace: string; itemKind: string; storagePath: string },
|
||||
subPath: string | undefined,
|
||||
scriptLang: string | undefined,
|
||||
editorLang: string
|
||||
): string {
|
||||
const inner = subPath ? `${cell.storagePath}/${subPath}` : cell.storagePath
|
||||
const file = computeFilePart(inner, scriptLang, editorLang)
|
||||
return `file:///${OWNED_ROOT}/${cell.workspace}/${cell.itemKind}/${file}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user