gdharness 1.3.67 → 1.3.68

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.
@@ -573,15 +573,17 @@ func _handle_message(json_string: String) -> void:
573
573
 
574
574
  ## Says [param tool_name] was not run because the editor is scanning or importing, for the server to
575
575
  ## ask again once that is over.
576
- func send_busy(request_id: String, tool_name: String) -> void:
576
+ ## [param doing] is what the editor is busy with, and [param mark] changes as it gets on with it.
577
+ func send_busy(request_id: String, tool_name: String, doing: String, mark: String) -> void:
577
578
  _send_message(
578
579
  {
579
580
  "type": "tool_result",
580
581
  "id": request_id,
581
582
  "success": false,
582
583
  "busy": true,
583
- "error":
584
- "The editor is scanning or importing its files, so %s was not run." % tool_name,
584
+ "busyWith": doing,
585
+ "progress": mark,
586
+ "error": "The editor is %s, so %s was not run." % [doing, tool_name],
585
587
  }
586
588
  )
587
589
 
@@ -66,6 +66,16 @@ static func through_script(object: Object, names: Dictionary) -> bool:
66
66
  return false
67
67
 
68
68
 
69
+ ## Sets [param name] on [param object] back to [param value]. A metadata entry put back to nothing
70
+ ## is removed. Set to null through its property, Godot keeps the entry holding null, and the saver
71
+ ## writes a line that erases what the node's script sets each time the scene loads.
72
+ static func restore(object: Object, name: String, value: Variant) -> void:
73
+ if value == null and name.begins_with("metadata/"):
74
+ object.remove_meta(name.trim_prefix("metadata/"))
75
+ return
76
+ object.set(name, value)
77
+
78
+
69
79
  ## Whether [param object] still holds [param held] for [param name] once set to [param wanted]
70
80
  ## because the engine derives it, as it keeps layout_mode at Container under a container: an engine
71
81
  ## property, so no script code ran for the set, and an editor saves the same.
@@ -574,3 +584,45 @@ class GdhSaved:
574
584
  path = at
575
585
  before = held
576
586
  resave = again
587
+
588
+
589
+ ## Every script that failed to compile while it is attached, as {file, line, message} in order.
590
+ class GdhParseErrors:
591
+ extends Logger
592
+
593
+ var raised: Array[Dictionary] = []
594
+
595
+ func _log_message(_message: String, _error: bool) -> void:
596
+ pass
597
+
598
+ # The engine's own signature for a Logger, eight parameters and not this project's to shape.
599
+ # gdstyle:ignore=quality/max-parameters
600
+ func _log_error(
601
+ _function: String,
602
+ file: String,
603
+ line: int,
604
+ code: String,
605
+ _rationale: String,
606
+ _editor_notify: bool,
607
+ error_type: int,
608
+ _script_backtraces: Array[ScriptBacktrace],
609
+ ) -> void:
610
+ if error_type == Logger.ERROR_TYPE_SCRIPT and code.begins_with("Parse Error: "):
611
+ raised.append(
612
+ { "file": file, "line": line, "message": code.trim_prefix("Parse Error: ") }
613
+ )
614
+
615
+ ## Why a write is refused for what was raised, or "" when nothing was.
616
+ func refusal(scene_path: String) -> String:
617
+ if raised.is_empty():
618
+ return ""
619
+ var first: Dictionary = raised[0]
620
+ return (
621
+ (
622
+ "%s does not compile in a headless engine (line %d: %s), and it loads with %s, "
623
+ + "so the scene's scripts would not run as they do in the game. A class_name added "
624
+ + "since the class cache was built is taken in by project_import refresh_classes. "
625
+ + "Fix the script or refresh the classes, then ask again."
626
+ )
627
+ % [first["file"], first["line"], first["message"], scene_path]
628
+ )
@@ -7,6 +7,7 @@ extends EditorPlugin
7
7
  const GdhBridgeClient = preload("bridge_client.gd")
8
8
  const GdhToolExecutor = preload("tool_executor.gd")
9
9
  const GdhReleaseExport = preload("release_export.gd")
10
+ const GdhSceneFile = preload("scene_file.gd")
10
11
  ## The tools answered while the editor scans or imports, because none of them saves a file: the
