754 lines
31 KiB
GDScript
754 lines
31 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
|
|
const Errors := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|
const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd")
|
|
const PLUGIN_TREE := "res://addons/godot_ai"
|
|
const SIDECARS := [".uid", ".import"]
|
|
const OWNER_EXTENSIONS := ["gd", "cs", "gdshader", "gdshaderinc", "tscn", "tres", "scn", "res"]
|
|
const MAX_ENTRIES := 10000
|
|
const MAX_FILE_BYTES := 256 * 1024
|
|
const MAX_TOTAL_BYTES := 64 * 1024 * 1024
|
|
const MAX_TARGETS := 256
|
|
## Container magic Godot writes for a compressed binary resource.
|
|
const COMPRESSED_MAGIC := "RSCC"
|
|
## Work allowed between frame yields: a share of how long the editor last took
|
|
## to come back, within these bounds. A fixed slice starves discovery once the
|
|
## editor is unfocused, because its low-processor mode then sleeps ~100 ms per
|
|
## frame (`interface/editor/timers/unfocused_low_processor_mode_sleep_usec`)
|
|
## and the frame count, not the work, reaches the deadline (#1120). The floor is
|
|
## what a focused editor gets; the ceiling bounds one slice on a stalled editor.
|
|
const BUDGET_MIN_USEC := 2000
|
|
const BUDGET_MAX_USEC := 50000
|
|
const BUDGET_GAP_DIVISOR := 3
|
|
const DEADLINE_MSEC := 25000
|
|
static var _busy := false
|
|
|
|
var _alive: Callable
|
|
var _deadline := 0
|
|
var _yield_at := 0
|
|
var _budget := BUDGET_MIN_USEC
|
|
var _bytes := 0
|
|
var _directories: Dictionary = {}
|
|
var _fingerprints: Dictionary = {}
|
|
var _large_owners: Dictionary = {}
|
|
var _fault := ""
|
|
var _fault_code := ""
|
|
var _io: Callable
|
|
|
|
## Dispatch stays synchronous; the retained job owns work across frame yields.
|
|
static func start(connection: McpConnection, params: Dictionary, operation: String) -> Dictionary:
|
|
var request_id := str(params.get("_request_id", ""))
|
|
if connection == null or request_id.is_empty():
|
|
return _unchanged(
|
|
"Filesystem mutations require a direct deferred command; call filesystem_manage outside batch_execute")
|
|
_finish(connection, request_id, params.duplicate(true), operation)
|
|
return {"_deferred": true, "_deferred_timeout_ms": 30000}
|
|
|
|
|
|
static func _finish(connection: McpConnection, request_id: String, params: Dictionary, operation: String) -> void:
|
|
var work := ScriptWork.begin("filesystem_" + operation)
|
|
var tree := connection.get_tree()
|
|
if tree == null:
|
|
ScriptWork.finish(work)
|
|
return
|
|
# Register the deferred response before the job can test its ownership.
|
|
await tree.process_frame
|
|
var job = load("res://addons/godot_ai/handlers/filesystem_mutation.gd").new()
|
|
var active := func() -> bool:
|
|
return is_instance_valid(connection) and connection.dispatcher != null and connection.dispatcher.has_pending_deferred_response(request_id)
|
|
var result: Dictionary = await job.run(params, operation, active)
|
|
if active.call():
|
|
connection.send_deferred_response(request_id, result)
|
|
ScriptWork.finish(work)
|
|
|
|
|
|
## Discovery is cancellable and read-only. Once disk effects start, this owner
|
|
## finishes or reconciles them before releasing the mutation slot.
|
|
func run(params: Dictionary, operation: String, active: Callable = Callable()) -> Dictionary:
|
|
if _busy:
|
|
return _unchanged("Another filesystem mutation is still running")
|
|
_busy = true
|
|
_alive = active
|
|
_deadline = Time.get_ticks_msec() + DEADLINE_MSEC
|
|
_yield_at = Time.get_ticks_usec()
|
|
_budget = BUDGET_MIN_USEC
|
|
_bytes = 0
|
|
_directories.clear()
|
|
_fingerprints.clear()
|
|
_large_owners.clear()
|
|
_fault = ""
|
|
_fault_code = ""
|
|
var result := await _run(params, operation)
|
|
_busy = false
|
|
return result
|
|
|
|
|
|
func _run(params: Dictionary, operation: String) -> Dictionary:
|
|
var source := str(params.get("path", "")).trim_suffix("/")
|
|
var error = McpPathValidator.path_error(source, "path", true)
|
|
if error != null:
|
|
return error
|
|
source = source.simplify_path()
|
|
if source == "res:/" or source == "res://" or _reserved(source):
|
|
return _unchanged("Cannot mutate the project root, metadata, or loaded plugin tree")
|
|
if _is_sidecar(source):
|
|
return _unchanged("Mutate the resource, not its .uid or .import sidecar")
|
|
var directory := DirAccess.dir_exists_absolute(source)
|
|
if not directory and not FileAccess.file_exists(source):
|
|
return Errors.make(Errors.RESOURCE_NOT_FOUND, "File or folder not found: %s" % source)
|
|
var destination := str(params.get("new_path", "")).trim_suffix("/")
|
|
if operation == "rename":
|
|
var name := str(params.get("new_name", ""))
|
|
if name.is_empty() or "/" in name or "\\" in name or name in [".", ".."]:
|
|
return _unchanged("new_name must be a nonempty bare name without path separators")
|
|
destination = source.get_base_dir().path_join(name)
|
|
if operation != "remove":
|
|
error = McpPathValidator.path_error(destination, "new_path", true)
|
|
if error != null:
|
|
return error
|
|
destination = destination.simplify_path()
|
|
if _reserved(destination):
|
|
return _unchanged("Cannot move into project metadata or the loaded plugin tree")
|
|
if source == destination or (directory and (destination + "/").to_lower().begins_with(source.to_lower() + "/")):
|
|
return _unchanged("Cannot move a path onto itself or into its own subtree")
|
|
if not DirAccess.dir_exists_absolute(destination.get_base_dir()):
|
|
return _unchanged("Destination parent must already exist: %s" % destination.get_base_dir())
|
|
if directory and operation == "remove" and bool(params.get("permanent", false)):
|
|
return _unchanged("Permanent directory removal is unsupported; use the default OS trash")
|
|
if not _confined_without_links(source) or (operation != "remove" and not _confined_without_links(destination)):
|
|
return _unchanged(_fault)
|
|
var files: Array[String] = []
|
|
if not await _collect_tree("res://", files):
|
|
return _fault_result()
|
|
if directory and not _directories.has(source):
|
|
return _unchanged("Source directory was excluded or has noncanonical casing; nothing changed")
|
|
var targets := {}
|
|
for path in files:
|
|
if not _is_sidecar(path) and (path == source or (directory and path.begins_with(source + "/"))):
|
|
targets[path] = {"uid": ResourceLoader.get_resource_uid(path)}
|
|
if not directory and not targets.has(source):
|
|
return _unchanged("Source was excluded from discovery; nothing changed")
|
|
if targets.size() > MAX_TARGETS:
|
|
return _unchanged("Mutation exceeds the %d-resource limit; use smaller groups" % MAX_TARGETS)
|
|
for path in files:
|
|
if not _mutation_member(path, source, directory):
|
|
continue
|
|
if path.get_extension().to_lower() in OWNER_EXTENSIONS:
|
|
# Owner-extension targets are read and fingerprinted by the owner
|
|
# pass below; reading them here too would charge the byte budget twice.
|
|
continue
|
|
if await _read_bounded(path) == null:
|
|
return _fault_result()
|
|
var owners := []
|
|
for path in files:
|
|
var extension := path.get_extension().to_lower()
|
|
if extension not in OWNER_EXTENSIONS:
|
|
continue
|
|
var hits: Array[Dictionary]
|
|
var length := _file_length(path)
|
|
if length < 0:
|
|
return _fault_result()
|
|
if length > MAX_FILE_BYTES:
|
|
# Dependency enumeration can replace a literal scan for packed binary
|
|
# owners, but not for scripts or text resources: those can refer to a
|
|
# target in ordinary strings the graph does not report. Fail closed.
|
|
if extension not in ["res", "scn"]:
|
|
_set_fault("Cannot safely read owner %s (exceeds %d bytes)" % [path, MAX_FILE_BYTES], Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return _fault_result()
|
|
hits = await _large_owner_references(path, targets)
|
|
else:
|
|
var content: Variant = await _read_bounded(path)
|
|
if content == null:
|
|
return _fault_result()
|
|
if extension in ["res", "scn"]:
|
|
hits = await _binary_references(path, content, targets)
|
|
else:
|
|
# One call per owner: _references lowercases and regex-scans the
|
|
# whole file, so a per-target loop repeated that up to MAX_TARGETS
|
|
# times.
|
|
hits = await _references(path, content, targets)
|
|
if not _fault.is_empty():
|
|
return _fault_result()
|
|
if not await _yield_if_needed():
|
|
return _fault_result()
|
|
if not hits.is_empty() and not (operation == "remove" and targets.has(path)):
|
|
owners.append({"path": path, "references": hits})
|
|
var editor_guard := _editor_state_guard(targets)
|
|
if not editor_guard.is_empty():
|
|
return editor_guard
|
|
for owner in owners:
|
|
for hit in owner.references:
|
|
if operation == "remove":
|
|
if not bool(params.get("force", false)):
|
|
return _unchanged("Resource is still referenced; repair references or use force=true for known dangling references", {"referenced_by": owners})
|
|
elif hit.kind != "uid":
|
|
return _unchanged("Move requires dependency path rewrites, which are unsupported; update references or use preserved uid:// references first", {"referenced_by": owners})
|
|
var group: Array[Dictionary] = [{"from": source, "to": destination}]
|
|
if not directory:
|
|
for suffix in SIDECARS:
|
|
if FileAccess.file_exists(source + suffix):
|
|
group.append({"from": source + suffix, "to": destination + suffix})
|
|
for item in group:
|
|
if not _confined_without_links(item.from):
|
|
return _fault_result()
|
|
if operation != "remove" and not _destination_available(item.from, item.to):
|
|
return _fault_result()
|
|
# Re-read owner bytes and directory entry censuses after the yielding plan.
|
|
if not await _revalidate():
|
|
return _fault_result()
|
|
if not _active():
|
|
_set_fault("Filesystem request expired before mutation; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return _fault_result()
|
|
for item in group:
|
|
if not _confined_without_links(item.from) or (operation != "remove" and not _destination_available(item.from, item.to)):
|
|
return _fault_result()
|
|
# Revalidation yields. Recheck mutable editor authority after the last
|
|
# callback/yield, with no suspension between this guard and disk effects.
|
|
editor_guard = _editor_state_guard(targets)
|
|
if not editor_guard.is_empty():
|
|
return editor_guard
|
|
if operation == "remove":
|
|
return _remove(source, directory, group, targets, owners, bool(params.get("permanent", false)))
|
|
return _move(source, destination, directory, group, targets)
|
|
|
|
|
|
func _active() -> bool:
|
|
return Time.get_ticks_msec() < _deadline and (not _alive.is_valid() or bool(_alive.call()))
|
|
|
|
|
|
func _yield_if_needed() -> bool:
|
|
if not _active():
|
|
_set_fault("Filesystem discovery expired or was cancelled; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return false
|
|
var paused := Time.get_ticks_usec()
|
|
if paused - _yield_at >= _budget:
|
|
await (Engine.get_main_loop() as SceneTree).process_frame
|
|
_yield_at = Time.get_ticks_usec()
|
|
_budget = _frame_budget(_yield_at - paused)
|
|
if not _active():
|
|
_set_fault("Filesystem discovery expired or was cancelled; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return false
|
|
return true
|
|
|
|
|
|
## The next slice for an editor that was away `gap_usec` between two slices.
|
|
## The frame sleep absorbs work shorter than itself, so a third of the gap is a
|
|
## quarter of a sleeping editor's frame and lengthens a busy one by a third.
|
|
static func _frame_budget(gap_usec: int) -> int:
|
|
return clampi(gap_usec / BUDGET_GAP_DIVISOR, BUDGET_MIN_USEC, BUDGET_MAX_USEC)
|
|
|
|
|
|
func _collect_tree(root: String, files: Array[String]) -> bool:
|
|
var pending: Array[String] = [root]
|
|
var count := 0
|
|
while not pending.is_empty():
|
|
var path: String = pending.pop_back()
|
|
var dir := DirAccess.open(path)
|
|
if dir == null:
|
|
_set_fault("Cannot inspect directory: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return false
|
|
dir.include_hidden = true
|
|
if dir.list_dir_begin() != OK:
|
|
_set_fault("Cannot list directory: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return false
|
|
var entries: Array[String] = []
|
|
var name := dir.get_next()
|
|
while not name.is_empty():
|
|
if name not in [".", ".."]:
|
|
var child := path.path_join(name)
|
|
entries.append(name)
|
|
# Engine metadata, VCS internals and the immutable loaded plugin
|
|
# implementation are outside the user-resource owner scan.
|
|
if child not in ["res://.godot", "res://.git", PLUGIN_TREE]:
|
|
if not dir.has_method("is_link") or bool(dir.call("is_link", child)):
|
|
_fault = "Cannot establish ownership through a linked entry: %s" % child
|
|
dir.list_dir_end()
|
|
return false
|
|
if dir.current_is_dir():
|
|
pending.append(child)
|
|
else:
|
|
files.append(child)
|
|
count += 1
|
|
if count > MAX_ENTRIES:
|
|
_set_fault("Project discovery exceeds %d entries; nothing changed" % MAX_ENTRIES, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
dir.list_dir_end()
|
|
return false
|
|
if not await _yield_if_needed():
|
|
dir.list_dir_end()
|
|
return false
|
|
name = dir.get_next()
|
|
dir.list_dir_end()
|
|
entries.sort()
|
|
_directories[path] = entries
|
|
return true
|
|
|
|
|
|
func _read_bounded(path: String) -> Variant:
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
_set_fault("Cannot safely read owner %s (unreadable)" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return null
|
|
if file.get_length() > MAX_FILE_BYTES:
|
|
_set_fault("Cannot safely read owner %s (exceeds %d bytes)" % [path, MAX_FILE_BYTES], Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return null
|
|
var bytes := PackedByteArray()
|
|
var length := file.get_length()
|
|
while file.get_position() < length:
|
|
var part := file.get_buffer(mini(16384, length - file.get_position()))
|
|
if part.is_empty():
|
|
_set_fault("Short read while inspecting %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return null
|
|
bytes.append_array(part)
|
|
_bytes += part.size()
|
|
if _bytes > MAX_TOTAL_BYTES:
|
|
_set_fault("Filesystem discovery exceeds its byte budget; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return null
|
|
if not await _yield_if_needed():
|
|
return null
|
|
file.close()
|
|
var digest := HashingContext.new()
|
|
digest.start(HashingContext.HASH_SHA256)
|
|
digest.update(bytes)
|
|
_fingerprints[path] = digest.finish()
|
|
return bytes
|
|
|
|
|
|
func _mutation_member(path: String, source: String, directory: bool) -> bool:
|
|
if directory:
|
|
return path == source or path.begins_with(source + "/")
|
|
return path == source or path in [source + ".uid", source + ".import"]
|
|
|
|
|
|
func _file_length(path: String) -> int:
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
_set_fault("Cannot inspect owner metadata: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return -1
|
|
var length := file.get_length()
|
|
file.close()
|
|
return length
|
|
|
|
|
|
func _owner_dependencies(path: String) -> PackedStringArray:
|
|
var dependencies := PackedStringArray()
|
|
for dependency in ResourceLoader.get_dependencies(path):
|
|
dependencies.append(str(dependency))
|
|
return dependencies
|
|
|
|
|
|
## Lowercased `uid://` and path segments of the engine's dependency records.
|
|
func _dependency_segments(dependencies: PackedStringArray) -> Dictionary:
|
|
var segments: Dictionary = {}
|
|
for dependency in dependencies:
|
|
for segment in dependency.split("::", false):
|
|
segments[segment.to_lower()] = true
|
|
return segments
|
|
|
|
|
|
func _large_owner_references(path: String, targets: Dictionary) -> Array[Dictionary]:
|
|
var dependencies := _owner_dependencies(path)
|
|
var dependency_paths := _dependency_segments(dependencies)
|
|
_large_owners[path] = {
|
|
"modified": FileAccess.get_modified_time(path),
|
|
"size": _file_length(path),
|
|
"dependencies": dependencies,
|
|
}
|
|
if int(_large_owners[path]["size"]) < 0:
|
|
return []
|
|
var hits: Array[Dictionary] = []
|
|
for target: String in targets:
|
|
if not await _yield_if_needed():
|
|
return hits
|
|
var uid: int = targets[target].uid
|
|
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
|
|
if dependency_paths.has(target.to_lower()) or (not uid_text.is_empty() and dependency_paths.has(uid_text.to_lower())):
|
|
# A dependency path can be a UID-backed ext_resource. Without reading
|
|
# the oversized owner we cannot prove the file reference is safe to
|
|
# rewrite, so fail closed for moves; force-remove may still use it.
|
|
hits.append({"path": target, "kind": "path"})
|
|
return hits
|
|
|
|
|
|
func _binary_references(path: String, bytes: PackedByteArray, targets: Dictionary) -> Array[Dictionary]:
|
|
var hits: Array[Dictionary] = []
|
|
var payload: Variant = _binary_scan_bytes(path, bytes)
|
|
if payload == null:
|
|
return hits
|
|
# The binary format stores an ext_resource UID as an integer, which no text
|
|
# scan can see, so the engine's dependency records are consulted as well.
|
|
var dependency_paths := _dependency_segments(_owner_dependencies(path))
|
|
for target: String in targets:
|
|
# Keep cancellation and frame progress even when a path match continues.
|
|
if not await _yield_if_needed():
|
|
return hits
|
|
var uid: int = targets[target].uid
|
|
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
|
|
var uses_path: bool = (
|
|
_bytes_contains_ci(payload, target.to_utf8_buffer())
|
|
or dependency_paths.has(target.to_lower())
|
|
or (not uid_text.is_empty() and dependency_paths.has(uid_text.to_lower()))
|
|
)
|
|
var uses_uid: bool = not uid_text.is_empty() and _bytes_contains_ci(payload, uid_text.to_utf8_buffer())
|
|
if uses_path:
|
|
hits.append({"path": target, "kind": "path"})
|
|
elif uses_uid:
|
|
if not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target:
|
|
_set_fault("UID mapping is not authoritative for %s" % target)
|
|
return hits
|
|
hits.append({"path": target, "kind": "uid"})
|
|
return hits
|
|
|
|
|
|
## The bytes a literal scan can inspect. The editor saves binary resources
|
|
## compressed by default (`filesystem/on_save/compress_binary_resources`), and
|
|
## a compressed container shows none of its serialized paths, so expand it
|
|
## first. Layout: magic, mode, block size and expanded size (u32 each), one
|
|
## u32 compressed size per block, then the blocks.
|
|
##
|
|
## Returns an empty array when the expanded payload exceeds the per-file limit
|
|
## (the caller then relies on dependency records alone, like any oversized
|
|
## binary owner) and null, with a fault, for a container that cannot be read.
|
|
func _binary_scan_bytes(path: String, bytes: PackedByteArray) -> Variant:
|
|
if bytes.size() < 4 or bytes.slice(0, 4).get_string_from_ascii() != COMPRESSED_MAGIC:
|
|
return bytes
|
|
var unreadable := "Cannot inspect compressed binary owner: %s" % path
|
|
if bytes.size() < 16:
|
|
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return null
|
|
var mode := bytes.decode_u32(4)
|
|
var block_size := bytes.decode_u32(8)
|
|
var total := bytes.decode_u32(12)
|
|
if mode > FileAccess.COMPRESSION_BROTLI or block_size == 0:
|
|
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return null
|
|
if total > MAX_FILE_BYTES:
|
|
return PackedByteArray()
|
|
var block_count := total / block_size + 1
|
|
var offset := 16 + block_count * 4
|
|
if offset > bytes.size():
|
|
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return null
|
|
var payload := PackedByteArray()
|
|
for index in block_count:
|
|
var compressed_size := bytes.decode_u32(16 + index * 4)
|
|
var expected := total % block_size if index == block_count - 1 else block_size
|
|
if offset + compressed_size > bytes.size():
|
|
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return null
|
|
if expected > 0:
|
|
var block := bytes.slice(offset, offset + compressed_size).decompress(expected, mode)
|
|
if block.size() != expected:
|
|
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return null
|
|
payload.append_array(block)
|
|
offset += compressed_size
|
|
_bytes += payload.size()
|
|
if _bytes > MAX_TOTAL_BYTES:
|
|
_set_fault("Filesystem discovery exceeds its byte budget; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
|
|
return null
|
|
return payload
|
|
|
|
|
|
func _ascii_lower(value: int) -> int:
|
|
if value >= 65 and value <= 90:
|
|
return value + 32
|
|
return value
|
|
|
|
|
|
func _bytes_contains_ci(haystack: PackedByteArray, needle: PackedByteArray) -> bool:
|
|
if needle.is_empty() or haystack.size() < needle.size():
|
|
return false
|
|
var lower := _ascii_lower(needle[0])
|
|
var upper := lower - 32 if lower >= 97 and lower <= 122 else lower
|
|
var candidates := [lower]
|
|
if candidates[0] != upper:
|
|
candidates.append(upper)
|
|
for candidate in candidates:
|
|
var position := haystack.find(candidate)
|
|
while position >= 0:
|
|
if position + needle.size() <= haystack.size():
|
|
var matches := true
|
|
for index in needle.size():
|
|
if _ascii_lower(haystack[position + index]) != _ascii_lower(needle[index]):
|
|
matches = false
|
|
break
|
|
if matches:
|
|
return true
|
|
position = haystack.find(candidate, position + 1)
|
|
return false
|
|
|
|
|
|
func _references(path: String, bytes: PackedByteArray, targets: Dictionary) -> Array[Dictionary]:
|
|
var hits: Array[Dictionary] = []
|
|
# Compare paths conservatively across case-insensitive project volumes.
|
|
# A case-only false positive on a sensitive volume is a safe refusal.
|
|
var text := bytes.get_string_from_utf8().to_lower()
|
|
# Relative preload/load literals are path dependencies too. Never rewrite
|
|
# source text: recognizing one causes an unchanged refusal below.
|
|
var literals := RegEx.new()
|
|
literals.compile("[\"']([^\"'\\n]+)[\"']")
|
|
var relative_paths: Dictionary = {}
|
|
for literal in literals.search_all(text):
|
|
var value: String = literal.get_string(1)
|
|
if not value.contains(":"):
|
|
relative_paths[path.get_base_dir().path_join(value).simplify_path().to_lower()] = true
|
|
# ResourceLoader.load uses the project root for bare paths.
|
|
relative_paths["res://".path_join(value).simplify_path().to_lower()] = true
|
|
for target: String in targets:
|
|
# Keep cancellation and frame progress even when a path match continues.
|
|
if not await _yield_if_needed():
|
|
return hits
|
|
var target_text := target.to_lower()
|
|
var uid: int = targets[target].uid
|
|
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
|
|
var uses_uid := not uid_text.is_empty() and (("\"%s\"" % uid_text) in text or ("'%s'" % uid_text) in text)
|
|
if relative_paths.has(target_text):
|
|
hits.append({"path": target, "kind": "path"})
|
|
continue
|
|
var uses_path := ("\"%s\"" % target_text) in text or ("'%s'" % target_text) in text
|
|
if uses_path:
|
|
# A stored ext_resource fallback path is safe only with this exact
|
|
# target's preserved UID on the same header. Other strings block.
|
|
var only_uid_headers := path.get_extension().to_lower() in ["tscn", "tres"] and not (("'%s'" % target_text) in text)
|
|
for line in text.split("\n"):
|
|
if ("\"%s\"" % target_text) in line:
|
|
only_uid_headers = only_uid_headers and line.strip_edges().begins_with("[ext_resource ") and not uid_text.is_empty() and ("uid=\"%s\"" % uid_text) in line
|
|
if not only_uid_headers:
|
|
hits.append({"path": target, "kind": "path"})
|
|
continue
|
|
uses_uid = true
|
|
if uses_uid:
|
|
if not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target:
|
|
_fault = "UID mapping is not authoritative for %s" % target
|
|
return hits
|
|
hits.append({"path": target, "kind": "uid"})
|
|
return hits
|
|
|
|
|
|
func _editor_state_guard(targets: Dictionary) -> Dictionary:
|
|
var settings := _settings_references(targets)
|
|
if not settings.is_empty():
|
|
return _unchanged("Project settings reference this resource; update those settings explicitly before mutating it", {"settings": settings})
|
|
for scene in EditorInterface.get_open_scenes():
|
|
if targets.has(scene):
|
|
return _unchanged("Close affected scene tabs before mutating their files", {"scene": scene})
|
|
for target: String in targets:
|
|
var uid: int = targets[target].uid
|
|
if uid != ResourceUID.INVALID_ID and (not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target):
|
|
return _unchanged("UID mapping is no longer authoritative for %s; nothing changed" % target)
|
|
return {}
|
|
|
|
|
|
func _settings_references(targets: Dictionary) -> Array[String]:
|
|
var settings: Array[String] = []
|
|
for prop in ProjectSettings.get_property_list():
|
|
var name := str(prop.name)
|
|
var value: Variant = ProjectSettings.get_setting(name)
|
|
if not value is String:
|
|
continue
|
|
var path: String = value.trim_prefix("*")
|
|
if path.begins_with("uid://"):
|
|
var uid := ResourceUID.text_to_id(path)
|
|
path = ResourceUID.get_id_path(uid) if ResourceUID.has_id(uid) else path
|
|
if targets.has(path):
|
|
settings.append(name)
|
|
return settings
|
|
|
|
|
|
func _revalidate() -> bool:
|
|
var original_directories := _directories.duplicate(true)
|
|
_directories.clear()
|
|
var files: Array[String] = []
|
|
if not await _collect_tree("res://", files):
|
|
return false
|
|
if _directories != original_directories:
|
|
_fault = "Project entries changed during discovery; retry after changes settle"
|
|
return false
|
|
var original := _fingerprints.duplicate(true)
|
|
for path in original:
|
|
if await _read_bounded(path) == null:
|
|
return false
|
|
if _fingerprints[path] != original[path]:
|
|
_fault = "Owner changed during discovery: %s" % path
|
|
return false
|
|
var original_large := _large_owners.duplicate(true)
|
|
for path in original_large:
|
|
if not await _large_owner_unchanged(path, original_large[path]):
|
|
_fault = "Owner changed during discovery: %s" % path
|
|
return false
|
|
return true
|
|
|
|
|
|
func _large_owner_unchanged(path: String, original: Dictionary) -> bool:
|
|
if not FileAccess.file_exists(path):
|
|
return false
|
|
if FileAccess.get_modified_time(path) != int(original.get("modified", 0)):
|
|
return false
|
|
if _file_length(path) != int(original.get("size", -1)):
|
|
return false
|
|
if str(_owner_dependencies(path)) != str(original.get("dependencies", PackedStringArray())):
|
|
return false
|
|
return await _yield_if_needed()
|
|
|
|
|
|
func _confined_without_links(path: String) -> bool:
|
|
var dir := DirAccess.open("res://")
|
|
if dir == null or not dir.has_method("is_link"):
|
|
_fault = "Filesystem link inspection is unavailable; refusing mutation"
|
|
return false
|
|
var current := "res://"
|
|
for component in path.trim_prefix("res://").split("/"):
|
|
current = current.path_join(component)
|
|
if bool(dir.call("is_link", current)):
|
|
_fault = "Linked filesystem paths cannot be mutated: %s" % current
|
|
return false
|
|
return true
|
|
|
|
|
|
func _destination_available(source: String, destination: String) -> bool:
|
|
if not _confined_without_links(destination):
|
|
return false
|
|
if not FileAccess.file_exists(destination) and not DirAccess.dir_exists_absolute(destination):
|
|
return true
|
|
var parent := source.get_base_dir()
|
|
if parent == destination.get_base_dir() and source.to_lower() == destination.to_lower():
|
|
var dir := DirAccess.open(parent)
|
|
if dir != null and dir.has_method("is_equivalent"):
|
|
dir.include_hidden = true
|
|
var entries := dir.get_files() + dir.get_directories()
|
|
if entries.has(source.get_file()) and not entries.has(destination.get_file()) and bool(dir.call("is_equivalent", source, destination)):
|
|
return true
|
|
_fault = "Destination already exists or case-only identity is ambiguous: %s" % destination
|
|
return false
|
|
|
|
|
|
func _rename(source: String, destination: String) -> int:
|
|
if _io.is_valid():
|
|
return int(_io.call("rename", source, destination))
|
|
return DirAccess.rename_absolute(source, destination)
|
|
|
|
|
|
func _move(source: String, destination: String, directory: bool, group: Array[Dictionary], targets: Dictionary) -> Dictionary:
|
|
var applied: Array[Dictionary] = []
|
|
for item in group:
|
|
var code := _rename(item.from, item.to)
|
|
if code != OK:
|
|
var unrestored: Array[Dictionary] = []
|
|
for index in range(applied.size() - 1, -1, -1):
|
|
var prior := applied[index]
|
|
if _rename(prior.to, prior.from) != OK:
|
|
unrestored.append(prior)
|
|
var outcome := "unchanged" if applied.is_empty() else ("rolled_back" if unrestored.is_empty() else "partial")
|
|
if not unrestored.is_empty():
|
|
_reconcile_move(targets, source, destination, false)
|
|
return _failure("Move failed: %s" % error_string(code), outcome, {"applied": applied, "unrestored": unrestored})
|
|
applied.append(item)
|
|
_reconcile_move(targets, source, destination)
|
|
var moved: Array[Dictionary] = []
|
|
for old_path: String in targets:
|
|
moved.append({"from": old_path, "to": destination + old_path.trim_prefix(source)})
|
|
return {"data": {"path": source, "new_path": destination, "kind": "directory" if directory else "file",
|
|
"moved": moved, "moved_count": moved.size(), "uids_updated": _uid_count(targets), "outcome": "committed", "scan_required": directory,
|
|
"dependencies_updated": [], "project_settings_updated": [], "warnings": [], "undoable": false,
|
|
"reason": "Filesystem moves are persistent; move the resource back explicitly to revert"}}
|
|
|
|
|
|
func _reconcile_move(targets: Dictionary, source: String, destination: String, refresh_editor: bool = true) -> void:
|
|
var efs := EditorInterface.get_resource_filesystem()
|
|
for old_path: String in targets:
|
|
var new_path := destination + old_path.trim_prefix(source)
|
|
var actual := new_path if FileAccess.file_exists(new_path) else old_path
|
|
var uid: int = targets[old_path].uid
|
|
if uid != ResourceUID.INVALID_ID:
|
|
if ResourceUID.has_id(uid):
|
|
ResourceUID.set_id(uid, actual)
|
|
else:
|
|
ResourceUID.add_id(uid, actual)
|
|
if actual != old_path and ResourceLoader.has_cached(old_path):
|
|
var cached := ResourceLoader.get_cached_ref(old_path)
|
|
if cached != null:
|
|
cached.take_over_path(actual)
|
|
# Updating a missing path removes orphan UID sidecars. On partial
|
|
# outcomes retain those bytes for explicit recovery instead.
|
|
if efs != null and refresh_editor:
|
|
if old_path.to_lower() != actual.to_lower():
|
|
efs.update_file(old_path)
|
|
efs.update_file(actual)
|
|
|
|
|
|
func _remove(source: String, directory: bool, group: Array[Dictionary], targets: Dictionary, owners: Array, permanent: bool) -> Dictionary:
|
|
var removed: Array[String] = []
|
|
var failure := OK
|
|
for item in group:
|
|
var code: int
|
|
if _io.is_valid():
|
|
code = int(_io.call("remove" if permanent else "trash", item.from, ""))
|
|
else:
|
|
code = DirAccess.remove_absolute(item.from) if permanent else OS.move_to_trash(ProjectSettings.globalize_path(item.from))
|
|
if code != OK:
|
|
failure = code
|
|
break
|
|
removed.append(item.from)
|
|
var efs := EditorInterface.get_resource_filesystem()
|
|
for target: String in targets:
|
|
if FileAccess.file_exists(target):
|
|
continue
|
|
var uid: int = targets[target].uid
|
|
if uid != ResourceUID.INVALID_ID and ResourceUID.has_id(uid):
|
|
ResourceUID.remove_id(uid)
|
|
if efs != null and failure == OK:
|
|
efs.update_file(target)
|
|
if failure != OK:
|
|
var remaining: Array[String] = []
|
|
for item in group:
|
|
if FileAccess.file_exists(item.from) or DirAccess.dir_exists_absolute(item.from):
|
|
remaining.append(item.from)
|
|
return _failure("Removal failed: %s" % error_string(failure), "unchanged" if removed.is_empty() else "partial",
|
|
{"removed": removed, "remaining": remaining, "trashed": not permanent})
|
|
return {"data": {"path": source, "kind": "directory" if directory else "file", "removed": targets.keys(),
|
|
"removed_count": targets.size(), "trashed": not permanent, "uids_released": _uid_count(targets),
|
|
"project_settings_cleared": [], "referenced_by": owners, "outcome": "committed", "scan_required": directory, "warnings": [], "undoable": false,
|
|
"reason": "Permanently deleted" if permanent else "Moved to the OS trash; restore it there to revert"}}
|
|
|
|
|
|
static func _uid_count(targets: Dictionary) -> int:
|
|
var count := 0
|
|
for entry in targets.values():
|
|
if entry.uid != ResourceUID.INVALID_ID:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
static func _is_sidecar(path: String) -> bool:
|
|
return path.ends_with(".uid") or path.ends_with(".import")
|
|
|
|
|
|
func _set_fault(message: String, code: String = "") -> void:
|
|
_fault = message
|
|
_fault_code = code
|
|
|
|
|
|
func _fault_result() -> Dictionary:
|
|
if _fault_code == Errors.FILESYSTEM_DISCOVERY_FAILED:
|
|
return _failure(_fault, "unchanged", {}, _fault_code)
|
|
return _unchanged(_fault)
|
|
|
|
|
|
static func _unchanged(message: String, details: Dictionary = {}) -> Dictionary:
|
|
return _failure(message, "unchanged", details, Errors.INVALID_PARAMS)
|
|
|
|
|
|
static func _failure(message: String, outcome: String, details: Dictionary, code: String = Errors.INTERNAL_ERROR) -> Dictionary:
|
|
var result := Errors.make(code, message)
|
|
result.error["data"] = details.merged({"outcome": outcome, "retry_safe": outcome in ["unchanged", "rolled_back"]}, true)
|
|
return result
|
|
|
|
|
|
static func _reserved(path: String) -> bool:
|
|
var normalized := path.to_lower().trim_suffix("/")
|
|
for reserved: String in ["res://.godot", "res://.git", PLUGIN_TREE]:
|
|
if normalized == reserved or normalized.begins_with(reserved + "/") or reserved.begins_with(normalized + "/"):
|
|
return true
|
|
return false
|