@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