11
12
  ## ones a caller waits on a scan with, and the ones that only read or stop a played game.
12
13
  const ANSWERED_WHILE_SCANNING: Array[String] = [
@@ -17,11 +18,29 @@ const ANSWERED_WHILE_SCANNING: Array[String] = [
17
18
  "stop_playing",
18
19
  "profile_read",
19
20
  ]
21
+ ## The tools answered inside a task of the editor's progress dialog, because none of them writes
22
+ ## anything the editor's task could be in the middle of.
23
+ const ANSWERED_DURING_A_TASK: Array[String] = [
24
+ "scan_status",
25
+ "playing_status",
26
+ "stop_playing",
27
+ "profile_read",
28
+ ]
29
+ ## What an editor opened to quit once a process has gone carries after `--`, with that pid after it.
30
+ ## Kept in step with `UNTIL_MARK` in src/launch.ts.
31
+ const UNTIL_MARK: String = "--gdharness-until="
32
+ ## Where the holder of such an editor asks it to save and quit, a file named for its pid. Kept in
33
+ ## step with `QUITS_FOLDER` in src/editor-open.ts.
34
+ const QUITS_FOLDER: String = "res://.godot/gdharness-editor-quits/"
35
+ ## How often such an editor looks for the request, in seconds.
36
+ const QUIT_LOOK_S: float = 1.0
20
37
 
21
38
  var _client: GdhBridgeClient
22
39
  var _tool_executor: GdhToolExecutor
23
40
  var _status_label: Label
24
41
  var _release_export: GdhReleaseExport
42
+ var _quit_look: Timer
43
+ var _loaded_at: int = 0
25
44
 
26
45
 
27
46
  func _enter_tree() -> void:
@@ -43,6 +62,7 @@ func _enter_tree() -> void:
43
62
 
44
63
  _setup_status_indicator()
45
64
  _client.connect_to_server()
65
+ _look_for_a_quit_request()
46
66
 
47
67
 
48
68
  func _exit_tree() -> void:
@@ -71,6 +91,41 @@ func _exit_tree() -> void:
71
91
  _status_label = null
72
92
 
73
93
 
94
+ ## Looks for its holder's request once a second, in an editor opened to quit once a process has
95
+ ## gone. Godot cannot watch that process itself, since `OS.is_process_running` answers false for any
96
+ ## pid it did not start, so a gdharness holder outside the editor watches it and writes the request.
97
+ func _look_for_a_quit_request() -> void:
98
+ var held: bool = false
99
+ for arg: String in OS.get_cmdline_user_args():
100
+ held = held or arg.begins_with(UNTIL_MARK)
101
+ if not held:
102
+ return
103
+ _loaded_at = int(Time.get_unix_time_from_system())
104
+ _quit_look = Timer.new()
105
+ _quit_look.wait_time = QUIT_LOOK_S
106
+ _quit_look.autostart = true
107
+ _listen(_quit_look.timeout, _on_quit_look)
108
+ add_child(_quit_look)
109
+
110
+
111
+ ## Saves and quits on a request written since this addon loaded, as quit_editor does. One older
112
+ ## than that was written for an earlier process with this pid. Not while the editor scans, imports
113
+ ## or runs a task, which every save waits out.
114
+ func _on_quit_look() -> void:
115
+ var request: String = ProjectSettings.globalize_path(QUITS_FOLDER + str(OS.get_process_id()))
116
+ if not FileAccess.file_exists(request) or FileAccess.get_modified_time(request) < _loaded_at:
117
+ return
118
+ if turned_away("quit_editor"):
119
+ return
120
+ _quit_look.stop()
121
+ var answer: Dictionary = await _tool_executor.execute_tool("quit_editor", {})
122
+ if answer.get("ok", false) != true:
123
+ push_warning(
124
+ "gdharness: the process holding this editor has gone, and it stays open: %s"
125
+ % str(answer.get("error", ""))
126
+ )
127
+
128
+
74
129
  # A failed connect is a mistake in this plugin rather than anything the editor did, so it goes to
75
130
  # the error stream instead of being dropped: a project holding return_value_discarded at error level
76
131
  # refuses to compile a script that throws the answer away.
@@ -107,16 +162,38 @@ func _on_disconnected() -> void:
107
162
  ## scan updates the file list the scan's thread is writing out (see SceneFile.scan_later), and an
108
163
  ## import can run frames of the editor from inside itself, the one handling this call among them.
109
164
  ## Every call waits out a capture, which shows a scene for frames before it reads the view.
165
+ ##
166
+ ## A task in the editor's progress dialog runs frames of the editor from inside itself at every
167
+ ## step, so a call handled then runs in the middle of that task. A save there asked for the script
168
+ ## classes again while the editor was registering them, and the two registrations walked one list,
169
+ ## which crashed the editor at its next scan.
110
170
  func turned_away(tool_name: String) -> bool:
111
171
  if _tool_executor != null and _tool_executor.capturing():
112
172
  return true
173
+ if not ANSWERED_DURING_A_TASK.has(tool_name) and GdhSceneFile.in_a_task():
174
+ return true
113
175
  var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
114
176
  return (
115
177
  not ANSWERED_WHILE_SCANNING.has(tool_name)
116
- and (filesystem.is_scanning() or filesystem.is_importing())
178
+ and (GdhSceneFile.scan_unfinished(filesystem) or filesystem.is_importing())
117
179
  )
118
180
 
119
181
 
182
+ ## What the editor is doing that turns calls away, and a mark that changes as it gets on with it,
183
+ ## as [what, mark]. The server keeps asking while the mark changes: registering forty classes in a
184
+ ## hidden editor took over thirty seconds, every one of them a step.
185
+ func busy_with() -> Array[String]:
186
+ if _tool_executor != null and _tool_executor.capturing():
187
+ return ["capturing its view", ""]
188
+ var labels: Array[String] = GdhSceneFile.task_labels()
189
+ if not labels.is_empty():
190
+ return ["running its task \"%s\"" % labels[0].trim_suffix("..."), "\n".join(labels)]
191
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
192
+ if filesystem.is_importing():
193
+ return ["importing its files", ""]
194
+ return ["scanning its files", str(filesystem.get_scanning_progress())]
195
+
196
+
120
197
  func _on_tool_requested(request_id: String, tool_name: String, args: Dictionary) -> void:
121
198
  if _tool_executor == null or _client == null:
122
199
  return
@@ -124,7 +201,8 @@ func _on_tool_requested(request_id: String, tool_name: String, args: Dictionary)
124
201
  # Turned away rather than held, so the server asks again: a call held here could run after the
125
202
  # server had given up on it.
126
203
  if turned_away(tool_name):
127
- _client.send_busy(request_id, tool_name)
204
+ var busy: Array[String] = busy_with()
205
+ _client.send_busy(request_id, tool_name, busy[0], busy[1])
128
206
  return
129
207
 
130
208
  var result: Dictionary = await _tool_executor.execute_tool(tool_name, args)
@@ -30,6 +30,7 @@ const PUTTING_BACK_PASSES: int = 4
30
30
  const GdhRead = preload("reading.gd")
31
31
  const GdhSame = preload("same_values.gd")
32
32
  const GdhSerialisation = preload("serialisation.gd")
33
+ const GdhSceneFile = preload("scene_file.gd")
33
34
  const GdhSceneUids = preload("scene_uids.gd")
34
35
 
35
36
  ## Whether a scan asked for through [method scan_later] is still to start, which a caller waiting
@@ -41,6 +42,10 @@ static var also_saved: Array[Dictionary] = []
41
42
  ## What a write said of the connections it kept in the file though the engine cannot make them,
42
43
  ## gathered for that call's answer as [member also_saved] is.
43
44
  static var unmade_note: String = ""
45
+ ## The editor's progress dialog, which Godot keeps for the editor's whole life and exposes no other
46
+ ## way. Looked for once, and again only if it has gone.
47
+ static var _progress: Node = null
48
+ static var _progress_sought: bool = false
44
49
 
45
50
  var root: Node = null
46
51
  var path: String = ""
@@ -102,12 +107,21 @@ func open(scene_path: String, for_writing: bool) -> Dictionary:
102
107
  var missing: String = missing_said(path, FileAccess.get_file_as_string(path))
103
108
  if not missing.is_empty():
104
109
  return { "ok": false, "error": missing }
110
+ # A script the scene's scripts load that fails to compile names no node, so nothing else sees
111
+ # it.
112
+ var failed: GdhHeadlessCheck.GdhParseErrors = GdhHeadlessCheck.GdhParseErrors.new()
113
+ var watching: bool = for_writing and not Engine.is_editor_hint()
114
+ if watching:
115
+ OS.add_logger(failed)
105
116
  var packed: PackedScene = (
106
117
  ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_IGNORE) as PackedScene
107
118
  )
