gdharness 1.1.21 → 1.1.23

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.
Files changed (51) hide show
  1. package/build/cli.js +1470 -726
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +12 -2
  3. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  4. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  6. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  8. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  12. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  14. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  17. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +15 -5
  20. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +51 -5
  21. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  24. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +89 -16
  25. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +107 -343
  26. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  27. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  28. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  29. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  30. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  31. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +61 -28
  32. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  33. package/build/godot/operations/audio_buses.gd +47 -11
  34. package/build/godot/operations/class_cache.gd +18 -2
  35. package/build/godot/operations/classdb_queries.gd +28 -17
  36. package/build/godot/operations/dependencies.gd +128 -17
  37. package/build/godot/operations/file_walk.gd +20 -0
  38. package/build/godot/operations/gdscript_analysis.gd +59 -20
  39. package/build/godot/operations/gdscript_authoring.gd +127 -60
  40. package/build/godot/operations/godot_operations.gd +24 -5
  41. package/build/godot/operations/import_pipeline.gd +179 -19
  42. package/build/godot/operations/logger.gd +2 -2
  43. package/build/godot/operations/plugins.gd +88 -70
  44. package/build/godot/operations/project_config.gd +41 -6
  45. package/build/godot/operations/project_diagnostics.gd +27 -11
  46. package/build/godot/operations/reading.gd +17 -0
  47. package/build/godot/operations/resource_files.gd +60 -17
  48. package/build/godot/operations/serialisation.gd +116 -7
  49. package/build/index.js +1385 -553
  50. package/build/keeper.js +4 -0
  51. package/package.json +1 -1
@@ -5,60 +5,16 @@ extends Node
5
5
 
6
6
  const Read = preload("../reading.gd")
7
7
  const PropertyValues = preload("../property_values.gd")
8
+ const SceneFile = preload("../scene_file.gd")
8
9
  const Serialisation = preload("../serialisation.gd")
9
10
 
10
11
  var _editor_plugin: EditorPlugin = null
11
12
  var _values: Serialisation = Serialisation.new()
12
13
  var _properties: PropertyValues = PropertyValues.new()
13
14
 
14
- ## How many scans the editor has completed, counted off `sources_changed`, which it emits at the end
15
- ## of every scan once the reimport the scan found is done: the one moment a scan is over. Neither
16
- ## flag it offers says so. A threaded scan stops reporting itself when its thread finishes, and its
17
- ## import runs a frame or more later when the thread is joined; an editor in the background took
18
- ## long enough over that frame that a rescan answered before the import of 211 changed scenes began.
19
- var _scans_completed: int = 0
20
-
21
- ## When the last scan completed, on the engine's clock, or -1 before the first. The editor writes
22
- ## the class cache a frame after it stops reporting a scan, measured on 4.7.2, so a game started
23
- ## in between reads a file being rewritten; how long ago the scan ended is what tells a caller
24
- ## whether that write can still be coming.
25
- var _scan_finished_at_msec: int = -1
26
-
27
- ## How many reimports asked for through [method reimport_files] have finished. Counted here rather
28
- ## than off `resources_reimported`, which a scan's own import emits as well.
29
- var _reimports_completed: int = 0
30
-
31
15
 
32
16
  func set_editor_plugin(plugin: EditorPlugin) -> void:
33
17
  _editor_plugin = plugin
34
- var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
35
- if not filesystem.sources_changed.is_connected(_scan_completed):
36
- var watched: int = filesystem.sources_changed.connect(_scan_completed)
37
- if watched != OK:
38
- push_error("gdharness could not watch for completed scans: %d" % watched)
39
-
40
-
41
- func _scan_completed(_sources_exist: bool) -> void:
42
- _scans_completed += 1
43
- _scan_finished_at_msec = Time.get_ticks_msec()
44
-
45
-
46
- func _refresh_and_reload(scene_path: String) -> void:
47
- _refresh_filesystem()
48
- _reload_scene_in_editor(scene_path)
49
-
50
-
51
- func _refresh_filesystem() -> void:
52
- if _editor_plugin:
53
- EditorInterface.get_resource_filesystem().scan()
54
-
55
-
56
- func _reload_scene_in_editor(scene_path: String) -> void:
57
- if not _editor_plugin:
58
- return
59
- var edited: Node = EditorInterface.get_edited_scene_root()
60
- if edited and edited.scene_file_path == scene_path:
61
- EditorInterface.reload_scene_from_path(scene_path)
62
18
 
63
19
 
64
20
  func _ensure_res_path(path: String) -> String:
@@ -84,29 +40,10 @@ func _to_scene_res_path(project_path: String, scene_path: String) -> String:
84
40
  return _ensure_res_path(p)
85
41
 
86
42
 
87
- func _load_scene(scene_path: String) -> Array:
88
- if not FileAccess.file_exists(scene_path):
89
- return [null, {"ok": false, "error": "Scene not found: " + scene_path}]
90
- var packed: PackedScene = load(scene_path)
91
- if not packed:
92
- return [null, {"ok": false, "error": "Failed to load: " + scene_path}]
93
- var root: Node = packed.instantiate()
94
- if not root:
95
- return [null, {"ok": false, "error": "Failed to instantiate: " + scene_path}]
96
- return [root, {}]
97
-
98
-
99
- func _save_scene(scene_root: Node, scene_path: String) -> Dictionary:
100
- var packed: PackedScene = PackedScene.new()
101
- if packed.pack(scene_root) != OK:
102
- scene_root.queue_free()
103
- return {"ok": false, "error": "Failed to pack scene"}
104
- if ResourceSaver.save(packed, scene_path) != OK:
105
- scene_root.queue_free()
106
- return {"ok": false, "error": "Failed to save scene"}
107
- scene_root.queue_free()
108
- _refresh_and_reload(scene_path)
109
- return {}
43
+ ## [param scene_path] opened for a tool, as [scene, refusal]: the refusal is {} when it opened.
44
+ func _open(scene_path: String, for_writing: bool) -> Array:
45
+ var scene: SceneFile = SceneFile.new()
46
+ return [scene, scene.open(scene_path, for_writing)]
110
47
 
111
48
 
112
49
  func _find_node(root: Node, path: String) -> Node:
@@ -115,9 +52,156 @@ func _find_node(root: Node, path: String) -> Node:
115
52
  return root.get_node_or_null(path)
116
53
 
117
54
 
