579 lines
17 KiB
GDScript
579 lines
17 KiB
GDScript
@tool
|
|
class_name YAMLEditorDocumentManager extends Node
|
|
|
|
signal document_changed(document)
|
|
signal document_created(document)
|
|
signal document_closed(document)
|
|
|
|
# Dictionary of open documents: {path: YAMLEditorDocument}
|
|
var documents: Dictionary = {}
|
|
var current_document: YAMLEditorDocument = null
|
|
|
|
# UI components
|
|
var file_list: YAMLEditorFileList
|
|
var code_editor: YAMLCodeEditor
|
|
var file_popup_menu: PopupMenu
|
|
var editor_node: Control
|
|
|
|
# Reference to the singleton
|
|
var file_system: YAMLFileSystem
|
|
|
|
# Track recently saved files to avoid external update conflicts
|
|
var recently_saved_files: Dictionary = {}
|
|
var ignore_update_timer: Timer
|
|
|
|
func _init(_editor: Control) -> void:
|
|
editor_node = _editor
|
|
|
|
func _ready() -> void:
|
|
# Get singleton reference
|
|
file_system = YAMLFileSystem.get_singleton()
|
|
|
|
# Listen for external file updates
|
|
file_system.file_updated.connect(_on_external_file_updated)
|
|
file_system.file_renamed.connect(_on_file_renamed)
|
|
|
|
# Create file popup menu
|
|
file_popup_menu = PopupMenu.new()
|
|
add_child(file_popup_menu)
|
|
|
|
# Add menu items
|
|
file_popup_menu.add_item("Save", 0)
|
|
file_popup_menu.add_item("Save As...", 1)
|
|
file_popup_menu.add_separator()
|
|
file_popup_menu.add_item("Close", 2)
|
|
file_popup_menu.add_separator()
|
|
file_popup_menu.add_item("Show in FileSystem", 3)
|
|
|
|
# Connect popup menu signals
|
|
file_popup_menu.id_pressed.connect(_on_file_popup_menu_id_pressed)
|
|
|
|
# Create timer for clearing recent saves
|
|
ignore_update_timer = Timer.new()
|
|
add_child(ignore_update_timer)
|
|
ignore_update_timer.one_shot = true
|
|
ignore_update_timer.wait_time = 0.5 # 500ms
|
|
ignore_update_timer.timeout.connect(_on_ignore_update_timer_timeout)
|
|
|
|
# Update UI
|
|
update_ui()
|
|
|
|
func setup(p_file_list: YAMLEditorFileList, p_code_editor: YAMLCodeEditor) -> void:
|
|
file_list = p_file_list
|
|
code_editor = p_code_editor
|
|
|
|
# Connect signals from file list component
|
|
file_list.file_selected.connect(_on_file_selected)
|
|
file_list.file_context_requested.connect(_on_file_context_requested)
|
|
|
|
func create_document(path: String, content: String = "") -> YAMLEditorDocument:
|
|
var normalized_path = _normalize_path(path)
|
|
|
|
# Check if document already exists with normalized path
|
|
if documents.has(normalized_path):
|
|
return documents[normalized_path]
|
|
|
|
var document := YAMLEditorDocument.new(normalized_path, content)
|
|
|
|
# Connect document signals
|
|
document.content_changed.connect(_on_document_content_changed)
|
|
document.modified_changed.connect(_on_document_modified_changed)
|
|
|
|
# Store document
|
|
documents[normalized_path] = document
|
|
document_created.emit(document)
|
|
|
|
return document
|
|
|
|
func open_file(path: String) -> void:
|
|
var normalized_path = _normalize_path(path)
|
|
|
|
# Check if already open
|
|
if documents.has(normalized_path):
|
|
set_current_document(documents[normalized_path])
|
|
return
|
|
|
|
# Look for any document with the same base filename
|
|
var filename = normalized_path.get_file()
|
|
for existing_path in documents.keys():
|
|
if existing_path.get_file() == filename and existing_path != normalized_path:
|
|
# Check if they point to the same actual file
|
|
if _paths_point_to_same_file(normalized_path, existing_path):
|
|
set_current_document(documents[existing_path])
|
|
return
|
|
|
|
# Use the file system singleton to read the file
|
|
var content := file_system.read_file(normalized_path)
|
|
if typeof(content) == TYPE_INT: # Error code
|
|
push_error("Could not open file '%s': %s" % [normalized_path, error_string(content)])
|
|
return
|
|
|
|
# Create new document
|
|
var document := create_document(normalized_path, content)
|
|
document.mark_saved() # Initial state is saved
|
|
|
|
# Switch to the new document
|
|
set_current_document(document)
|
|
|
|
# Notify the file system
|
|
file_system.notify_file_opened(normalized_path)
|
|
|
|
func close_document(document: YAMLEditorDocument) -> bool:
|
|
if document == null:
|
|
return true
|
|
|
|
if document.is_modified:
|
|
# Show confirmation dialog for unsaved changes
|
|
var dialog := ConfirmationDialog.new()
|
|
dialog.title = "Unsaved Changes"
|
|
dialog.dialog_text = "Save changes to '" + document.get_file_name() + "' before closing?"
|
|
dialog.add_button("Don't Save", true, "dont_save")
|
|
dialog.add_cancel_button("Cancel")
|
|
|
|
dialog.confirmed.connect(
|
|
func():
|
|
# Save was chosen
|
|
if save_document(document):
|
|
_close_document_internal(document)
|
|
dialog.queue_free()
|
|
)
|
|
|
|
dialog.custom_action.connect(
|
|
func(action):
|
|
if action == "dont_save":
|
|
_close_document_internal(document)
|
|
dialog.queue_free()
|
|
)
|
|
|
|
dialog.canceled.connect(func(): dialog.queue_free())
|
|
|
|
add_child(dialog)
|
|
dialog.popup_centered()
|
|
return false
|
|
|
|
return _close_document_internal(document)
|
|
|
|
func _close_document_internal(document: YAMLEditorDocument) -> bool:
|
|
if document == null:
|
|
return false
|
|
|
|
# Find the document in our dictionary
|
|
var path_to_remove = ""
|
|
for path in documents.keys():
|
|
if documents[path] == document:
|
|
path_to_remove = path
|
|
break
|
|
|
|
if path_to_remove.is_empty():
|
|
return false
|
|
|
|
# Notify document is being closed
|
|
document_closed.emit(document)
|
|
|
|
# Remove document
|
|
documents.erase(document.path)
|
|
|
|
# If this was the current document, switch to another
|
|
if current_document == document:
|
|
current_document = null
|
|
|
|
# Select another document if available
|
|
if not documents.is_empty():
|
|
set_current_document(documents.values()[0])
|
|
else:
|
|
# Clear the editor if no documents left
|
|
code_editor.text = ""
|
|
|
|
# Update UI
|
|
update_ui()
|
|
|
|
# Notify the file system
|
|
file_system.notify_file_closed(document.path)
|
|
|
|
return true
|
|
|
|
func save_document(document: YAMLEditorDocument) -> bool:
|
|
if document == null:
|
|
return false
|
|
|
|
# Don't save untitled files directly
|
|
if document.is_untitled():
|
|
return false # Caller should handle "Save As" dialog
|
|
|
|
# Mark this file as recently saved to ignore update notifications
|
|
recently_saved_files[document.path] = Time.get_unix_time_from_system()
|
|
ignore_update_timer.start()
|
|
|
|
# Use the file system singleton to save the file
|
|
var result := file_system.save_file(document.path, document.content)
|
|
if result != OK:
|
|
push_error("Could not save file '%s': %s" % [document.path, error_string(result)])
|
|
recently_saved_files.erase(document.path) # Remove from recently saved if error
|
|
return false
|
|
|
|
# Mark document as saved
|
|
document.mark_saved()
|
|
|
|
# Update UI
|
|
update_ui()
|
|
|
|
return true
|
|
|
|
func save_document_as(document: YAMLEditorDocument, new_path: String) -> bool:
|
|
if document == null or new_path.is_empty():
|
|
return false
|
|
|
|
var normalized_new_path = _normalize_path(new_path)
|
|
|
|
# Check if we're trying to save to a path that's already open
|
|
if documents.has(normalized_new_path) and documents[normalized_new_path] != document:
|
|
push_error("Cannot save as '%s' - file is already open" % normalized_new_path)
|
|
return false
|
|
|
|
# Remember the old path
|
|
var old_path := document.path
|
|
|
|
# Update document path
|
|
document.path = normalized_new_path
|
|
|
|
# Update the documents dictionary
|
|
if old_path != normalized_new_path:
|
|
documents.erase(old_path)
|
|
documents[normalized_new_path] = document
|
|
|
|
# Save the document
|
|
if save_document(document):
|
|
# If old path was temporary, clean up
|
|
if old_path != normalized_new_path and old_path.begins_with("untitled"):
|
|
file_system.notify_file_closed(old_path)
|
|
|
|
# Update UI
|
|
update_ui()
|
|
|
|
# Notify file system
|
|
file_system.notify_file_opened(new_path)
|
|
|
|
return true
|
|
|
|
# Restore old path if save failed
|
|
if old_path != normalized_new_path:
|
|
document.path = old_path
|
|
documents.erase(normalized_new_path)
|
|
documents[old_path] = document
|
|
|
|
return false
|
|
|
|
func new_file() -> void:
|
|
# Create a new untitled file
|
|
var untitled_name := "untitled.yaml"
|
|
var index := 1
|
|
|
|
while documents.has(untitled_name):
|
|
index += 1
|
|
untitled_name = "untitled%d.yaml" % index
|
|
|
|
# Create a new document
|
|
var document := create_document(untitled_name)
|
|
document.set_modified(true) # New document is always modified
|
|
|
|
# Switch to the new document
|
|
set_current_document(document)
|
|
|
|
# Set focus to code editor
|
|
code_editor.grab_focus()
|
|
|
|
# Notify the file system
|
|
file_system.notify_file_opened(untitled_name)
|
|
|
|
func set_current_document(document: YAMLEditorDocument) -> void:
|
|
if document == null or document == current_document:
|
|
return
|
|
|
|
current_document = document
|
|
|
|
# Update editor content
|
|
if is_instance_valid(code_editor):
|
|
code_editor.set_text_and_preserve_state(document.content)
|
|
|
|
# Update UI
|
|
update_ui()
|
|
|
|
# Emit signal
|
|
document_changed.emit(document)
|
|
|
|
func update_document_content(document: YAMLEditorDocument, new_content: String) -> void:
|
|
if document == null:
|
|
return
|
|
|
|
var caret_line := 0
|
|
var caret_column := 0
|
|
|
|
if is_instance_valid(code_editor):
|
|
caret_line = code_editor.get_caret_line()
|
|
caret_column = code_editor.get_caret_column()
|
|
|
|
document.set_content(new_content, caret_line, caret_column)
|
|
|
|
func _normalize_path(path: String) -> String:
|
|
# Convert to absolute path and normalize
|
|
var normalized = path
|
|
|
|
# Handle different path formats
|
|
if normalized.begins_with("res://"):
|
|
normalized = ProjectSettings.globalize_path(normalized)
|
|
|
|
# Convert to canonical form
|
|
normalized = normalized.simplify_path()
|
|
|
|
# Convert back to res:// format if it was originally a project path
|
|
if path.begins_with("res://"):
|
|
normalized = ProjectSettings.localize_path(normalized)
|
|
|
|
return normalized
|
|
|
|
func _paths_point_to_same_file(path1: String, path2: String) -> bool:
|
|
# For project files, compare the res:// paths
|
|
if path1.begins_with("res://") and path2.begins_with("res://"):
|
|
return path1 == path2
|
|
|
|
# For absolute paths, normalize and compare
|
|
var abs_path1 = ProjectSettings.globalize_path(path1) if path1.begins_with("res://") else path1
|
|
var abs_path2 = ProjectSettings.globalize_path(path2) if path2.begins_with("res://") else path2
|
|
|
|
return abs_path1.simplify_path() == abs_path2.simplify_path()
|
|
|
|
func _on_document_content_changed(document: YAMLEditorDocument) -> void:
|
|
# Update UI if this is the current document
|
|
if document == current_document:
|
|
update_ui()
|
|
|
|
func _on_document_modified_changed(document: YAMLEditorDocument) -> void:
|
|
# Update UI if this is the current document
|
|
if document == current_document:
|
|
update_ui()
|
|
|
|
func update_ui() -> void:
|
|
# Toggle editor visibility
|
|
editor_node.visible = documents.size() > 0
|
|
|
|
if not is_instance_valid(file_list):
|
|
return
|
|
|
|
# Prepare file data for the file list component
|
|
var file_data := {}
|
|
for path in documents:
|
|
var document = documents[path]
|
|
file_data[path] = {
|
|
"name": document.get_file_name(),
|
|
"modified": document.is_modified
|
|
}
|
|
|
|
# Update the file list component
|
|
var current_path = current_document.path if current_document else ""
|
|
file_list.update_files(file_data, current_path)
|
|
|
|
func _on_file_selected(path: String) -> void:
|
|
if path.is_empty():
|
|
return
|
|
|
|
var normalized_path = _normalize_path(path)
|
|
if not documents.has(normalized_path):
|
|
return
|
|
|
|
set_current_document(documents[normalized_path])
|
|
|
|
func _on_file_context_requested(path: String, at_position: Vector2) -> void:
|
|
if path.is_empty():
|
|
return
|
|
|
|
var normalized_path = _normalize_path(path)
|
|
if not documents.has(normalized_path):
|
|
return
|
|
|
|
set_current_document(documents[normalized_path])
|
|
|
|
# Calculate the global position for the popup
|
|
var global_rect := Rect2(file_list.get_global_mouse_position(), Vector2.ZERO)
|
|
file_popup_menu.popup_on_parent(global_rect)
|
|
|
|
func _on_file_popup_menu_id_pressed(id: int) -> void:
|
|
var path := file_list.get_selected_file_path()
|
|
if path.is_empty():
|
|
return
|
|
|
|
var normalized_path = _normalize_path(path)
|
|
if not documents.has(normalized_path):
|
|
return
|
|
|
|
var document: YAMLEditorDocument = documents[normalized_path]
|
|
|
|
match id:
|
|
0: # Save
|
|
save_document(document)
|
|
1: # Save As
|
|
# Main editor should handle the save as dialog
|
|
set_current_document(document)
|
|
2: # Close
|
|
close_document(document)
|
|
3: # Show in FileSystem
|
|
if not document.is_untitled() and document.path.begins_with("res://"):
|
|
EditorInterface.get_file_system_dock().navigate_to_path(document.path)
|
|
|
|
func _on_external_file_updated(path: String) -> void:
|
|
var normalized_path = _normalize_path(path)
|
|
|
|
# Only process if the file is open and it's a YAML file
|
|
if documents.has(normalized_path) and file_system.is_yaml_file(normalized_path):
|
|
# Check if we just saved this file ourselves
|
|
if recently_saved_files.has(normalized_path):
|
|
var save_time: int = recently_saved_files[normalized_path]
|
|
var current_time := Time.get_unix_time_from_system()
|
|
|
|
# If saved less than 1 second ago, ignore this update
|
|
if current_time - save_time < 1.0:
|
|
return
|
|
|
|
var document: YAMLEditorDocument = documents[normalized_path]
|
|
|
|
# Check if the document has unsaved changes
|
|
if not document.is_modified:
|
|
# Document is not modified locally, safe to reload
|
|
var content = file_system.read_file(normalized_path)
|
|
if typeof(content) != TYPE_INT: # Not an error
|
|
# Update document content
|
|
document.content = content
|
|
document.mark_saved()
|
|
|
|
# If this is the current document, update the editor
|
|
if document == current_document:
|
|
# Preserve cursor position and scroll state
|
|
var previous_caret_line := code_editor.get_caret_line()
|
|
var previous_caret_column := code_editor.get_caret_column()
|
|
var previous_scroll_v := code_editor.get_v_scroll_bar().value
|
|
var previous_scroll_h := code_editor.get_h_scroll_bar().value
|
|
|
|
code_editor.text = content
|
|
|
|
# Restore position if possible
|
|
if previous_caret_line < code_editor.get_line_count():
|
|
code_editor.set_caret_line(previous_caret_line)
|
|
var line_length := code_editor.get_line(previous_caret_line).length()
|
|
if previous_caret_column <= line_length:
|
|
code_editor.set_caret_column(previous_caret_column)
|
|
|
|
# Restore scroll position
|
|
code_editor.get_v_scroll_bar().value = previous_scroll_v
|
|
code_editor.get_h_scroll_bar().value = previous_scroll_h
|
|
|
|
update_ui()
|
|
else:
|
|
# Document has unsaved changes, show conflict dialog
|
|
if document == current_document:
|
|
var dialog := ConfirmationDialog.new()
|
|
dialog.title = "External Changes Detected"
|
|
dialog.dialog_text = "The file '" + document.get_file_name() + "' has been modified externally. Do you want to reload it and lose your changes?"
|
|
dialog.confirmed.connect(
|
|
func():
|
|
var content := file_system.read_file(path)
|
|
if typeof(content) != TYPE_INT:
|
|
document.content = content
|
|
document.mark_saved()
|
|
|
|
if document == current_document:
|
|
code_editor.text = content
|
|
|
|
update_ui()
|
|
dialog.queue_free()
|
|
)
|
|
dialog.canceled.connect(func(): dialog.queue_free())
|
|
add_child(dialog)
|
|
dialog.popup_centered()
|
|
|
|
func _on_ignore_update_timer_timeout() -> void:
|
|
# Clear out any old saved entries
|
|
var current_time := Time.get_unix_time_from_system()
|
|
var keys_to_remove: PackedStringArray = []
|
|
|
|
for path in recently_saved_files:
|
|
var save_time = recently_saved_files[path]
|
|
if current_time - save_time >= 1.0:
|
|
keys_to_remove.append(path)
|
|
|
|
for path in keys_to_remove:
|
|
recently_saved_files.erase(path)
|
|
|
|
func _on_file_renamed(old_path: String, new_path: String) -> void:
|
|
var normalized_old_path = _normalize_path(old_path)
|
|
var normalized_new_path = _normalize_path(new_path)
|
|
|
|
# If we have this document open, update our references
|
|
if documents.has(normalized_old_path):
|
|
var document: YAMLEditorDocument = documents[normalized_old_path]
|
|
document.path = normalized_new_path
|
|
|
|
documents.erase(normalized_old_path)
|
|
documents[normalized_new_path] = document
|
|
|
|
update_ui()
|
|
|
|
func handle_filesystem_change() -> void:
|
|
# Check if any of our open res:// files no longer exist
|
|
var missing_files: PackedStringArray = []
|
|
|
|
for path in documents.keys():
|
|
if path.begins_with("res://") and not file_system.file_exists(path):
|
|
missing_files.append(path)
|
|
|
|
# Handle missing files
|
|
for old_path in missing_files:
|
|
var document: YAMLEditorDocument = documents[old_path]
|
|
|
|
# Try to find a file with the same name but different path in the filesystem
|
|
var filename := old_path.get_file()
|
|
var filesystem_root := EditorInterface.get_resource_filesystem().get_filesystem()
|
|
var new_path := file_system.find_file_in_filesystem(filesystem_root, filename)
|
|
|
|
if not new_path.is_empty():
|
|
var normalized_new_path = _normalize_path(new_path)
|
|
|
|
# Found potential match - update the document path
|
|
document.path = normalized_new_path
|
|
documents.erase(old_path)
|
|
documents[normalized_new_path] = document
|
|
|
|
# If this is the current document, emit signal
|
|
if document == current_document:
|
|
document_changed.emit(document)
|
|
|
|
# Update UI
|
|
update_ui()
|
|
|
|
# Notify file system
|
|
file_system.notify_file_closed(old_path)
|
|
file_system.notify_file_opened(normalized_new_path)
|
|
file_system.notify_file_renamed(old_path, normalized_new_path)
|
|
else:
|
|
# Keep it open but mark as potentially moved/deleted to avoid losing unsaved changes
|
|
pass
|
|
|
|
func has_unsaved_changes() -> bool:
|
|
for document in documents.values():
|
|
if document.is_modified:
|
|
return true
|
|
return false
|
|
|
|
func get_open_documents() -> Array:
|
|
return documents.values()
|
|
|
|
func get_open_paths() -> Array:
|
|
return documents.keys()
|
|
|
|
func has_document(path: String) -> bool:
|
|
return documents.has(path)
|
|
|
|
func get_document(path: String) -> YAMLEditorDocument:
|
|
return documents.get(path, null)
|
|
|
|
func get_current_document() -> YAMLEditorDocument:
|
|
return current_document
|