@el4cteo/rbx-studio-mcp 0.4.6 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/dist/bridge/api.js +22 -3
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/console.js +348 -0
- package/dist/bridge/console.js.map +1 -0
- package/dist/bridge/harness.js +883 -0
- package/dist/bridge/harness.js.map +1 -0
- package/dist/bridge/remote.js +9 -1
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +69 -4
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +54 -6
- package/dist/bridge/server.js.map +1 -1
- package/dist/doctor.js +13 -1
- package/dist/doctor.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/protocol.js.map +1 -1
- package/dist/tools/screenshot.js +7 -6
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/terrain.js +116 -0
- package/dist/tools/terrain.js.map +1 -0
- package/package.json +4 -4
- package/plugin/src/Commands.luau +524 -0
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +282 -49
- package/plugin/src/Format.luau +114 -0
- package/plugin/src/Prompt.luau +436 -0
- package/plugin/src/ScriptEdit.luau +48 -0
- package/plugin/src/Visuals.luau +1409 -1332
- package/plugin/src/handlers/Capture.luau +68 -20
- package/plugin/src/handlers/Terrain.luau +371 -0
- package/plugin/src/init.server.luau +829 -725
- package/scripts/test-console.mjs +529 -0
- package/scripts/test-failover.mjs +47 -0
- package/scripts/test-plugin.mjs +85 -82
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The console's command line.
|
|
4
|
+
|
|
5
|
+
The panel was read-only for its whole life, and the footer even carried a
|
|
6
|
+
note explaining why it had no cursor: a blinking block promises that
|
|
7
|
+
something accepts input, and nothing did. That is what this changes. One row
|
|
8
|
+
under the header, always visible -- it does not ride the `visuals` toggle,
|
|
9
|
+
because a control that disappears with a decoration is a control nobody
|
|
10
|
+
trusts -- holding a caret and a field.
|
|
11
|
+
|
|
12
|
+
Two kinds of thing go in it and the row does not distinguish them, on
|
|
13
|
+
purpose. A word it knows is a command and runs here in milliseconds; anything
|
|
14
|
+
else is a sentence, and sentences go to an agent. Making the user mark which
|
|
15
|
+
is which would be asking them to know our implementation before they can
|
|
16
|
+
type.
|
|
17
|
+
|
|
18
|
+
History and completion are the two things that separate a prompt from a text
|
|
19
|
+
box, so both are here. Keyboard input inside a plugin widget is delivered to
|
|
20
|
+
the GuiObjects themselves rather than through UserInputService, which is why
|
|
21
|
+
the arrows and Tab are read off the field's own InputBegan.
|
|
22
|
+
]]
|
|
23
|
+
|
|
24
|
+
local Themes = require(script.Parent.Themes)
|
|
25
|
+
|
|
26
|
+
local Prompt = {}
|
|
27
|
+
|
|
28
|
+
-- Tall enough for a 12pt line with air around it, short enough that it does not
|
|
29
|
+
-- read as a second header. The log gives up exactly this much.
|
|
30
|
+
Prompt.HEIGHT = 30
|
|
31
|
+
|
|
32
|
+
-- Matches the log's own left inset, so the caret sits in the same column as the
|
|
33
|
+
-- sigils below it and the whole panel reads as one grid.
|
|
34
|
+
local INSET = 12
|
|
35
|
+
local CARET_WIDTH = 14
|
|
36
|
+
|
|
37
|
+
--[[
|
|
38
|
+
How many past lines are kept.
|
|
39
|
+
|
|
40
|
+
A ring rather than a session log: this is for getting back to the thing you
|
|
41
|
+
typed a moment ago, and anyone scrolling past fifty entries wanted the log,
|
|
42
|
+
not the history.
|
|
43
|
+
]]
|
|
44
|
+
local MAX_HISTORY = 50
|
|
45
|
+
|
|
46
|
+
type Runtime = {
|
|
47
|
+
row: Frame?,
|
|
48
|
+
caret: TextLabel?,
|
|
49
|
+
field: TextBox?,
|
|
50
|
+
hint: TextLabel?,
|
|
51
|
+
rule: Frame?,
|
|
52
|
+
--[[
|
|
53
|
+
Newest last. The entry being edited is NOT in here -- it is in the field
|
|
54
|
+
-- so the list holds only lines that were actually submitted.
|
|
55
|
+
]]
|
|
56
|
+
history: { string },
|
|
57
|
+
--[[
|
|
58
|
+
Where in the history the user is, counted from the end. Zero means "at
|
|
59
|
+
the live line", which is the only state in which the field's contents
|
|
60
|
+
belong to the user rather than to a recalled entry.
|
|
61
|
+
]]
|
|
62
|
+
offset: number,
|
|
63
|
+
-- What was being typed before the arrows took the field over, so coming back
|
|
64
|
+
-- down from the history returns it rather than an empty line.
|
|
65
|
+
draft: string,
|
|
66
|
+
busy: boolean,
|
|
67
|
+
--[[
|
|
68
|
+
What is selected in Studio right now, already worded for display.
|
|
69
|
+
|
|
70
|
+
Kept here because the prompt is where it earns its place: half of what
|
|
71
|
+
anyone types into this box is deictic -- "make this bigger", "delete
|
|
72
|
+
that" -- and the word only means something if you can see what it points
|
|
73
|
+
at while you are writing it. Studio's own Explorer is often scrolled
|
|
74
|
+
somewhere else, or behind the panel.
|
|
75
|
+
]]
|
|
76
|
+
selection: string,
|
|
77
|
+
complete: ((string) -> { string })?,
|
|
78
|
+
submit: ((string) -> ())?,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
local runtime: Runtime = {
|
|
82
|
+
row = nil,
|
|
83
|
+
caret = nil,
|
|
84
|
+
field = nil,
|
|
85
|
+
hint = nil,
|
|
86
|
+
rule = nil,
|
|
87
|
+
history = {},
|
|
88
|
+
offset = 0,
|
|
89
|
+
draft = "",
|
|
90
|
+
busy = false,
|
|
91
|
+
selection = "",
|
|
92
|
+
complete = nil,
|
|
93
|
+
submit = nil,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
--[[
|
|
97
|
+
The caret's colour, which is the row's only status light.
|
|
98
|
+
|
|
99
|
+
Violet at rest is the project's accent and says "this is ours". Amber while
|
|
100
|
+
an agent runs says something is happening without stealing the caption's
|
|
101
|
+
line, which is already reporting what.
|
|
102
|
+
]]
|
|
103
|
+
local function paintCaret()
|
|
104
|
+
local caret = runtime.caret
|
|
105
|
+
if caret == nil then
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
local palette = Themes.palette()
|
|
109
|
+
caret.TextColor3 = if runtime.busy then palette.amber else palette.violet
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
local function placeholder(): string
|
|
113
|
+
return if runtime.busy
|
|
114
|
+
then "running -- type stop to cancel"
|
|
115
|
+
else "type a command, or ask for something"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
--[[
|
|
119
|
+
The greyed-out remainder of the command being typed.
|
|
120
|
+
|
|
121
|
+
Drawn as a separate label positioned after the text rather than inside the
|
|
122
|
+
field, because a TextBox that writes its own guess into itself has to undo
|
|
123
|
+
that on every keystroke and gets it wrong the moment the user types fast.
|
|
124
|
+
This only ever suggests; Tab is what accepts.
|
|
125
|
+
]]
|
|
126
|
+
local function showHint()
|
|
127
|
+
local field = runtime.field
|
|
128
|
+
local hint = runtime.hint
|
|
129
|
+
local complete = runtime.complete
|
|
130
|
+
if field == nil or hint == nil or complete == nil then
|
|
131
|
+
return
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
--[[
|
|
135
|
+
One slot, two things to say, and typing wins.
|
|
136
|
+
|
|
137
|
+
The completion is about the keystroke happening right now; the selection
|
|
138
|
+
is standing context. Giving them separate slots would put two competing
|
|
139
|
+
readouts on one 30-pixel row, so they share it and the more urgent one
|
|
140
|
+
takes it.
|
|
141
|
+
]]
|
|
142
|
+
local typed = field.Text
|
|
143
|
+
-- Only the first word completes. Everything after it is an argument, and
|
|
144
|
+
-- guessing at arguments is how a prompt starts fighting the person using it.
|
|
145
|
+
if typed ~= "" and string.find(typed, "%s") == nil then
|
|
146
|
+
local matches = complete(typed)
|
|
147
|
+
if #matches == 1 and matches[1] ~= typed then
|
|
148
|
+
hint.Text = string.format("%s tab", matches[1])
|
|
149
|
+
return
|
|
150
|
+
elseif #matches > 1 then
|
|
151
|
+
hint.Text = table.concat(matches, " ")
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
hint.Text = runtime.selection
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
--[[
|
|
160
|
+
Puts a past line back in the field.
|
|
161
|
+
|
|
162
|
+
`offset` counts backwards from the newest entry, so 1 is the last thing
|
|
163
|
+
typed. Zero is the live line, which is the draft rather than any entry.
|
|
164
|
+
]]
|
|
165
|
+
local function recall(offset: number)
|
|
166
|
+
local field = runtime.field
|
|
167
|
+
if field == nil then
|
|
168
|
+
return
|
|
169
|
+
end
|
|
170
|
+
local total = #runtime.history
|
|
171
|
+
local wanted = math.clamp(offset, 0, total)
|
|
172
|
+
|
|
173
|
+
-- Stepping off the live line for the first time: keep what was there, or
|
|
174
|
+
-- walking up and back down silently eats a half-typed command.
|
|
175
|
+
if runtime.offset == 0 and wanted > 0 then
|
|
176
|
+
runtime.draft = field.Text
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
runtime.offset = wanted
|
|
180
|
+
field.Text = if wanted == 0 then runtime.draft else runtime.history[total - wanted + 1]
|
|
181
|
+
field.CursorPosition = #field.Text + 1
|
|
182
|
+
showHint()
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
local function remember(line: string)
|
|
186
|
+
local history = runtime.history
|
|
187
|
+
-- A line repeated back to back is one entry. Pressing up should walk through
|
|
188
|
+
-- what was done, not through how many times it was retried.
|
|
189
|
+
if history[#history] ~= line then
|
|
190
|
+
table.insert(history, line)
|
|
191
|
+
end
|
|
192
|
+
while #history > MAX_HISTORY do
|
|
193
|
+
table.remove(history, 1)
|
|
194
|
+
end
|
|
195
|
+
runtime.offset = 0
|
|
196
|
+
runtime.draft = ""
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
--[[
|
|
200
|
+
Accepts the suggestion, when there is exactly one.
|
|
201
|
+
|
|
202
|
+
Several matches print themselves in the hint and do nothing, which is what
|
|
203
|
+
every shell does: completing to a common prefix saves a keystroke and costs
|
|
204
|
+
the user the list they were about to read.
|
|
205
|
+
]]
|
|
206
|
+
local function acceptHint()
|
|
207
|
+
local field = runtime.field
|
|
208
|
+
local complete = runtime.complete
|
|
209
|
+
if field == nil or complete == nil or field.Text == "" then
|
|
210
|
+
return
|
|
211
|
+
end
|
|
212
|
+
local matches = complete(field.Text)
|
|
213
|
+
if #matches ~= 1 then
|
|
214
|
+
return
|
|
215
|
+
end
|
|
216
|
+
field.Text = matches[1] .. " "
|
|
217
|
+
field.CursorPosition = #field.Text + 1
|
|
218
|
+
showHint()
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
--[[
|
|
222
|
+
Builds the row. Called once from `Console.mount`, positioned by it.
|
|
223
|
+
]]
|
|
224
|
+
function Prompt.mount(parent: Instance, handlers: { submit: (string) -> (), complete: (string) -> { string } })
|
|
225
|
+
local palette = Themes.palette()
|
|
226
|
+
runtime.submit = handlers.submit
|
|
227
|
+
runtime.complete = handlers.complete
|
|
228
|
+
|
|
229
|
+
local row = Instance.new("Frame")
|
|
230
|
+
row.Name = "Prompt"
|
|
231
|
+
row.BackgroundColor3 = palette.background
|
|
232
|
+
row.BorderSizePixel = 0
|
|
233
|
+
row.Parent = parent
|
|
234
|
+
runtime.row = row
|
|
235
|
+
|
|
236
|
+
local caret = Instance.new("TextLabel")
|
|
237
|
+
caret.Name = "Caret"
|
|
238
|
+
caret.Text = "\u{203A}"
|
|
239
|
+
caret.Font = Enum.Font.Code
|
|
240
|
+
caret.TextSize = 14
|
|
241
|
+
caret.TextColor3 = palette.violet
|
|
242
|
+
caret.TextXAlignment = Enum.TextXAlignment.Left
|
|
243
|
+
caret.BackgroundTransparency = 1
|
|
244
|
+
caret.Position = UDim2.fromOffset(INSET, 0)
|
|
245
|
+
caret.Size = UDim2.new(0, CARET_WIDTH, 1, 0)
|
|
246
|
+
caret.Parent = row
|
|
247
|
+
runtime.caret = caret
|
|
248
|
+
|
|
249
|
+
local left = INSET + CARET_WIDTH
|
|
250
|
+
|
|
251
|
+
--[[
|
|
252
|
+
The hint sits at the right end rather than immediately after the text.
|
|
253
|
+
|
|
254
|
+
Following the cursor means measuring the text every keystroke, and a
|
|
255
|
+
suggestion that jitters horizontally while you type is worse than one
|
|
256
|
+
parked somewhere predictable.
|
|
257
|
+
]]
|
|
258
|
+
local hint = Instance.new("TextLabel")
|
|
259
|
+
hint.Name = "Hint"
|
|
260
|
+
hint.Text = ""
|
|
261
|
+
hint.Font = Enum.Font.Code
|
|
262
|
+
hint.TextSize = 11
|
|
263
|
+
hint.TextColor3 = palette.dim
|
|
264
|
+
hint.TextXAlignment = Enum.TextXAlignment.Right
|
|
265
|
+
hint.TextTruncate = Enum.TextTruncate.AtEnd
|
|
266
|
+
hint.BackgroundTransparency = 1
|
|
267
|
+
hint.AnchorPoint = Vector2.new(1, 0)
|
|
268
|
+
hint.Position = UDim2.new(1, -INSET, 0, 0)
|
|
269
|
+
hint.Size = UDim2.new(0.5, 0, 1, 0)
|
|
270
|
+
hint.Parent = row
|
|
271
|
+
runtime.hint = hint
|
|
272
|
+
|
|
273
|
+
local field = Instance.new("TextBox")
|
|
274
|
+
field.Name = "Field"
|
|
275
|
+
field.Text = ""
|
|
276
|
+
field.PlaceholderText = placeholder()
|
|
277
|
+
field.PlaceholderColor3 = palette.dim
|
|
278
|
+
field.Font = Enum.Font.Code
|
|
279
|
+
field.TextSize = 12
|
|
280
|
+
field.TextColor3 = palette.text
|
|
281
|
+
field.TextXAlignment = Enum.TextXAlignment.Left
|
|
282
|
+
field.BackgroundTransparency = 1
|
|
283
|
+
field.BorderSizePixel = 0
|
|
284
|
+
-- Cleared on submit, not on focus: clicking away to read a line above and
|
|
285
|
+
-- clicking back must not cost what was typed.
|
|
286
|
+
field.ClearTextOnFocus = false
|
|
287
|
+
field.ClipsDescendants = true
|
|
288
|
+
field.Position = UDim2.fromOffset(left, 0)
|
|
289
|
+
field.Size = UDim2.new(1, -(left + INSET), 1, 0)
|
|
290
|
+
field.Parent = row
|
|
291
|
+
runtime.field = field
|
|
292
|
+
|
|
293
|
+
-- A hairline ABOVE the row, because the row sits at the foot of the panel:
|
|
294
|
+
-- the edge worth drawing is the one between the log and the input, and a
|
|
295
|
+
-- second line just above the status bar would only crowd it.
|
|
296
|
+
local rule = Instance.new("Frame")
|
|
297
|
+
rule.Name = "Rule"
|
|
298
|
+
rule.AnchorPoint = Vector2.new(0, 0)
|
|
299
|
+
rule.Position = UDim2.fromScale(0, 0)
|
|
300
|
+
rule.Size = UDim2.new(1, 0, 0, 1)
|
|
301
|
+
rule.BackgroundColor3 = palette.rule
|
|
302
|
+
rule.BackgroundTransparency = 0.4
|
|
303
|
+
rule.BorderSizePixel = 0
|
|
304
|
+
rule.Parent = row
|
|
305
|
+
runtime.rule = rule
|
|
306
|
+
|
|
307
|
+
field:GetPropertyChangedSignal("Text"):Connect(function()
|
|
308
|
+
-- Typing puts the user back on the live line. Without this, editing a
|
|
309
|
+
-- recalled entry and pressing down would throw the edit away.
|
|
310
|
+
if runtime.offset ~= 0 then
|
|
311
|
+
runtime.offset = 0
|
|
312
|
+
runtime.draft = ""
|
|
313
|
+
end
|
|
314
|
+
showHint()
|
|
315
|
+
end)
|
|
316
|
+
|
|
317
|
+
field.FocusLost:Connect(function(enterPressed)
|
|
318
|
+
if not enterPressed then
|
|
319
|
+
return
|
|
320
|
+
end
|
|
321
|
+
local line = string.match(field.Text, "^%s*(.-)%s*$") or ""
|
|
322
|
+
field.Text = ""
|
|
323
|
+
if runtime.hint then
|
|
324
|
+
(runtime.hint :: TextLabel).Text = ""
|
|
325
|
+
end
|
|
326
|
+
if line == "" then
|
|
327
|
+
return
|
|
328
|
+
end
|
|
329
|
+
remember(line)
|
|
330
|
+
local run = runtime.submit
|
|
331
|
+
if run ~= nil then
|
|
332
|
+
-- Spawned so a slow command -- one that waits on the bridge -- cannot
|
|
333
|
+
-- hold the field unresponsive while it runs.
|
|
334
|
+
task.spawn(run, line)
|
|
335
|
+
end
|
|
336
|
+
-- Re-focused on the next frame so a burst of commands can be typed
|
|
337
|
+
-- without reaching for the mouse. Enter releases focus first, so this
|
|
338
|
+
-- has to happen after Roblox is done taking it away.
|
|
339
|
+
task.defer(function()
|
|
340
|
+
if field.Parent ~= nil then
|
|
341
|
+
field:CaptureFocus()
|
|
342
|
+
end
|
|
343
|
+
end)
|
|
344
|
+
end)
|
|
345
|
+
|
|
346
|
+
field.InputBegan:Connect(function(input)
|
|
347
|
+
if input.UserInputType ~= Enum.UserInputType.Keyboard then
|
|
348
|
+
return
|
|
349
|
+
end
|
|
350
|
+
if input.KeyCode == Enum.KeyCode.Up then
|
|
351
|
+
recall(runtime.offset + 1)
|
|
352
|
+
elseif input.KeyCode == Enum.KeyCode.Down then
|
|
353
|
+
recall(runtime.offset - 1)
|
|
354
|
+
elseif input.KeyCode == Enum.KeyCode.Tab then
|
|
355
|
+
acceptHint()
|
|
356
|
+
end
|
|
357
|
+
end)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
--[[
|
|
361
|
+
Puts the cursor in the field, for the toolbar button and for `help`.
|
|
362
|
+
|
|
363
|
+
Guarded: the widget can be closed while a command is still finishing, and
|
|
364
|
+
capturing focus on a field whose window is gone throws.
|
|
365
|
+
]]
|
|
366
|
+
function Prompt.focus()
|
|
367
|
+
local field = runtime.field
|
|
368
|
+
if field ~= nil and field.Parent ~= nil then
|
|
369
|
+
pcall(function()
|
|
370
|
+
field:CaptureFocus()
|
|
371
|
+
end)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
--[[
|
|
376
|
+
Reports that an agent is running, in the one place the user is looking.
|
|
377
|
+
|
|
378
|
+
The log says what the agent is doing and the band says how long it is
|
|
379
|
+
taking; this says only that the line they are about to type will queue behind
|
|
380
|
+
something. That is a fact about the input, so it belongs to the input.
|
|
381
|
+
]]
|
|
382
|
+
function Prompt.setBusy(busy: boolean)
|
|
383
|
+
runtime.busy = busy
|
|
384
|
+
paintCaret()
|
|
385
|
+
local field = runtime.field
|
|
386
|
+
if field ~= nil then
|
|
387
|
+
field.PlaceholderText = placeholder()
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
--[[
|
|
392
|
+
Whether an agent started from this prompt is still working.
|
|
393
|
+
|
|
394
|
+
Read by the console before it announces a silence. The flag lives here
|
|
395
|
+
because the caret is what displays it, and one owner beats two that have to
|
|
396
|
+
be kept in step.
|
|
397
|
+
]]
|
|
398
|
+
--[[
|
|
399
|
+
Reports what Studio has selected, as a phrase rather than a list.
|
|
400
|
+
|
|
401
|
+
Called from the selection watcher, which has already debounced and worded
|
|
402
|
+
it. This only decides whether it is on screen, which is `showHint`'s job
|
|
403
|
+
anyway -- so the value is stored and the one renderer is asked to run again.
|
|
404
|
+
]]
|
|
405
|
+
function Prompt.setSelection(text: string)
|
|
406
|
+
runtime.selection = text
|
|
407
|
+
showHint()
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
function Prompt.isBusy(): boolean
|
|
411
|
+
return runtime.busy
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
function Prompt.applyTheme()
|
|
415
|
+
local palette = Themes.palette()
|
|
416
|
+
local row = runtime.row
|
|
417
|
+
if row then
|
|
418
|
+
row.BackgroundColor3 = palette.background
|
|
419
|
+
end
|
|
420
|
+
local field = runtime.field
|
|
421
|
+
if field then
|
|
422
|
+
field.TextColor3 = palette.text
|
|
423
|
+
field.PlaceholderColor3 = palette.dim
|
|
424
|
+
end
|
|
425
|
+
local hint = runtime.hint
|
|
426
|
+
if hint then
|
|
427
|
+
hint.TextColor3 = palette.dim
|
|
428
|
+
end
|
|
429
|
+
local rule = runtime.rule
|
|
430
|
+
if rule then
|
|
431
|
+
rule.BackgroundColor3 = palette.rule
|
|
432
|
+
end
|
|
433
|
+
paintCaret()
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
return Prompt
|
|
@@ -52,13 +52,60 @@ end
|
|
|
52
52
|
Raises SCRIPT_LOCKED when the write is refused, which normally means the
|
|
53
53
|
script is a package member or is owned by another Team Create session.
|
|
54
54
|
]]
|
|
55
|
+
--[[
|
|
56
|
+
Makes sure a write that reported success actually stuck.
|
|
57
|
+
|
|
58
|
+
`UpdateSourceAsync` returns true and the editor buffer shows the new text --
|
|
59
|
+
and then, a third of a second later, the freshly opened document finishes
|
|
60
|
+
loading and overwrites the buffer with the source from disk. The write is
|
|
61
|
+
gone, nothing raised, and the tool reports the edit applied. Measured:
|
|
62
|
+
|
|
63
|
+
updateOk = true
|
|
64
|
+
immediately = "M.rate = 99" <- the write landed
|
|
65
|
+
after 0.5s = "M.rate = 60" <- and was thrown away
|
|
66
|
+
|
|
67
|
+
Reading back straight after the write therefore proves nothing; the first
|
|
68
|
+
version of this check did exactly that, saw a match, and returned happy
|
|
69
|
+
while the edit was still about to be discarded. The read has to happen after
|
|
70
|
+
the load could have finished.
|
|
71
|
+
|
|
72
|
+
Nothing here opens editor tabs any more, so the loading document that
|
|
73
|
+
caused this is only reachable when the USER opens one in the same moment we
|
|
74
|
+
write to it -- rare, and cheap enough to cover anyway. One settle, one read,
|
|
75
|
+
and a rewrite if it did not take.
|
|
76
|
+
]]
|
|
77
|
+
local SETTLE = 0.2
|
|
78
|
+
|
|
79
|
+
local function confirm(target: LuaSourceContainer, produced: string?)
|
|
80
|
+
if produced == nil then
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
-- Three rounds: the race is with one load, so a single rewrite settles it,
|
|
85
|
+
-- and a script that discards three identical writes is failing for a reason
|
|
86
|
+
-- repeating will not fix.
|
|
87
|
+
for _ = 1, 3 do
|
|
88
|
+
task.wait(SETTLE)
|
|
89
|
+
if ScriptEdit.read(target) == produced then
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
pcall(function()
|
|
93
|
+
ScriptEditorService:UpdateSourceAsync(target, function()
|
|
94
|
+
return produced
|
|
95
|
+
end)
|
|
96
|
+
end)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
55
100
|
function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> string)
|
|
56
101
|
local pending: any = nil
|
|
57
102
|
|
|
103
|
+
local produced: string? = nil
|
|
58
104
|
local ok, err = pcall(function()
|
|
59
105
|
ScriptEditorService:UpdateSourceAsync(target, function(source)
|
|
60
106
|
local applied, result = pcall(transform, source)
|
|
61
107
|
if applied then
|
|
108
|
+
produced = result
|
|
62
109
|
return result
|
|
63
110
|
end
|
|
64
111
|
pending = result
|
|
@@ -70,6 +117,7 @@ function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> str
|
|
|
70
117
|
error(pending, 0)
|
|
71
118
|
end
|
|
72
119
|
if ok then
|
|
120
|
+
confirm(target, produced)
|
|
73
121
|
return
|
|
74
122
|
end
|
|
75
123
|
|