119
+ if packed != null:
120
+ root = packed.instantiate(PackedScene.GEN_EDIT_STATE_MAIN)
121
+ if watching:
122
+ OS.remove_logger(failed)
108
123
  if packed == null:
109
124
  return { "ok": false, "error": "Failed to load %s as a scene" % path }
110
- root = packed.instantiate(PackedScene.GEN_EDIT_STATE_MAIN)
111
125
  if root == null:
112
126
  return { "ok": false, "error": "Failed to instantiate " + path }
113
127
  _state = packed.get_state()
@@ -124,6 +138,8 @@ func open(scene_path: String, for_writing: bool) -> Dictionary:
124
138
  if for_writing:
125
139
  _text = FileAccess.get_file_as_string(path)
126
140
  var unsettled: String = settle()
141
+ if unsettled.is_empty() and for_writing:
142
+ unsettled = failed.refusal(path)
127
143
  if not unsettled.is_empty():
128
144
  return refuse(("Nothing was written: " if for_writing else "Not read: ") + unsettled)
129
145
  return {}
@@ -416,6 +432,64 @@ func copied(copies: Dictionary) -> void:
416
432
  _copied_from[copy.get_instance_id()] = original.get_instance_id()
417
433
 
418
434
 
435
+ ## A scene to write at [param branch_path] whose root is [param top], a node of this one about to be
436
+ ## taken out of it, keeping what this file stores on each node of [param top] and what the tool
437
+ ## asked of them. A connection the engine could not make goes with it when both its ends are in
438
+ ## [param top]. One with a single end there is taken out of this scene's and answered, that end
439
+ ## given as its path from [param top] under sourceBelow or targetBelow, for [method rejoin_unmade].
440
+ ## Answers [scene, those connections].
441
+ func split_off(top: Node, branch_path: String) -> Array:
442
+ var branch: GdhSceneFile = GdhSceneFile.new()
443
+ branch.root = top
444
+ branch.path = branch_path
445
+ branch._text = _text
446
+ branch._reached = _reached.duplicate()
447
+ var nodes: Array[Node] = [top]
448
+ nodes.append_array(top.find_children("*", "", true, false))
449
+ for node: Node in nodes:
450
+ var id: int = node.get_instance_id()
451
+ if _filed.has(id):
452
+ branch._filed[id] = _filed[id]
453
+ if _asked.has(id):
454
+ branch._asked[id] = _asked[id]
455
+ if _before.has(id):
456
+ branch._before[id] = _before[id]
457
+ var crossing: Array[Dictionary] = []
458
+ var still: Array[Dictionary] = []
459
+ for held: Dictionary in _unmade:
460
+ var source_in: bool = _within(held["source"], top)
461
+ var target_in: bool = _within(held["target"], top)
462
+ if source_in and target_in:
463
+ branch._unmade.append(held)
464
+ continue
465
+ if not source_in and not target_in:
466
+ still.append(held)
467
+ continue
468
+ var end: String = "source" if source_in else "target"
469
+ var below: Node = held[end]
470
+ held[end + "Below"] = str(top.get_path_to(below))
471
+ crossing.append(held)
472
+ _unmade = still
473
+ return [branch, crossing]
474
+
475
+
476
+ ## Puts back each of [param crossing], from [method split_off], among the connections a write keeps,
477
+ ## its end below the branch now on the node at that path below [param instance].
478
+ func rejoin_unmade(crossing: Array[Dictionary], instance: Node) -> void:
479
+ for held: Dictionary in crossing:
480
+ for end: String in ["source", "target"]:
481
+ if held.has(end + "Below"):
482
+ held[end] = instance.get_node_or_null(NodePath(str(held[end + "Below"])))
483
+ _unmade.append(held)
484
+
485
+
486
+ static func _within(held: Variant, top: Node) -> bool:
487
+ if not is_instance_valid(held) or not held is Node:
488
+ return false
489
+ var node: Node = held
490
+ return node == top or top.is_ancestor_of(node)
491
+
492
+
419
493
  ## Why [param scene_path] cannot be written while the editor has it open, or "".