118
- ## Set each property, answering with what went wrong or "" when nothing did.
119
- func _set_node_properties(node: Node, properties: Dictionary) -> String:
120
- return _properties.write_all(node, properties)
55
+ ## Why the property or signal [param missing] could not be found on [param node], adding the one
56
+ ## cause the name alone does not show: a script that does not compile leaves the editor holding a
57
+ ## placeholder with no properties or signals at all, and the refusal named the property instead.
58
+ func _missing_on(node: Node, missing: String) -> String:
59
+ var script: Script = node.get_script()
60
+ if script == null or not script.has_source_code():
61
+ return missing
62
+ var probe: GDScript = GDScript.new()
63
+ probe.source_code = script.source_code
64
+ if probe.reload() == OK:
65
+ return missing
66
+ return (
67
+ "%s. Its script, %s, does not compile, so the editor knows none of what it declares."
68
+ % [missing, script.resource_path]
69
+ )
70
+
71
+
72
+ ## Whether each of [param names] reads the same off [param saved] as off [param node], answering
73
+ ## the first that does not, or "".
74
+ func _kept(scene: SceneFile, saved: Node, node: Node, names: Array) -> String:
75
+ var at: String = scene.path_of(node)
76
+ var copy: Node = saved.get_node_or_null(at)
77
+ if copy == null:
78
+ return "%s is not in the scene as it would be saved." % at
79
+ for key: Variant in names:
80
+ var property: String = str(key)
81
+ var loaded: Variant = copy.get(property)
82
+ if not scene.kept(saved, loaded, node.get(property)):
83
+ return (
84
+ (
85
+ "%s.%s would load from the file as %s: the scene does not keep it, because the property "
86
+ + "is not one a scene stores or the node belongs to another scene."
87
+ )
88
+ % [at, property, JSON.stringify(_values.serialize_value(loaded))]
89
+ )
90
+ return ""
91
+
92
+
93
+ ## Owns [param node] and the nodes under it to [param root], except those an instanced scene
94
+ ## inside it owns: giving those to the root made the pack write the instance's own nodes into this
95
+ ## scene as overrides.
96
+ func _copy(source: Node, root: Node, copies: Dictionary) -> Node:
97
+ var copy: Node = null
98
+ if not source.scene_file_path.is_empty():
99
+ # An instanced scene made again from its file, so the copy is an instance too. Node.duplicate
100
+ # makes a plain node of the instance's class, and the pack saved it with every property of the
101
+ # instance's root frozen into this scene.
102
+ var packed: PackedScene = ResourceLoader.load(source.scene_file_path, "PackedScene") as PackedScene
103
+ if packed == null:
104
+ return null
105
+ copy = packed.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
106
+ for declared: Dictionary in source.get_property_list():
107
+ if not (Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
108
+ continue
109
+ var property: String = str(declared.get("name", ""))
110
+ var value: Variant = source.get(property)
111
+ if not PropertyValues.same(copy.get(property), value):
112
+ copy.set(property, value)
113
+ for group: StringName in source.get_groups():
114
+ if not copy.is_in_group(group):
115
+ copy.add_to_group(group, true)
116
+ else:
117
+ copy = source.duplicate(Node.DUPLICATE_GROUPS | Node.DUPLICATE_SCRIPTS)
118
+ for child: Node in copy.get_children():
119
+ copy.remove_child(child)
120
+ child.free()
121
+ copy.name = source.name
122
+ copies[source] = copy
123
+ # Only what this scene owns: an instance's own nodes come with the instance.
124
+ for child: Node in source.get_children():
125
+ if child.owner == root:
126
+ var copied: Node = _copy(child, root, copies)
127
+ if copied == null:
128
+ return null
129
+ copy.add_child(copied)
130
+ return copy
131
+
132
+
133
+ ## Gives each copy in [param copies] the persistent connections its source has, to the copy of the
134
+ ## target when the target was copied too. A connection an instance makes inside itself is skipped:
135
+ ## the instance made again from its file already has it.
136
+ func _reconnect(copies: Dictionary, top: Node) -> void:
137
+ for key: Variant in copies:
138
+ var source: Node = key
139
+ var copy: Node = copies[key]
140
+ for info: Dictionary in source.get_signal_list():
141
+ var signal_name: String = str(info.get("name", ""))
142
+ for conn: Dictionary in source.get_signal_connection_list(signal_name):
143
+ var flags: int = Read.as_int(conn.get("flags", 0))
144
+ var callable: Callable = conn.get("callable", Callable())
145
+ var target: Object = callable.get_object()
146
+ if not flags & Object.CONNECT_PERSIST or target == null:
147
+ continue
148
+ var to: Object = copies.get(target, target)
149
+ if to == target and target is Node and (target == top or top.is_ancestor_of(target as Node)):
150
+ continue
151
+ var copied: Callable = Callable(to, callable.get_method())
152
+ if (
153
+ not copy.is_connected(signal_name, copied)
154
+ and copy.connect(signal_name, copied, flags) != OK
155
+ ):
156
+ push_error(
157
+ "gdharness: could not copy the connection %s of %s" % [signal_name, source.name]
158
+ )
159
+
160
+
161
+ func _own(node: Node, root: Node) -> void:
162
+ node.owner = root
163
+ for child: Node in node.get_children():
164
+ if child.owner == null or child.owner == root:
165
+ _own(child, root)
166
+
167
+
168
+ ## [param node]'s stored properties as a read answers them. A property holding a node is answered
169
+ ## as the path to it: the copy read here is never in the tree, and an object description named a
170
+ ## node that was freed when the call ended.
171
+ func _properties_of(node: Node, include_defaults: bool, set_here: Dictionary) -> Dictionary:
172
+ var script: Script = node.get_script()
173
+ var scripted: Dictionary = {}
174
+ if script != null:
175
+ for declared: Dictionary in script.get_script_property_list():
176
+ scripted[str(declared.get("name", ""))] = true
177
+ var props: Dictionary = {}
178
+ for p: Dictionary in node.get_property_list():
179
+ if not (Read.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
180
+ continue
181
+ var property: String = str(p.get("name", ""))
182
+ if property.is_empty():
183
+ continue
184
+ var value: Variant = node.get(property)
185
+ if not include_defaults and not set_here.has(property):
186
+ var default: Variant = (
187
+ script.get_property_default_value(property)
188
+ if scripted.has(property)
189
+ else ClassDB.class_get_property_default_value(node.get_class(), property)
190
+ )
191
+ if PropertyValues.same(value, default):
192
+ continue
193
+ props[property] = _readable(node, value)
194
+ return props
195
+
196
+
197
+ func _readable(node: Node, value: Variant) -> Variant:
198
+ if value is Node:
199
+ var target: Node = value
200
+ return _values.serialize_value(node.get_path_to(target))
201
+ if value is Array:
202
+ var items: Array = value
203
+ return items.map(func(item: Variant) -> Variant: return _readable(node, item))
204
+ return _values.serialize_value(value)
121
205
 
122
206
 
123
207
  func _parse_properties_arg(raw_properties: Variant) -> Dictionary:
@@ -133,20 +217,6 @@ func _parse_properties_arg(raw_properties: Variant) -> Dictionary:
133
217
  return {}
134
218
 
135
219
 
136
- func _ensure_parent_dir_for_scene(scene_path: String) -> void:
137
- var base_dir: String = scene_path.get_base_dir()
138
- if not DirAccess.dir_exists_absolute(base_dir):
139
- var made: Error = DirAccess.make_dir_recursive_absolute(base_dir)
140
- if made != OK:
141
- push_error("gdharness: could not create " + base_dir + ": " + error_string(made))
142
-
143
-
144
- func _set_owner_recursive(node: Node, scene_owner: Node) -> void:
145
- node.owner = scene_owner
146
- for child: Node in node.get_children():
147
- _set_owner_recursive(child, scene_owner)
148
-
149
-
150
220
  func _build_node_tree(
151
221
  node: Node, include_properties: bool, depth: int, current_depth: int, node_path: String
152
222
  ) -> Dictionary:
@@ -156,15 +226,7 @@ func _build_node_tree(
156
226
  }
157
227
 
158
228
  if include_properties:
159
- var props: Dictionary = {}
160
- for p: Dictionary in node.get_property_list():
161
- if not (Read.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
162
- continue
163
- var pn: String = str(p.get("name", ""))
164
- if pn.is_empty():
165
- continue
166
- props[pn] = _values.serialize_value(node.get(pn))
167
- data["properties"] = props
229
+ data["properties"] = _properties_of(node, true, {})
168
230
 
169
231
  if depth >= 0 and current_depth >= depth:
170
232
  return data
@@ -193,27 +255,53 @@ func create_scene(args: Dictionary) -> Dictionary:
193
255
  return {"ok": false, "error": "Missing scenePath"}
194
256
  if not scene_path.ends_with(".tscn"):
195
257
  scene_path += ".tscn"
196
- if not ClassDB.class_exists(root_node_type):
197
- return {"ok": false, "error": "Invalid rootNodeType: " + root_node_type}
198
-
199
- _ensure_parent_dir_for_scene(scene_path)
258
+ # A class that is not a node went into a Node variable, which is a runtime error the caller
259
+ # heard about only as "Invalid tool result".
260
+ if not ClassDB.class_exists(root_node_type) or not ClassDB.is_parent_class(root_node_type, "Node"):
261
+ return {
262
+ "ok": false, "error": "rootNodeType must be a node class, and %s is not one." % root_node_type
263
+ }
264
+ if not ClassDB.can_instantiate(root_node_type):
265
+ return {"ok": false, "error": "%s cannot be instantiated." % root_node_type}
266
+ # Saving over a scene that is there replaced it, nodes and all, and answered as a new scene.
267
+ if FileAccess.file_exists(scene_path):
268
+ return {
269
+ "ok": false,
270
+ "error":
271
+ (
272
+ (
273
+ "%s already exists, and scene_create op=create would replace everything in it. Edit it "
274
+ + "with scene_node, or create the scene under another path."
275
+ )
276
+ % scene_path
277
+ )
278
+ }
200
279
 
201
280
  var root: Node = ClassDB.instantiate(root_node_type)
202
- if not root:
203
- return {"ok": false, "error": "Failed to instantiate root node: " + root_node_type}
204
281
  root.name = root_node_type
205
282
 
206
283
  if not script_path.is_empty():
207
284
  var full_script_path: String = _to_scene_res_path(project_path, script_path)
208
- var script: Resource = load(full_script_path)
209
- if not script:
210
- root.queue_free()
211
- return {"ok": false, "error": "Failed to load script: " + full_script_path}
285
+ var script: Script = load(full_script_path) as Script
286
+ if script == null:
287
+ root.free()
288
+ return {"ok": false, "error": "No script loads from " + full_script_path}
289
+ var base: String = str(script.get_instance_base_type())
290
+ if not ClassDB.is_parent_class(root_node_type, base):
291
+ root.free()
292
+ return {
293
+ "ok": false,
294
+ "error":
295
+ "%s extends %s, so it cannot be on a %s root." % [full_script_path, base, root_node_type]
296
+ }
212
297
  root.set_script(script)
213
298
 
214
- var err: Dictionary = _save_scene(root, scene_path)
215
- if not err.is_empty():
216
- return err
299
+ var scene: SceneFile = SceneFile.new()
300
+ scene.root = root
301
+ scene.path = scene_path
302
+ var written: Dictionary = scene.write()
303
+ if not written.is_empty():
304
+ return written
217
305
 
218
306
  return {"ok": true, "scenePath": scene_path, "rootNodeType": root_node_type}
219
307
 
@@ -224,15 +312,15 @@ func list_scene_nodes(args: Dictionary) -> Dictionary:
224
312
  var depth: int = Read.as_int(args.get("depth", -1), -1)
225
313
  var include_properties: bool = Read.as_bool(args.get("includeProperties", false))
226
314
 
227
- var loaded: Array = _load_scene(scene_path)
228
- var refused: Dictionary = loaded[1]
315
+ var opened: Array = _open(scene_path, false)
316
+ var scene: SceneFile = opened[0]
317
+ var refused: Dictionary = opened[1]
229
318
  if not refused.is_empty():
230
319
  return refused
231
320
 
232
- var root: Node = loaded[0]
233
- var tree: Dictionary = _build_node_tree(root, include_properties, depth, 0, ".")
234
- root.queue_free()
235
- return {"ok": true, "tree": tree}
321
+ var tree: Dictionary = _build_node_tree(scene.root, include_properties, depth, 0, ".")
322
+ scene.close()
323
+ return {"ok": true, "tree": tree, "unsavedInEditor": SceneFile.unsaved(scene_path)}
236
324
 
237
325
 
238
326
  func add_node(args: Dictionary) -> Dictionary:
@@ -245,40 +333,45 @@ func add_node(args: Dictionary) -> Dictionary:
245
333
 
246
334
  if node_type.is_empty() or node_name.is_empty():
247
335
  return {"ok": false, "error": "Missing nodeType or nodeName"}
248
- if not ClassDB.class_exists(node_type):
249
- return {"ok": false, "error": "Invalid nodeType: " + node_type}
250
-
251
- var loaded: Array = _load_scene(scene_path)
252
- var refused: Dictionary = loaded[1]
336
+ if not ClassDB.class_exists(node_type) or not ClassDB.is_parent_class(node_type, "Node"):
337
+ return {"ok": false, "error": "nodeType must be a node class, and %s is not one." % node_type}
338
+ if not ClassDB.can_instantiate(node_type):
339
+ return {"ok": false, "error": "%s cannot be instantiated." % node_type}
340
+
341
+ var opened: Array = _open(scene_path, true)
342
+ var scene: SceneFile = opened[0]
343
+ var refused: Dictionary = opened[1]
253
344
  if not refused.is_empty():
254
345
  return refused
255
346
 
256
- var root: Node = loaded[0]
347
+ var root: Node = scene.root
257
348
  var parent: Node = _find_node(root, parent_node_path)
258
349
  if not parent:
259
- root.queue_free()
260
- return {"ok": false, "error": "Parent node not found: " + parent_node_path}
261
-
350
+ return scene.refuse("Parent node not found: " + parent_node_path)
351
+ var foreign: String = scene.foreign(parent)
352
+ if not foreign.is_empty():
353
+ return scene.refuse(foreign)
354
+ var unusable: String = scene.unusable_name(parent, node_name)
355
+ if not unusable.is_empty():
356
+ return scene.refuse(unusable)
357
+
358
+ # In the tree before its properties, so a property holding a node can find it by path.
262
359
  var new_node: Node = ClassDB.instantiate(node_type)
263
- if not new_node:
264
- root.queue_free()
265
- return {"ok": false, "error": "Failed to instantiate nodeType: " + node_type}
266
-
267
360
  new_node.name = node_name
268
- var refused_property: String = _set_node_properties(new_node, properties)
269
- if not refused_property.is_empty():
270
- new_node.queue_free()
271
- root.queue_free()
272
- return {"ok": false, "error": refused_property}
273
-
274
361
  parent.add_child(new_node)
275
- _set_owner_recursive(new_node, root)
362
+ _own(new_node, root)
363
+ var refused_property: String = _properties.write_all(new_node, properties)
364
+ if not refused_property.is_empty():
365
+ return scene.refuse(refused_property)
276
366
 
277
- var err: Dictionary = _save_scene(root, scene_path)
278
- if not err.is_empty():
279
- return err
367
+ var new_path: String = scene.path_of(new_node)
368
+ var written: Dictionary = scene.write(
369
+ func(saved: Node) -> String: return _kept(scene, saved, new_node, properties.keys())
370
+ )
371
+ if not written.is_empty():
372
+ return written
280
373
 
281
- return {"ok": true, "nodeName": node_name, "nodeType": node_type}
374
+ return {"ok": true, "nodeName": node_name, "nodeType": node_type, "nodePath": new_path}
282
375
 
283
376
 
284
377
  func delete_node(args: Dictionary) -> Dictionary:
@@ -289,30 +382,33 @@ func delete_node(args: Dictionary) -> Dictionary:
289
382
  if node_path.is_empty() or node_path == ".":
290
383
  return {"ok": false, "error": "Cannot delete root node"}
291
384
 
292
- var loaded: Array = _load_scene(scene_path)
293
- var refused: Dictionary = loaded[1]
385
+ var opened: Array = _open(scene_path, true)
386
+ var scene: SceneFile = opened[0]
387
+ var refused: Dictionary = opened[1]
294
388
  if not refused.is_empty():
295
389
  return refused
296
390
 
297
- var root: Node = loaded[0]
298
- var node: Node = _find_node(root, node_path)
391
+ var node: Node = _find_node(scene.root, node_path)
299
392
  if not node:
300
- root.queue_free()
301
- return {"ok": false, "error": "Node not found: " + node_path}
302
-
303
- var parent: Node = node.get_parent()
304
- if not parent:
305
- root.queue_free()
306
- return {"ok": false, "error": "Cannot delete root node"}
393
+ return scene.refuse("Node not found: " + node_path)
394
+ var fixed: String = scene.fixed(node)
395
+ if not fixed.is_empty():
396
+ return scene.refuse(fixed)
307
397
 
308
- parent.remove_child(node)
309
- node.queue_free()
398
+ var at: String = scene.path_of(node)
399
+ node.get_parent().remove_child(node)
400
+ node.free()
310
401
 
311
- var err: Dictionary = _save_scene(root, scene_path)
312
- if not err.is_empty():
313
- return err
402
+ var written: Dictionary = scene.write(
403
+ func(saved: Node) -> String:
404
+ if saved.get_node_or_null(at) != null:
405
+ return "%s would still be in the scene when it loads." % at
406
+ return ""
407
+ )
408
+ if not written.is_empty():
409
+ return written
314
410
 
315
- return {"ok": true, "deletedNodePath": node_path}
411
+ return {"ok": true, "deletedNodePath": at}
316
412
 
317
413
 
318
414
  func duplicate_node(args: Dictionary) -> Dictionary:
@@ -325,38 +421,55 @@ func duplicate_node(args: Dictionary) -> Dictionary:
325
421
  if node_path.is_empty() or new_name.is_empty():
326
422
  return {"ok": false, "error": "Missing nodePath or newName"}
327
423
 
328
- var loaded: Array = _load_scene(scene_path)
329
- var refused: Dictionary = loaded[1]
424
+ var opened: Array = _open(scene_path, true)
425
+ var scene: SceneFile = opened[0]
426
+ var refused: Dictionary = opened[1]
330
427
  if not refused.is_empty():
331
428
  return refused
332
429
 
333
- var root: Node = loaded[0]
430
+ var root: Node = scene.root
334
431
  var source: Node = _find_node(root, node_path)
335
432
  if not source:
336
- root.queue_free()
337
- return {"ok": false, "error": "Node not found: " + node_path}
433
+ return scene.refuse("Node not found: " + node_path)
434
+ if source == root:
435
+ return scene.refuse("The root of %s cannot be copied into its own scene." % scene_path)
436
+ var foreign: String = scene.foreign(source)
437
+ if not foreign.is_empty():
438
+ return scene.refuse(foreign)
338
439
 
339
440
  var target_parent: Node = source.get_parent()
340
441
  if not parent_path.is_empty():
341
442
  target_parent = _find_node(root, parent_path)
342
443
  if not target_parent:
343
- root.queue_free()
344
- return {"ok": false, "error": "Parent not found: " + parent_path}
345
-
346
- var duplicated_node: Node = source.duplicate()
444
+ return scene.refuse("Parent not found: " + parent_path)
445
+ foreign = scene.foreign(target_parent)
446
+ if not foreign.is_empty():
447
+ return scene.refuse(foreign)
448
+ var unusable: String = scene.unusable_name(target_parent, new_name)
449
+ if not unusable.is_empty():
450
+ return scene.refuse(unusable)
451
+
452
+ var copies: Dictionary = {}
453
+ var duplicated_node: Node = _copy(source, root, copies)
347
454
  if not duplicated_node:
348
- root.queue_free()
349
- return {"ok": false, "error": "Failed to duplicate node: " + node_path}
455
+ return scene.refuse("Failed to duplicate node: " + node_path)
350
456
 
351
457
  duplicated_node.name = new_name
352
458
  target_parent.add_child(duplicated_node)
353
- _set_owner_recursive(duplicated_node, root)
459
+ _own(duplicated_node, root)
460
+ _reconnect(copies, source)
354
461
 
355
- var err: Dictionary = _save_scene(root, scene_path)
356
- if not err.is_empty():
357
- return err
462
+ var new_path: String = scene.path_of(duplicated_node)
463
+ var written: Dictionary = scene.write(
464
+ func(saved: Node) -> String:
465
+ if saved.get_node_or_null(new_path) == null:
466
+ return "the copy would not be in the scene when it loads."
467
+ return ""
468
+ )
469
+ if not written.is_empty():
470
+ return written
358
471
 
359
- return {"ok": true, "nodePath": node_path, "newName": new_name}
472
+ return {"ok": true, "nodePath": node_path, "newName": new_name, "newNodePath": new_path}
360
473
 
361
474
 
362
475
  func reparent_node(args: Dictionary) -> Dictionary:
@@ -370,35 +483,53 @@ func reparent_node(args: Dictionary) -> Dictionary:
370
483
  if new_parent_path.is_empty():
371
484
  return {"ok": false, "error": "Missing newParentPath"}
372
485
 
373
- var loaded: Array = _load_scene(scene_path)
374
- var refused: Dictionary = loaded[1]
486
+ var opened: Array = _open(scene_path, true)
487
+ var scene: SceneFile = opened[0]
488
+ var refused: Dictionary = opened[1]
375
489
  if not refused.is_empty():
376
490
  return refused
377
491
 
378
- var root: Node = loaded[0]
492
+ var root: Node = scene.root
379
493
  var node: Node = _find_node(root, node_path)
380
494
  var new_parent: Node = _find_node(root, new_parent_path)
381
495
  if not node:
382
- root.queue_free()
383
- return {"ok": false, "error": "Node not found: " + node_path}
496
+ return scene.refuse("Node not found: " + node_path)
384
497
  if not new_parent:
385
- root.queue_free()
386
- return {"ok": false, "error": "New parent not found: " + new_parent_path}
387
-
388
- var old_parent: Node = node.get_parent()
389
- if not old_parent:
390
- root.queue_free()
391
- return {"ok": false, "error": "Cannot reparent root node"}
392
-
393
- old_parent.remove_child(node)
498
+ return scene.refuse("New parent not found: " + new_parent_path)
499
+ var fixed: String = scene.fixed(node)
500
+ if not fixed.is_empty():
501
+ return scene.refuse(fixed)
502
+ # Removed first and then refused by add_child, the node and everything under it were saved as
503
+ # gone.
504
+ if new_parent == node or node.is_ancestor_of(new_parent):
505
+ return scene.refuse(
506
+ "%s cannot go under %s, which is itself or inside it." % [node_path, new_parent_path]
507
+ )
508
+ var foreign: String = scene.foreign(new_parent)
509
+ if not foreign.is_empty():
510
+ return scene.refuse(foreign)
511
+ var unusable: String = scene.unusable_name(new_parent, str(node.name), node)
512
+ if not unusable.is_empty():
513
+ return scene.refuse(unusable)
514
+
515
+ var old_path: String = scene.path_of(node)
516
+ node.get_parent().remove_child(node)
394
517
  new_parent.add_child(node)
395
- _set_owner_recursive(node, root)
518
+ _own(node, root)
396
519
 
397
- var err: Dictionary = _save_scene(root, scene_path)
398
- if not err.is_empty():
399
- return err
520
+ var new_path: String = scene.path_of(node)
521
+ var written: Dictionary = scene.write(
522
+ func(saved: Node) -> String:
523
+ if saved.get_node_or_null(new_path) == null:
524
+ return "%s would not be in the scene when it loads." % new_path
525
+ if old_path != new_path and saved.get_node_or_null(old_path) != null:
526
+ return "%s would still be in the scene as well when it loads." % old_path
527
+ return ""
528
+ )
529
+ if not written.is_empty():
530
+ return written
400
531
 
401
- return {"ok": true, "nodePath": node_path, "newParentPath": new_parent_path}
532
+ return {"ok": true, "nodePath": old_path, "newParentPath": new_parent_path, "newNodePath": new_path}
402
533
 
403
534
 
404
535
  func set_node_properties(args: Dictionary) -> Dictionary:
@@ -407,27 +538,41 @@ func set_node_properties(args: Dictionary) -> Dictionary:
407
538
  var node_path: String = str(args.get("nodePath", "."))
408
539
  var properties: Dictionary = _parse_properties_arg(args.get("properties", {}))
409
540
 
410
- var loaded: Array = _load_scene(scene_path)
411
- var refused: Dictionary = loaded[1]
541
+ var opened: Array = _open(scene_path, true)
542
+ var scene: SceneFile = opened[0]
543
+ var refused: Dictionary = opened[1]
412
544
  if not refused.is_empty():
413
545
  return refused
414
546
 
415
- var root: Node = loaded[0]
416
- var node: Node = _find_node(root, node_path)
547
+ var node: Node = _find_node(scene.root, node_path)
417
548
  if not node:
418
- root.queue_free()
419
- return {"ok": false, "error": "Node not found: " + node_path}
420
-
421
- var refused_property: String = _set_node_properties(node, properties)
549
+ return scene.refuse("Node not found: " + node_path)
550
+ var foreign: String = scene.foreign(node)
551
+ if not foreign.is_empty():
552
+ return scene.refuse(foreign)
553
+ if properties.has("name") and node != scene.root:
554
+ var fixed: String = scene.fixed(node)
555
+ if not fixed.is_empty():
556
+ return scene.refuse(fixed)
557
+ var unusable: String = scene.unusable_name(node.get_parent(), str(properties["name"]), node)
558
+ if not unusable.is_empty():
559
+ return scene.refuse(unusable)
560
+
561
+ var refused_property: String = _properties.write_all(node, properties)
422
562
  if not refused_property.is_empty():
423
- root.queue_free()
424
- return {"ok": false, "error": refused_property}
563
+ return scene.refuse(_missing_on(node, refused_property))
425
564
 
426
- var err: Dictionary = _save_scene(root, scene_path)
427
- if not err.is_empty():
428
- return err
565
+ var held: Dictionary = {}
566
+ for key: Variant in properties:
567
+ held[str(key)] = _readable(node, node.get(str(key)))
568
+ var new_path: String = scene.path_of(node)
569
+ var written: Dictionary = scene.write(
570
+ func(saved: Node) -> String: return _kept(scene, saved, node, properties.keys())
571
+ )
572
+ if not written.is_empty():
573
+ return written
429
574
 
430
- return {"ok": true, "nodePath": node_path}
575
+ return {"ok": true, "nodePath": new_path, "properties": held}
431
576
 
432
577
 
433
578
  func get_node_properties(args: Dictionary) -> Dictionary:
@@ -436,40 +581,28 @@ func get_node_properties(args: Dictionary) -> Dictionary:
436
581
  var node_path: String = str(args.get("nodePath", "."))
437
582
  var include_defaults: bool = Read.as_bool(args.get("includeDefaults", false))
438
583
 
439
- var loaded: Array = _load_scene(scene_path)
440
- var refused: Dictionary = loaded[1]
584
+ var opened: Array = _open(scene_path, false)
585
+ var scene: SceneFile = opened[0]
586
+ var refused: Dictionary = opened[1]
441
587
  if not refused.is_empty():
442
588
  return refused
443
589
 
444
- var root: Node = loaded[0]
445
- var node: Node = _find_node(root, node_path)
590
+ var node: Node = _find_node(scene.root, node_path)
446
591
  if not node:
447
- root.queue_free()
448
- return {"ok": false, "error": "Node not found: " + node_path}
449
-
450
- var defaults: Node = null
451
- if not include_defaults and ClassDB.class_exists(node.get_class()):
452
- defaults = ClassDB.instantiate(node.get_class())
453
-
454
- var props: Dictionary = {}
455
- for p: Dictionary in node.get_property_list():
456
- var usage: int = Read.as_int(p.get("usage", 0))
457
- if not (usage & PROPERTY_USAGE_STORAGE):
458
- continue
459
- var prop_name: String = str(p.get("name", ""))
460
- if prop_name.is_empty():
461
- continue
462
- var current_val: Variant = node.get(prop_name)
463
- if not include_defaults and defaults:
464
- var default_val: Variant = defaults.get(prop_name)
465
- if current_val == default_val:
466
- continue
467
- props[prop_name] = _values.serialize_value(current_val)
468
-
469
- if defaults:
470
- defaults.queue_free()
471
- root.queue_free()
472
- return {"ok": true, "nodePath": node_path, "properties": props}
592
+ return scene.refuse("Node not found: " + node_path)
593
+
594
+ # Against the default the node would have in no scene: its script's for what the script
595
+ # declares, the class's for the rest. Compared with a bare instance of the class, every exported
596
+ # variable read as changed; and a value this scene sets back to the default was hidden, so the
597
+ # names this scene's file sets are always answered.
598
+ var props: Dictionary = _properties_of(node, include_defaults, scene.set_here(node))
599
+ scene.close()
600
+ return {
601
+ "ok": true,
602
+ "nodePath": node_path,
603
+ "properties": props,
604
+ "unsavedInEditor": SceneFile.unsaved(scene_path)
605
+ }
473
606
 
474
607
 
475
608
  func save_scene(args: Dictionary) -> Dictionary:
@@ -480,18 +613,69 @@ func save_scene(args: Dictionary) -> Dictionary:
480
613
  if not new_path_raw.is_empty():
481
614
  target_path = _to_scene_res_path(project_path, new_path_raw)
482
615
 
483
- var loaded: Array = _load_scene(scene_path)
484
- var refused: Dictionary = loaded[1]
485
- if not refused.is_empty():
486
- return refused
616
+ if target_path != scene_path:
617
+ var unsaved: String = SceneFile.unsaved_in_editor(target_path)
618
+ if not unsaved.is_empty():
619
+ return {"ok": false, "error": unsaved}
620
+
621
+ var open_root: Node = null
622
+ for open: Node in EditorInterface.get_open_scene_roots():
623
+ if open.scene_file_path == scene_path:
624
+ open_root = open
625
+ if open_root != null and target_path == scene_path:
626
+ return _save_open_scene(scene_path)
627
+
628
+ # Of the editor's scene when it is open, since that is what a save means, and of the file as it
629
+ # loads otherwise. The editor's scene is packed where it is, as the editor's own save packs it;
630
+ # packing leaves the nodes as they were.
631
+ var written: Dictionary = {}
632
+ if open_root != null:
633
+ var packed: PackedScene = PackedScene.new()
634
+ var packing: Error = packed.pack(open_root)
635
+ if packing != OK:
636
+ return {"ok": false, "error": "Failed to pack %s: %s" % [scene_path, error_string(packing)]}
637
+ written = SceneFile.save_packed(packed, target_path)
638
+ else:
639
+ var scene: SceneFile = SceneFile.new()
640
+ var refused: Dictionary = scene.open(scene_path, false)
641
+ if not refused.is_empty():
642
+ return refused
643
+ scene.path = target_path
644
+ written = scene.write()
645
+ if not written.is_empty():
646
+ return written
487
647
 
488
- _ensure_parent_dir_for_scene(target_path)
489
- var root: Node = loaded[0]
490
- var err: Dictionary = _save_scene(root, target_path)
491
- if not err.is_empty():
492
- return err
648
+ return {
649
+ "ok": true,
650
+ "scenePath": scene_path,
651
+ "savedPath": target_path,
652
+ "from": "editor" if open_root != null else "file"
653
+ }
493
654
 
494
- return {"ok": true, "scenePath": scene_path, "savedPath": target_path}
655
+
656
+ ## Saves the editor's own copy of [param scene_path] through the editor, which is what its Save
657
+ ## does. Loading the file and writing it back, as this did, saved the disk version, and the reload
658
+ ## after it discarded every unsaved change in the editor.
659
+ func _save_open_scene(scene_path: String) -> Dictionary:
660
+ var current: Node = EditorInterface.get_edited_scene_root()
661
+ var previous: String = current.scene_file_path if current != null else ""
662
+ if previous != scene_path:
663
+ EditorInterface.open_scene_from_path(scene_path)
664
+ var now: Node = EditorInterface.get_edited_scene_root()
665
+ if now == null or now.scene_file_path != scene_path:
666
+ return {"ok": false, "error": "The editor did not switch to %s to save it." % scene_path}
667
+ var saving: Error = EditorInterface.save_scene()
668
+ if not previous.is_empty() and previous != scene_path:
669
+ EditorInterface.open_scene_from_path(previous)
670
+ if saving != OK:
671
+ return {"ok": false, "error": "The editor failed to save %s: %s" % [scene_path, error_string(saving)]}
672
+ return {
673
+ "ok": true,
674
+ "scenePath": scene_path,
675
+ "savedPath": scene_path,
676
+ "from": "editor",
677
+ "unsavedInEditor": SceneFile.unsaved(scene_path)
678
+ }
495
679
 
496
680
 
497
681
  func connect_signal(args: Dictionary) -> Dictionary:
@@ -513,45 +697,94 @@ func connect_signal(args: Dictionary) -> Dictionary:
513
697
  ):
514
698
  return {"ok": false, "error": "Missing required signal connection arguments"}
515
699
 
516
- var loaded: Array = _load_scene(scene_path)
517
- var refused: Dictionary = loaded[1]
700
+ var opened: Array = _open(scene_path, true)
701
+ var scene: SceneFile = opened[0]
702
+ var refused: Dictionary = opened[1]
518
703
  if not refused.is_empty():
519
704
  return refused
520
705
 
521
- var root: Node = loaded[0]
522
- var source: Node = _find_node(root, source_node_path)
523
- var target: Node = _find_node(root, target_node_path)
524
- if not source:
525
- root.queue_free()
526
- return {"ok": false, "error": "Source node not found: " + source_node_path}
527
- if not target:
528
- root.queue_free()
529
- return {"ok": false, "error": "Target node not found: " + target_node_path}
530
- if not source.has_signal(signal_name):
531
- root.queue_free()
532
- return {"ok": false, "error": "Signal not found on source: " + signal_name}
706
+ var ends: Array = _connection_ends(scene, source_node_path, signal_name, target_node_path)
707
+ if ends[0] == null:
708
+ return scene.refuse(str(ends[1]))
709
+ var source: Node = ends[0]
710
+ var target: Node = ends[1]
533
711
 
712
+ # Reconnected rather than left alone when the flags differ, so the connection saved is the one
713
+ # asked for. Left alone, the answer gave the flags asked for and the file kept the old ones.
534
714
  var callable: Callable = Callable(target, method_name)
535
- if not source.is_connected(signal_name, callable):
536
- var connect_result: Error = source.connect(signal_name, callable, flags)
537
- if connect_result != OK:
538
- root.queue_free()
539
- return {"ok": false, "error": "Failed to connect signal: " + error_string(connect_result)}
540
-
541
- var err: Dictionary = _save_scene(root, scene_path)
542
- if not err.is_empty():
543
- return err
715
+ var method_found: bool = target.has_method(method_name)
716
+ if source.is_connected(signal_name, callable):
717
+ source.disconnect(signal_name, callable)
718
+ var connect_result: Error = source.connect(signal_name, callable, flags)
719
+ if connect_result != OK:
720
+ return scene.refuse("Failed to connect signal: " + error_string(connect_result))
721
+
722
+ var from: String = scene.path_of(source)
723
+ var to: String = scene.path_of(target)
724
+ var saved_flags: Array = [-1]
725
+ var written: Dictionary = scene.write(
726
+ func(saved: Node) -> String:
727
+ saved_flags[0] = _connection_flags(saved, from, signal_name, to, method_name)
728
+ if saved_flags[0] < 0:
729
+ return "the connection would not be in the scene when it loads."
730
+ return ""
731
+ )
732
+ if not written.is_empty():
733
+ return written
544
734
 
545
735
  return {
546
736
  "ok": true,
547
- "sourceNodePath": source_node_path,
737
+ "sourceNodePath": from,
548
738
  "signalName": signal_name,
549
- "targetNodePath": target_node_path,
739
+ "targetNodePath": to,
550
740
  "methodName": method_name,
551
- "flags": flags
741
+ "flags": saved_flags[0],
742
+ # Connected either way, as the editor's own dialog allows, since the method may be written
743
+ # next; a caller told nothing learnt of a misspelt one only when the signal fired.
744
+ "methodFound": method_found
552
745
  }
553
746
 
554
747
 
748
+ ## The two ends of a connection in [param scene], as [source, target], or [null, why not].
749
+ func _connection_ends(
750
+ scene: SceneFile, source_node_path: String, signal_name: String, target_node_path: String
751
+ ) -> Array:
752
+ var source: Node = _find_node(scene.root, source_node_path)
753
+ var target: Node = _find_node(scene.root, target_node_path)
754
+ if not source:
755
+ return [null, "Source node not found: " + source_node_path]
756
+ if not target:
757
+ return [null, "Target node not found: " + target_node_path]
758
+ # pack drops a connection with either end inside an instanced scene, and the answer said it was
759
+ # made or removed.
760
+ for end: Node in [source, target]:
761
+ var foreign: String = scene.foreign(end)
762
+ if not foreign.is_empty():
763
+ return [null, foreign]
764
+ if not source.has_signal(signal_name):
765
+ return [null, _missing_on(source, "%s has no signal %s" % [source_node_path, signal_name])]
766
+ return [source, target]
767
+
768
+
769
+ ## The flags of the persistent connection from [param from]'s [param signal_name] to
770
+ ## [param method] on [param to] in the tree under [param root], or -1 when there is none.
771
+ func _connection_flags(root: Node, from: String, signal_name: String, to: String, method: String) -> int:
772
+ var source: Node = root.get_node_or_null(from)
773
+ var target: Node = root.get_node_or_null(to)
774
+ if source == null or target == null or not source.has_signal(signal_name):
775
+ return -1
776
+ for conn: Dictionary in source.get_signal_connection_list(signal_name):
777
+ var flags: int = Read.as_int(conn.get("flags", 0))
778
+ var callable: Callable = conn.get("callable", Callable())
779
+ if (
780
+ flags & Object.CONNECT_PERSIST
781
+ and callable.get_object() == target
782
+ and str(callable.get_method()) == method
783
+ ):
784
+ return flags
785
+ return -1
786
+
787
+
555
788
  func disconnect_signal(args: Dictionary) -> Dictionary:
556
789
  var project_path: String = str(args.get("projectPath", ""))
557
790
  var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
@@ -568,34 +801,42 @@ func disconnect_signal(args: Dictionary) -> Dictionary:
568
801
  ):
569
802
  return {"ok": false, "error": "Missing required signal disconnection arguments"}
570
803
 
571
- var loaded: Array = _load_scene(scene_path)
572
- var refused: Dictionary = loaded[1]
804
+ var opened: Array = _open(scene_path, true)
805
+ var scene: SceneFile = opened[0]
806
+ var refused: Dictionary = opened[1]
573
807
  if not refused.is_empty():
574
808
  return refused
575
809
 
576
- var root: Node = loaded[0]
577
- var source: Node = _find_node(root, source_node_path)
578
- var target: Node = _find_node(root, target_node_path)
579
- if not source:
580
- root.queue_free()
581
- return {"ok": false, "error": "Source node not found: " + source_node_path}
582
- if not target:
583
- root.queue_free()
584
- return {"ok": false, "error": "Target node not found: " + target_node_path}
585
-
586
- var callable: Callable = Callable(target, method_name)
587
- if source.is_connected(signal_name, callable):
588
- source.disconnect(signal_name, callable)
589
-
590
- var err: Dictionary = _save_scene(root, scene_path)
591
- if not err.is_empty():
592
- return err
810
+ var ends: Array = _connection_ends(scene, source_node_path, signal_name, target_node_path)
811
+ if ends[0] == null:
812
+ return scene.refuse(str(ends[1]))
813
+ var source: Node = ends[0]
814
+ var target: Node = ends[1]
815
+
816
+ var from: String = scene.path_of(source)
817
+ var to: String = scene.path_of(target)
818
+ # A connection that was never there saved the scene unchanged and answered as removed, so a
819
+ # typo in either name read as success.
820
+ if _connection_flags(scene.root, from, signal_name, to, method_name) < 0:
821
+ return scene.refuse(
822
+ "%s has no connection from %s.%s to %s.%s." % [scene_path, from, signal_name, to, method_name]
823
+ )
824
+ source.disconnect(signal_name, Callable(target, method_name))
825
+
826
+ var written: Dictionary = scene.write(
827
+ func(saved: Node) -> String:
828
+ if _connection_flags(saved, from, signal_name, to, method_name) >= 0:
829
+ return "the connection would still be in the scene when it loads."
830
+ return ""
831
+ )
832
+ if not written.is_empty():
833
+ return written
593
834
 
594
835
  return {
595
836
  "ok": true,
596
- "sourceNodePath": source_node_path,
837
+ "sourceNodePath": from,
597
838
  "signalName": signal_name,
598
- "targetNodePath": target_node_path,
839
+ "targetNodePath": to,
599
840
  "methodName": method_name
600
841
  }
601
842
 
@@ -605,207 +846,60 @@ func list_connections(args: Dictionary) -> Dictionary:
605
846
  var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
606
847
  var filter_path: String = str(args.get("nodePath", ""))
607
848
 
608
- var loaded: Array = _load_scene(scene_path)
609
- var refused: Dictionary = loaded[1]
849
+ var opened: Array = _open(scene_path, false)
850
+ var scene: SceneFile = opened[0]
851
+ var refused: Dictionary = opened[1]
610
852
  if not refused.is_empty():
611
853
  return refused
612
854
 
613
- var root: Node = loaded[0]
855
+ var root: Node = scene.root
856
+ var filtered: Node = null
857
+ if not filter_path.is_empty():
858
+ filtered = _find_node(root, filter_path)
859
+ if filtered == null:
860
+ return scene.refuse("Node not found: " + filter_path)
614
861
  var nodes: Array = []
615
862
  _collect_nodes_recursive(root, ".", nodes)
616
863
 
864
+ # Only persistent connections, which are the ones a scene file holds: a Container connects
865
+ # signals of each child to itself as the child is added, and those were answered as if the file
866
+ # held them. Matched on either end, since the filter is for connections involving the node.
617
867
  var connections: Array = []
618
868
  for entry: Dictionary in nodes:
619
869
  var path: String = str(entry["path"])
620
- if not filter_path.is_empty() and filter_path != path:
621
- continue
622
870
  var node: Node = entry["node"]
623
871
  for signal_info: Dictionary in node.get_signal_list():
624
872
  var signal_name: String = str(signal_info.get("name", ""))
625
873
  if signal_name.is_empty():
626
874
  continue
627
875
  for conn: Dictionary in node.get_signal_connection_list(signal_name):
876
+ var flags: int = Read.as_int(conn.get("flags", 0))
877
+ if not flags & Object.CONNECT_PERSIST:
878
+ continue
628
879
  var callable: Callable = conn.get("callable", Callable())
629
880
  var target_obj: Object = callable.get_object()
881
+ if filtered != null and node != filtered and target_obj != filtered:
882
+ continue
630
883
  var target_path: String = ""
884
+ var editable: bool = scene.foreign(node).is_empty()
631
885
  if target_obj is Node:
632
886
  target_path = str(root.get_path_to(target_obj as Node))
633
- connections.append(
634
- {
635
- "sourceNodePath": path,
636
- "signalName": signal_name,
637
- "targetNodePath": target_path,
638
- "methodName": str(callable.get_method()),
639
- "flags": Read.as_int(conn.get("flags", 0))
640
- }
887
+ editable = editable and scene.foreign(target_obj as Node).is_empty()
888
+ (
889
+ connections
890
+ . append(
891
+ {
892
+ "sourceNodePath": path,
893
+ "signalName": signal_name,
894
+ "targetNodePath": target_path,
895
+ "methodName": str(callable.get_method()),
896
+ "flags": flags,
897
+ # False for a connection an instanced scene makes inside itself, which only that
898
+ # scene can change.
899
+ "editable": editable
900
+ }
901
+ )
641
902
  )
642
903
 
643
- root.queue_free()
644
- return {"ok": true, "connections": connections}
645
-
646
-
647
- ## Rescan the project filesystem, and report whether a scan is still running.
648
- ##
649
- ## The editor rescans when its window regains focus, so a script written by anything other
650
- ## than the editor stays invisible until someone clicks on Godot. Until then its
651
- ## `class_name` is missing from the global class list and the language server reports every
652
- ## use of it as an unknown type, which is godotengine/godot#42786.
653
- ##
654
- ## Returns as soon as the scan is queued rather than awaiting it, because the tool executor
655
- ## takes a Dictionary and not a coroutine. Pass `statusOnly` to poll without starting
656
- ## another scan.
657
- func rescan_filesystem(args: Dictionary) -> Dictionary:
658
- if not _editor_plugin:
659
- return {"ok": false, "error": "Editor plugin unavailable"}
660
-
661
- var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
662
- # Not over a scan or an import the editor is already running: a scan asked for then starts a
663
- # second reimport over the first, and the editor logs "Task 'reimport' already exists" and two
664
- # conditions from its progress dialog. Declined and said, so the caller waits and asks again.
665
- var busy: bool = filesystem.is_scanning() or filesystem.is_importing()
666
- var started: bool = false
667
- var completed_before: int = _scans_completed
668
- var uids: Dictionary = {"reread": [], "duplicated": []}
669
- if not Read.as_bool(args.get("statusOnly", false)) and not busy:
670
- uids = _reread_changed_uids(filesystem)
671
- filesystem.scan()
672
- # Whether the scan ran, read off the editor rather than assumed. `scan()` returns without
673
- # a word while the thread of the scan before is still to be joined, which is a frame or
674
- # more after that scan stops reporting itself, and that scan then signals as it is joined.
675
- # Taken as started, the answer came from a scan begun before the files were written:
676
- # downstream, a rescan after 698 files were rewritten answered finished in 1.5s and the
677
- # one changed file stayed on its old import. A threaded scan reports itself from inside
678
- # the call, and one run on this thread has finished and signalled before it returns.
679
- started = filesystem.is_scanning() or _scans_completed != completed_before
680
-
681
- # Importing is reported separately from scanning, and a class is not registered until
682
- # both are done, so a caller watching only one of them can look too early. The two counts are
683
- # what a caller waits on: the scan it asked for is over once the count has moved past the one
684
- # from before it was asked.
685
- return {
686
- "ok": true,
687
- "started": started,
688
- "scanning": filesystem.is_scanning(),
689
- "importing": filesystem.is_importing(),
690
- "scansCompletedBefore": completed_before,
691
- "scansCompleted": _scans_completed,
692
- "uidsReread": uids["reread"],
693
- "uidsDuplicatedOnDisk": uids["duplicated"],
694
- }
695
-
696
-
697
- ## Has the editor read again every file whose `.uid` names a UID the editor does not hold for it.
698
- ##
699
- ## The scan re-reads a file when the file changes and not when only the `.uid` beside it does, so a
700
- ## script copied with its `.uid` and then given a fresh one keeps the copied UID in the editor, and
701
- ## every headless engine reading the editor's cache warns of a duplicate. Measured on 4.7.2: a scan
702
- ## after the `.uid` was rewritten left both scripts on one UID in the cache. What the editor holds
703
- ## is read off its UID table, which maps each UID to one path, so a `.uid` whose UID is missing from
704
- ## the table or mapped to another file is one the editor has not taken in.
705
- ##
706
- ## Two files on disk naming one UID are left alone and named: reading them again cannot settle
707
- ## which of them owns it, and doing it on every scan would only move the UID between them.
708
- func _reread_changed_uids(filesystem: EditorFileSystem) -> Dictionary:
709
- var sidecars: Array[String] = []
710
- _uid_files("res://", sidecars)
711
- var claimed: Dictionary = {}
712
- for sidecar: String in sidecars:
713
- var id: int = ResourceUID.text_to_id(FileAccess.get_file_as_string(sidecar).strip_edges())
714
- var path: String = sidecar.trim_suffix(".uid")
715
- if id == ResourceUID.INVALID_ID or not FileAccess.file_exists(path):
716
- continue
717
- var paths: Array = claimed.get_or_add(id, [])
718
- paths.append(path)
719
- var reread: Array[String] = []
720
- var duplicated: Array[Array] = []
721
- for id: int in claimed:
722
- var paths: Array = claimed[id]
723
- if paths.size() > 1:
724
- paths.sort()
725
- duplicated.append(paths)
726
- continue
727
- var path: String = str(paths[0])
728
- if ResourceUID.has_id(id) and ResourceUID.get_id_path(id) == path:
729
- continue
730
- filesystem.update_file(path)
731
- reread.append(path)
732
- reread.sort()
733
- duplicated.sort()
734
- return {"reread": reread, "duplicated": duplicated}
735
-
736
-
737
- ## Every `.uid` under [param directory], skipping what the editor skips: hidden directories, any
738
- ## holding a `.gdignore`, and any below the project's own that holds a `project.godot` of its own,
739
- ## which the editor passes over as another project. A nested project's UIDs are never in the editor's
740
- ## table, so without that last one its files would be read again on every rescan, and one holding a
741
- ## copy of an outer file's `.uid` named as a duplicate on disk.
742
- func _uid_files(directory: String, found: Array[String]) -> void:
743
- var listing: DirAccess = DirAccess.open(directory)
744
- if listing == null or FileAccess.file_exists(directory.path_join(".gdignore")):
745
- return
746
- if directory != "res://" and FileAccess.file_exists(directory.path_join("project.godot")):
747
- return
748
- for child: String in listing.get_directories():
749
- if not child.begins_with("."):
750
- _uid_files(directory.path_join(child), found)
751
- for file: String in listing.get_files():
752
- if file.ends_with(".uid"):
753
- found.append(directory.path_join(file))
754
-
755
-
756
- ## Whether the editor is scanning, and how long ago its last scan finished, without asking for one.
757
- ##
758
- ## For a caller about to start a game: the editor rewrites the class cache a frame after a scan
759
- ## stops reporting, so a game booted during the scan or in that frame reads a file being rewritten.
760
- ## Apart from `rescan_filesystem` because asking must not start or count anything.
761
- func scan_status(_args: Dictionary) -> Dictionary:
762
- if not _editor_plugin:
763
- return {"ok": false, "error": "Editor plugin unavailable"}
764
- var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
765
- return {
766
- "ok": true,
767
- "scanning": filesystem.is_scanning(),
768
- "importing": filesystem.is_importing(),
769
- "sinceScanFinishedMs":
770
- -1 if _scan_finished_at_msec < 0 else Time.get_ticks_msec() - _scan_finished_at_msec,
771
- "reimportsCompleted": _reimports_completed,
772
- }
773
-
774
-
775
- ## Reimport the files named under `paths`, whatever their state, the way the editor's Reimport
776
- ## button does, and report whether the reimport was started.
777
- ##
778
- ## Started on the next frame rather than run here: the editor imports on its main thread, and a
779
- ## reimport of a few hundred scenes outlasts the wait on any one command. The caller waits for
780
- ## `reimportsCompleted` in [method scan_status] to move past `reimportsCompletedBefore`. Declined
781
- ## while the editor scans or imports, since `reimport_files` refuses to run inside an import.
782
- func reimport_files(args: Dictionary) -> Dictionary:
783
- if not _editor_plugin:
784
- return {"ok": false, "error": "Editor plugin unavailable"}
785
- var paths: Array[String] = []
786
- var given: Variant = args.get("paths", [])
787
- if given is Array:
788
- for path: Variant in given:
789
- paths.append(str(path))
790
- if paths.is_empty():
791
- return {"ok": false, "error": "reimport_files needs paths"}
792
- var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
793
- var busy: bool = filesystem.is_scanning() or filesystem.is_importing()
794
- if not busy:
795
- _reimport.call_deferred(PackedStringArray(paths))
796
- return {
797
- "ok": true,
798
- "started": not busy,
799
- "scanning": filesystem.is_scanning(),
800
- "importing": filesystem.is_importing(),
801
- "reimportsCompletedBefore": _reimports_completed,
802
- }
803
-
804
-
805
- func _reimport(paths: PackedStringArray) -> void:
806
- var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
807
- # A scan can begin between the answer and this frame, and its import would refuse this one.
808
- while filesystem.is_scanning() or filesystem.is_importing():
809
- await get_tree().process_frame
810
- filesystem.reimport_files(paths)
811
- _reimports_completed += 1
904
+ scene.close()
905
+ return {"ok": true, "connections": connections, "unsavedInEditor": SceneFile.unsaved(scene_path)}