420
494
  static func unsaved_in_editor(scene_path: String) -> String:
421
495
  if not Engine.is_editor_hint() or not EditorInterface.get_unsaved_scenes().has(scene_path):
@@ -1075,23 +1149,63 @@ static func scan_later() -> void:
1075
1149
  _start_scan.call_deferred()
1076
1150
 
1077
1151
 
1078
- ## [method EditorFileSystem.scan] does nothing, and says nothing, while the thread of the scan
1079
- ## before it is still to be joined, which is a frame or two after that scan stops reporting itself,
1080
- ## so a scan that leaves the editor not scanning is asked for once more once that is over. Asked of
1081
- ## a scan that ended within the call, the second costs one more scan.
1152
+ ## It waits out a task of the progress dialog too, whose every step runs a frame of the editor from
1153
+ ## inside itself. A scan's end registers the classes it found in such a task (4.7.2's
1154
+ ## [code]EditorFileSystem::_notification[/code]). A scan started from that frame ran beside the end
1155
+ ## of the one before, which crashed the editor or left it scanning.
1082
1156
  static func _start_scan() -> void:
1083
1157
  var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
1084
1158
  var tree: SceneTree = Engine.get_main_loop()
1085
- while filesystem.is_scanning() or filesystem.is_importing():
1159
+ while scan_unfinished(filesystem) or filesystem.is_importing() or in_a_task():
1086
1160
  await tree.process_frame
1087
1161
  filesystem.scan()
1088
- if not filesystem.is_scanning():
1089
- await tree.process_frame
1090
- await tree.process_frame
1091
- filesystem.scan()
1092
1162
  scan_due = false
1093
1163
 
1094
1164
 
1165
+ ## Whether the editor scans its files, counting the frames after a scan stops reporting itself and
1166
+ ## before the editor handles its end. That end swaps in the new file list and registers the classes
1167
+ ## the scan found, from a frame of the editor. A save in those frames registered the classes as
1168
+ ## well,
1169
+ ## inside its own task, and the editor crashed or logged "Task 'update_scripts_classes' already
1170
+ ## exists". [method EditorFileSystem.scan] also does nothing then, without a word. The editor
1171
+ ## processes from the start of a scan until it has handled the end (4.7.2's
1172
+ ## [code]EditorFileSystem::scan[/code] and [code]_notification[/code]).
1173
+ static func scan_unfinished(filesystem: EditorFileSystem) -> bool:
1174
+ return filesystem.is_scanning() or filesystem.is_processing()
1175
+
1176
+
1177
+ ## Whether the editor's progress dialog is showing, which it does for as long as a task is open.
1178
+ static func in_a_task() -> bool:
1179
+ if _progress != null and not is_instance_valid(_progress):
1180
+ _progress = null
1181
+ _progress_sought = false
1182
+ if not _progress_sought:
1183
+ _progress_sought = true
1184
+ _progress = _found_progress((Engine.get_main_loop() as SceneTree).root)
1185
+ return _progress is CanvasItem and (_progress as CanvasItem).visible
1186
+
1187
+
1188
+ ## The labels of the progress dialog's open tasks, each task's title then the step it is on, which
1189
+ ## change as the task goes on. Empty when no task is open.
1190
+ static func task_labels() -> Array[String]:
1191
+ var labels: Array[String] = []
1192
+ if not in_a_task():
1193
+ return labels
1194
+ for label: Node in _progress.find_children("*", "Label", true, false):
1195
+ labels.append(str(label.get("text")))
1196
+ return labels
1197
+
1198
+
1199
+ static func _found_progress(top: Node) -> Node:
1200
+ var pending: Array[Node] = [top]
1201
+ while not pending.is_empty():
1202
+ var node: Node = pending.pop_back()
1203
+ if node.is_class("ProgressDialog"):
1204
+ return node
1205
+ pending.append_array(node.get_children(true))
1206
+ return null
1207
+
1208
+
1095
1209
  ## Whether the editor holds unsaved changes to [param scene_path], for a read to say that the file
1096
1210
  ## it answers from is not what the editor shows.
1097
1211
  static func unsaved(scene_path: String) -> bool:
@@ -1123,7 +1237,7 @@ func _settled(packed: PackedScene) -> Array:
1123
1237
  var name: String = found[1]
1124
1238
  var wanted: Variant = found[3] if found[2] else _unset_value(node, name)
1125
1239
  var held: Variant = node.get(name)
1126
- node.set(name, wanted)
1240
+ GdhHeadlessCheck.restore(node, name, wanted)
1127
1241
  if GdhHeadlessCheck.kept_by_engine(node, name, wanted, held):
1128
1242
  _engine_kept["%d:%s" % [node.get_instance_id(), name]] = true
1129
1243
  for found: Array in moved:
@@ -1131,7 +1245,9 @@ func _settled(packed: PackedScene) -> Array:
1131
1245
  var before: Dictionary = _before[node.get_instance_id()]
1132
1246
  var restored: Dictionary = restored_by_write.get_or_add(node.get_instance_id(), {})
1133
1247
  for name: String in found[1]:
1134
- node.set(name, before.get(name, GdhHeadlessCheck.default_of(node, name)))
1248
+ GdhHeadlessCheck.restore(
1249
+ node, name, before.get(name, GdhHeadlessCheck.default_of(node, name))
1250
+ )
1135
1251
  restored[name] = true
1136
1252
  current = PackedScene.new()
1137
1253
  var packing: Error = current.pack(root)
@@ -118,6 +118,7 @@ func _init_tools() -> void:
118
118
  "delete_node": [_scene_tools, "delete_node"],
119
119
  "duplicate_node": [_scene_tools, "duplicate_node"],
120
120
  "reparent_node": [_scene_tools, "reparent_node"],
121
+ "save_branch": [_scene_tools, "save_branch"],
121
122
  "set_node_properties": [_scene_tools, "set_node_properties"],
122
123
  "get_node_properties": [_scene_tools, "get_node_properties"],
123
124
  "bake_navigation": [_scene_tools, "bake_navigation"],
@@ -21,6 +21,8 @@ extends Node
21
21
  ## A file the scan skipped is either missing from the tree or recorded with no class name against
22
22
  ## it, and both come back as a class the editor does not have, which is the whole of the question.
23
23
 
24
+ const GdhSceneFile = preload("../scene_file.gd")
25
+
24
26
  var _editor_plugin: EditorPlugin = null
25
27
 
26
28
 
@@ -282,7 +284,7 @@ func global_classes(_args: Dictionary) -> Dictionary:
282
284
  return {
283
285
  "ok": true,
284
286
  "classes": names,
285
- "scanning": filesystem.is_scanning(),
287
+ "scanning": GdhSceneFile.scan_unfinished(filesystem),
286
288
  "importing": filesystem.is_importing(),
287
289
  }
288
290
 
@@ -55,7 +55,7 @@ func rescan_filesystem(args: Dictionary) -> Dictionary:
55
55
  # Not over a scan or an import the editor is already running: a scan asked for then starts a
56
56
  # second reimport over the first, and the editor logs "Task 'reimport' already exists" and two
57
57
  # conditions from its progress dialog. Declined and said, so the caller waits and asks again.
58
- var busy: bool = filesystem.is_scanning() or filesystem.is_importing()
58
+ var busy: bool = GdhSceneFile.scan_unfinished(filesystem) or filesystem.is_importing()
59
59
  var started: bool = false
60
60
  var completed_before: int = _scans_completed
61
61
  var uids: Dictionary = { "reread": [], "duplicated": [] }
@@ -71,13 +71,9 @@ func rescan_filesystem(args: Dictionary) -> Dictionary:
71
71
  for path: Variant in paths:
72
72
  filesystem.update_file(str(path))
73
73
  filesystem.scan()
74
- # Whether the scan ran, read off the editor rather than assumed. `scan()` returns without
75
- # a word while the thread of the scan before is still to be joined, which is a frame or
76
- # more after that scan stops reporting itself, and that scan then signals as it is joined.
77
- # Taken as started, the answer came from a scan begun before the files were written:
78
- # downstream, a rescan after 698 files were rewritten answered finished in 1.5s and the
79
- # one changed file stayed on its old import. A threaded scan reports itself from inside
80
- # the call, and one run on this thread has finished and signalled before it returns.
74
+ # Whether the scan ran, read off the editor rather than assumed. A threaded scan reports
75
+ # itself from inside the call, and one run on this thread has finished and signalled before
76
+ # it returns.
81
77
  started = filesystem.is_scanning() or _scans_completed != completed_before
82
78
 
83
79
  # Importing is reported separately from scanning, and a class is not registered until
@@ -87,7 +83,7 @@ func rescan_filesystem(args: Dictionary) -> Dictionary:
87
83
  return {
88
84
  "ok": true,
89
85
  "started": started,
90
- "scanning": filesystem.is_scanning(),
86
+ "scanning": GdhSceneFile.scan_unfinished(filesystem),
91
87
  "importing": filesystem.is_importing(),
92
88
  "scansCompletedBefore": completed_before,
93
89
  "scansCompleted": _scans_completed,
@@ -179,7 +175,7 @@ func scan_status(_args: Dictionary) -> Dictionary:
179
175
  "openScenes": open,
180
176
  "editedScene": "" if shown == null else shown.scene_file_path,
181
177
  "unsavedScenes": unsaved,
182
- "scanning": filesystem.is_scanning(),
178
+ "scanning": GdhSceneFile.scan_unfinished(filesystem),
183
179
  "importing": filesystem.is_importing(),
184
180
  "pending": GdhSceneFile.scan_due,
185
181
  "sinceScanFinishedMs":
@@ -210,13 +206,13 @@ func reimport_files(args: Dictionary) -> Dictionary:
210
206
  if paths.is_empty():
211
207
  return { "ok": false, "error": "reimport_files needs paths" }
212
208
  var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
213
- var busy: bool = filesystem.is_scanning() or filesystem.is_importing()
209
+ var busy: bool = GdhSceneFile.scan_unfinished(filesystem) or filesystem.is_importing()
214
210
  if not busy:
215
211
  _reimport.call_deferred(PackedStringArray(paths))
216
212
  return {
217
213
  "ok": true,
218
214
  "started": not busy,
219
- "scanning": filesystem.is_scanning(),
215
+ "scanning": GdhSceneFile.scan_unfinished(filesystem),
220
216
  "importing": filesystem.is_importing(),
221
217
  "reimportsCompletedBefore": _reimports_completed,
222
218
  }
@@ -290,7 +286,7 @@ func files_moved(args: Dictionary) -> Dictionary:
290
286
  func _reimport(paths: PackedStringArray) -> void:
291
287
  var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
292
288
  # A scan can begin between the answer and this frame, and its import would refuse this one.
293
- while filesystem.is_scanning() or filesystem.is_importing():
289
+ while GdhSceneFile.scan_unfinished(filesystem) or filesystem.is_importing():
294
290
  await get_tree().process_frame
295
291
  filesystem.reimport_files(paths)
296
292
  _reimports_completed += 1
@@ -180,19 +180,7 @@ static func follow(
180
180
  ) -> Dictionary:
181
181
  var texts: Dictionary = {}
182
182
  _read_scenes("res://", texts)
183
- var kin: Array[String] = [changed]
184
- var grew: bool = true
185
- while grew:
186
- grew = false
187
- for scene_path: String in texts:
188
- if kin.has(scene_path):
189
- continue
190
- var text: String = texts[scene_path]
191
- for one: String in kin:
192
- if text.contains(' path="%s"' % one):
193
- kin.append(scene_path)
194
- grew = true
195
- break
183
+ var kin: Array[String] = _kin_of(changed, texts)
196
184
  var followed: Array[String] = []
197
185
  var not_followed: Array[Dictionary] = []
198
186
  for scene_path: String in kin:
@@ -247,6 +235,92 @@ static func follow(
247
235
  return answer
248
236
 
249
237
 
238
+ ## Marks the instance a branch at [param at] in [param changed] became editable in every other
239
+ ## .tscn that instances or inherits [param changed] at any depth, wherever that scene keeps values
240
+ ## on a node inside it or adds one below it. Those still load, and the next save of that scene
241
+ ## dropped them, since the packer skips every node under an instance whose children are not
242
+ ## editable. Answers followed, the scenes marked, and notFollowed, each left with why, each only
243
+ ## when it names any.
244
+ static func enclose(changed: String, at: String) -> Dictionary:
245
+ var texts: Dictionary = {}
246
+ _read_scenes("res://", texts)
247
+ var followed: Array[String] = []
248
+ var not_followed: Array[Dictionary] = []
249
+ for scene_path: String in _kin_of(changed, texts):
250
+ if scene_path == changed:
251
+ continue
252
+ var moves: Dictionary = {}
253
+ if _inherits(scene_path, changed, texts):
254
+ moves[at] = at
255
+ var read: Dictionary = GdhFileReads.scene_tree(scene_path, -1, false, false)
256
+ if not read.get("ok", false):
257
+ not_followed.append({ "scenePath": scene_path, "reason": str(read.get("error", "")) })
258
+ continue
259
+ var tree: Dictionary = read["tree"]
260
+ _instances_of(tree, changed, at, at, moves)
261
+ var text: String = texts[scene_path]
262
+ var marks: PackedStringArray = []
263
+ for path: String in moves:
264
+ var mark: String = '[editable path="%s"]' % path
265
+ if not text.contains(mark) and _holds_below(text, path):
266
+ var _marked: bool = marks.append(mark)
267
+ if marks.is_empty():
268
+ continue
269
+ var unsaved: String = GdhSceneFile.unsaved_in_editor(scene_path)
270
+ if not unsaved.is_empty():
271
+ not_followed.append({ "scenePath": scene_path, "reason": unsaved })
272
+ continue
273
+ var file: FileAccess = FileAccess.open(scene_path, FileAccess.WRITE)
274
+ if (file == null
275
+ or not file.store_string(text.rstrip("\n") + "\n\n" + "\n".join(marks) + "\n")):
276
+ not_followed.append(
277
+ { "scenePath": scene_path, "reason": error_string(FileAccess.get_open_error()) }
278
+ )
279
+ continue
280
+ file.close()
281
+ GdhSceneFile.refresh(scene_path)
282
+ followed.append(scene_path)
283
+ var answer: Dictionary = {}
284
+ if not followed.is_empty():
285
+ answer["followed"] = followed
286
+ if not not_followed.is_empty():
287
+ answer["notFollowed"] = not_followed
288
+ return answer
289
+
290
+
291
+ ## Whether [param text], a scene's file, has a node entry below [param path], one setting values on
292
+ ## a node there or adding one.
293
+ static func _holds_below(text: String, path: String) -> bool:
294
+ for line: String in text.split("\n"):
295
+ if not line.begins_with("[node "):
296
+ continue
297
+ var parent: String = _attribute(line, "parent")
298
+ if parent.is_empty():
299
+ continue
300
+ var full: String = _under(parent, _attribute(line, "name"))
301
+ if full.begins_with(path + "/"):
302
+ return true
303
+ return false
304
+
305
+
306
+ ## [param changed] and every scene in [param texts] that instances or inherits it at any depth.
307
+ static func _kin_of(changed: String, texts: Dictionary) -> Array[String]:
308
+ var kin: Array[String] = [changed]
309
+ var grew: bool = true
310
+ while grew:
311
+ grew = false
312
+ for scene_path: String in texts:
313
+ if kin.has(scene_path):
314
+ continue
315
+ var text: String = texts[scene_path]
316
+ for one: String in kin:
317
+ if text.contains(' path="%s"' % one):
318
+ kin.append(scene_path)
319
+ grew = true
320
+ break
321
+ return kin
322
+
323
+
250
324
  ## The text of every .tscn under [param directory] into [param texts], keyed by res:// path, leaving
251
325
  ## out the engine's own folder and hidden ones.
252
326
  static func _read_scenes(directory: String, texts: Dictionary) -> void: