diff --git a/src/MainWindow.vala b/src/MainWindow.vala index 53b8635edb..9fdcd141f4 100644 --- a/src/MainWindow.vala +++ b/src/MainWindow.vala @@ -595,7 +595,7 @@ namespace Scratch { protected override bool delete_event (Gdk.EventAny event) { handle_quit (); - return !check_unsaved_changes (); + return Gdk.EVENT_STOP; } // Set sensitive property for 'delicate' Widgets/GtkActions while @@ -643,19 +643,6 @@ namespace Scratch { document_view.close_document (doc); } - // Check if there no unsaved changes - private bool check_unsaved_changes () { - document_view.is_closing = true; - foreach (var doc in document_view.docs) { - if (!doc.do_close (true)) { - document_view.current_document = doc; - return false; - } - } - - return true; - } - // Save session information different from window state private void restore_saved_state_extra () { // Plugin panes size @@ -708,8 +695,23 @@ namespace Scratch { // For exit cleanup private void handle_quit () { - document_view.save_opened_files (); - update_saved_state (); + save_all_documents.begin ((obj, res) => { + if (save_all_documents.end (res)) { + update_saved_state (); + destroy (); + } + }); + } + + private async bool save_all_documents () { + unowned var docs = document_view.docs; + var success = true; + var docs_copy = docs.copy (); + foreach (var doc in docs_copy) { + success = success && yield doc.do_close (true); + } + + return success; } public void set_default_zoom () { @@ -798,9 +800,6 @@ namespace Scratch { private void action_quit () { handle_quit (); - if (check_unsaved_changes ()) { - destroy (); - } } private void action_open () { @@ -872,7 +871,7 @@ namespace Scratch { if (doc.is_file_temporary == true) { action_save_as (); } else { - doc.save.begin (true); + doc.save.begin (); } } } diff --git a/src/Services/Document.vala b/src/Services/Document.vala index 1db488abf1..7f0f3d04bd 100644 --- a/src/Services/Document.vala +++ b/src/Services/Document.vala @@ -4,6 +4,7 @@ Copyright (C) 2011-2012 Giulio Collura 2013 Mario Guerriero + 2023 elementary LLC. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License version 3, as published by the Free Software Foundation. @@ -34,6 +35,8 @@ namespace Scratch.Services { // The parent window's actions public unowned SimpleActionGroup actions { get; set construct; } + public Gtk.SourceFile source_file { get; private set; } + public Scratch.Widgets.SourceView source_view { get; private set; } public bool is_file_temporary { get { @@ -43,7 +46,6 @@ namespace Scratch.Services { } } - private Gtk.SourceFile source_file; public GLib.File file { get { return source_file.location; @@ -86,23 +88,35 @@ namespace Scratch.Services { } } - public Gtk.Stack main_stack; - public Scratch.Widgets.SourceView source_view; + public bool delay_autosaving { get; set; } + public bool inhibit_saving { + get { + return !loaded || completion_shown; + } + } + public bool content_changed { + get { + return last_save_content != source_view.buffer.text; + } + } + private Scratch.Services.SymbolOutline? outline = null; - public string original_content; - private string last_save_content; - public bool saved = true; + private string original_content = ""; // For restoring to original + public string last_save_content = ""; // For detecting internal and external changes private bool completion_shown = false; + private bool loaded = false; + private Gtk.Stack main_stack; private Gtk.ScrolledWindow scroll; private Gtk.InfoBar info_bar; private Gtk.SourceMap source_map; private Gtk.Paned outline_widget_pane; + private DocumentManager doc_manager; + // Used by DocumentManager + public GLib.Cancellable save_cancellable; + public GLib.Cancellable load_cancellable; - private GLib.Cancellable save_cancellable; - private GLib.Cancellable load_cancellable; - private ulong onchange_handler_id = 0; // It is used to not mark files as changed on load - private bool loaded = false; + // private ulong onchange_handler_id = 0; // It is used to not mark files as changed on load private bool mounted = true; // Mount state of the file private Mount mount; @@ -144,6 +158,8 @@ namespace Scratch.Services { source_map = new Gtk.SourceMap (); outline_widget_pane = new Gtk.Paned (Gtk.Orientation.HORIZONTAL); + doc_manager = DocumentManager.get_instance (); + if (builder_blocks_font != null && builder_font_map != null) { source_map.set_font_map (builder_font_map); source_map.font_desc = builder_blocks_font; @@ -156,10 +172,6 @@ namespace Scratch.Services { restore_settings (); settings.changed.connect (restore_settings); - /* Block user editing while working */ - source_view.key_press_event.connect (() => { - return working; - }); var source_grid = new Gtk.Grid () { orientation = Gtk.Orientation.HORIZONTAL, @@ -179,28 +191,21 @@ namespace Scratch.Services { this.source_view.buffer.create_tag ("highlight_search_all", "background", "yellow", null); - toggle_changed_handlers (true); - - // Focus out event for SourceView - this.source_view.focus_out_event.connect (() => { - if (Scratch.settings.get_boolean ("autosave")) { - save.begin (); - } - - return false; + source_view.buffer.modified_changed.connect ((buffer) => { + set_saved_status (); + check_undoable_actions (); }); source_view.buffer.changed.connect (() => { - if (source_view.buffer.text != last_save_content) { - saved = false; - if (!Scratch.settings.get_boolean ("autosave")) { - set_saved_status (false); - } - } else { - set_saved_status (true); - } + // May need to wait for completion to close + // which would otherwise inhibit saving + Idle.add (() => { + doc_manager.save_request.begin ( + this, SaveReason.AUTOSAVE + ); + return Source.REMOVE; + }); }); - source_view.completion.show.connect (() => { completion_shown = true; }); @@ -214,42 +219,14 @@ namespace Scratch.Services { ellipsize_mode = Pango.EllipsizeMode.MIDDLE; } - public void toggle_changed_handlers (bool enabled) { - if (enabled && onchange_handler_id == 0) { - onchange_handler_id = this.source_view.buffer.changed.connect (() => { - if (onchange_handler_id != 0) { - this.source_view.buffer.disconnect (onchange_handler_id); - } - - // Signals for SourceView - uint timeout_saving = 0; - check_undoable_actions (); - onchange_handler_id = source_view.buffer.changed.connect (() => { - check_undoable_actions (); - // Save if autosave is ON - if (Scratch.settings.get_boolean ("autosave")) { - if (timeout_saving > 0) { - Source.remove (timeout_saving); - timeout_saving = 0; - } - timeout_saving = Timeout.add (1000, () => { - save.begin (); - timeout_saving = 0; - return false; - }); - } - }); - }); - } else if (!enabled && onchange_handler_id != 0) { - this.source_view.buffer.disconnect (onchange_handler_id); - onchange_handler_id = 0; - } - } private uint load_timout_id = 0; public async void open (bool force = false) { /* Loading improper files may hang so we cancel after a certain time as a fallback. - * In most cases, an error will be thrown and caught. */ + * In most cases, an error will be thrown and caught. */ + if (loaded) { + focus_in_event.disconnect (on_focus_in); + } loaded = false; if (load_cancellable != null) { /* just in case */ load_cancellable.cancel (); @@ -269,9 +246,8 @@ namespace Scratch.Services { source_view.sensitive = false; this.working = true; - + // Check whether it is a text file var content_type = ContentType.from_mime_type (mime_type); - if (!force && !(ContentType.is_a (content_type, "text/plain"))) { var title = _("%s Is Not a Text File").printf (get_basename ()); var description = _("Code will not load this type of file."); @@ -294,7 +270,7 @@ namespace Scratch.Services { } var buffer = new Gtk.SourceBuffer (null); /* Faster to load into a separate buffer */ - + // Set time limit on loading the file load_timout_id = Timeout.add_seconds_full (GLib.Priority.HIGH, 5, () => { if (load_cancellable != null && !load_cancellable.is_cancelled ()) { var title = _("Loading File \"%s\" Is Taking a Long Time").printf (get_basename ()); @@ -317,6 +293,7 @@ namespace Scratch.Services { return GLib.Source.REMOVE; }); + //Try to load the file try { var source_file_loader = new Gtk.SourceFileLoader (buffer, source_file); yield source_file_loader.load_async (GLib.Priority.LOW, load_cancellable, null); @@ -342,21 +319,16 @@ namespace Scratch.Services { } } - // Focus in event for SourceView - this.source_view.focus_in_event.connect (() => { - check_file_status (); - check_undoable_actions (); - return false; - }); // Change syntax highlight this.source_view.change_syntax_highlight_from_file (this.file); source_view.buffer.set_modified (false); original_content = source_view.buffer.text; - last_save_content = source_view.buffer.text; - set_saved_status (true); + last_save_content = original_content; + set_saved_status (); + check_undoable_actions (); doc_opened (); source_view.sensitive = true; @@ -367,142 +339,91 @@ namespace Scratch.Services { Idle.add (() => { working = false; loaded = true; + // Check file status etc + on_focus_in (); + // File status rechecked on every focus in + focus_in_event.connect (on_focus_in); return false; }); return; } - public bool do_close (bool app_closing = false) { + public async bool do_close (bool app_closing) { debug ("Closing \"%s\"", get_basename ()); - if (!loaded) { load_cancellable.cancel (); return true; } - bool ret_value = true; - if (Scratch.settings.get_boolean ("autosave") && !saved) { - save_with_hold (); - } else if (app_closing && is_file_temporary && !delete_temporary_file ()) { - debug ("Save temporary file!"); - save_with_hold (); - } - // Check for unsaved changes - else if (!this.saved || (!app_closing && is_file_temporary && !delete_temporary_file ())) { - var parent_window = source_view.get_toplevel () as Gtk.Window; - - var dialog = new Granite.MessageDialog ( - _("Save changes to \"%s\" before closing?").printf (this.get_basename ()), - _("If you don't save, changes will be permanently lost."), - new ThemedIcon ("dialog-warning"), - Gtk.ButtonsType.NONE - ); - dialog.transient_for = parent_window; - - var no_save_button = (Gtk.Button) dialog.add_button (_("Close Without Saving"), Gtk.ResponseType.NO); - no_save_button.get_style_context ().add_class (Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION); - - dialog.add_button (_("Cancel"), Gtk.ResponseType.CANCEL); - dialog.add_button (_("Save"), Gtk.ResponseType.YES); - dialog.set_default_response (Gtk.ResponseType.YES); - - int response = dialog.run (); - switch (response) { - case Gtk.ResponseType.CANCEL: - case Gtk.ResponseType.DELETE_EVENT: - ret_value = false; - break; - case Gtk.ResponseType.YES: - if (this.is_file_temporary) - save_as_with_hold (); - else - save_with_hold (); - break; - case Gtk.ResponseType.NO: - if (this.is_file_temporary) - delete_temporary_file (true); - break; - } - dialog.destroy (); - } - - if (ret_value) { - // Delete backup copy file - delete_backup (); + var reason = app_closing ? SaveReason.APP_CLOSING : SaveReason.TAB_CLOSING; + if (yield doc_manager.save_request (this, reason)) { + // DocumentManager will delete any backup doc_closed (); + return true; } - return ret_value; + return false; } - public bool save_with_hold (bool force = false) { - GLib.Application.get_default ().hold (); - bool result = false; - save.begin (force, (obj, res) => { - result = save.end (res); - GLib.Application.get_default ().release (); - }); - - return result; + public async bool save () { + return yield doc_manager.save_request (this, SaveReason.USER_REQUEST); } - public bool save_as_with_hold () { - GLib.Application.get_default ().hold (); - bool result = false; - save_as.begin ((obj, res) => { - result = save_as.end (res); - GLib.Application.get_default ().release (); - }); - - return result; - } - - public async bool save (bool force = false) { - if (completion_shown || - !force && (source_view.buffer.get_modified () == false || - !loaded)) { - - return false; - } - - this.create_backup (); - - if (Scratch.settings.get_boolean ("strip-trailing-on-save") && force) { - strip_trailing_spaces (); - } + public async bool save_as () { + var new_uri = get_save_as_uri (); + assert_nonnull (new_uri); + if (new_uri != "") { + var old_uri = file.get_uri (); + var was_temporary = is_file_temporary; + file = GLib.File.new_for_uri (new_uri); + if (!(yield doc_manager.save_request ( + this, SaveReason.USER_REQUEST))) { + // Revert to original location if save failed or cancelled + file = GLib.File.new_for_uri (old_uri); + string message = _( + "You cannot save to the file \"%s\".\n Do you want to save the changes somewhere else?" + ).printf ("%s".printf (new_uri)); + + set_message ( + Gtk.MessageType.WARNING, + message, + _("Save changes elsewhere"), + () => { + save_as.begin ((obj, res) => { + if (save_as.end (res)) { + hide_info_bar (); + } + }); + }, + _("Cancel"), () => { + hide_info_bar (); + } + ); - // Replace old content with the new one - save_cancellable.cancel (); - save_cancellable = new GLib.Cancellable (); - var source_file_saver = new Gtk.SourceFileSaver ((Gtk.SourceBuffer) source_view.buffer, source_file); - try { - yield source_file_saver.save_async (GLib.Priority.DEFAULT, save_cancellable, null); - } catch (Error e) { - // We don't need to send an error message at cancellation (corresponding to error code 19) - if (e.code != 19) - warning ("Cannot save \"%s\": %s", get_basename (), e.message); + return false; + } else if (was_temporary) { + //Need to delete old temp file and backup + var temp_file = GLib.File.new_for_uri (old_uri); + //TODO DRY this (also in DocumentManager) + try { + temp_file.delete (); + } catch (Error e) { + //TODO Inform user in UI? + warning ("Cannot delete temporary file \"%s\": %s", old_uri, e.message); + } + } + return true; + } else { + warning ("Save As: Failed to get new uri"); return false; } - - source_view.buffer.set_modified (false); - - if (outline != null) { - outline.parse_symbols (); - } - - this.set_saved_status (true); - last_save_content = source_view.buffer.text; - - debug ("File \"%s\" saved successfully", get_basename ()); - - return true; } - public async bool save_as () { - // New file + public string get_save_as_uri () { + // Get new path to save to from user if (!loaded) { - return false; + return ""; } var all_files_filter = new Gtk.FileFilter (); @@ -523,48 +444,19 @@ namespace Scratch.Services { file_chooser.add_filter (all_files_filter); file_chooser.add_filter (text_files_filter); file_chooser.do_overwrite_confirmation = true; - file_chooser.set_current_folder_uri (Utils.last_path ?? GLib.Environment.get_home_dir ()); - - var success = false; - var current_file = file.get_path (); - var is_current_file_temporary = this.is_file_temporary; + file_chooser.set_current_folder_uri ( + Utils.last_path ?? GLib.Environment.get_home_dir () + ); + var new_path = ""; if (file_chooser.run () == Gtk.ResponseType.ACCEPT) { - file = File.new_for_uri (file_chooser.get_uri ()); // Update last visited path - Utils.last_path = Path.get_dirname (file_chooser.get_file ().get_uri ()); - success = true; - } - - if (success) { - source_view.buffer.set_modified (true); - var is_saved = yield save (true); - - if (is_saved && is_current_file_temporary) { - try { - // Delete temporary file - File.new_for_path (current_file).delete (); - } catch (Error err) { - warning ("Temporary file cannot be deleted: %s", current_file); - } - } - - delete_backup (current_file + "~"); - this.source_view.change_syntax_highlight_from_file (this.file); + new_path = file_chooser.get_file ().get_uri (); + Utils.last_path = Path.get_dirname (new_path); } - /* We delay destruction of file chooser dialog til to avoid the document focussing in, - * which triggers premature loading of overwritten content. - */ file_chooser.destroy (); - return success; - } - - public bool move (File new_dest) { - this.file = new_dest; - this.save.begin (); - - return true; + return new_path; } private void restore_settings () { @@ -576,10 +468,6 @@ namespace Scratch.Services { source_map.no_show_all = true; scroll.vscrollbar_policy = Gtk.PolicyType.AUTOMATIC; } - - if (Scratch.settings.get_boolean ("strip-trailing-on-save")) { - strip_trailing_spaces (); - } } // Focus the SourceView @@ -594,6 +482,7 @@ namespace Scratch.Services { // Get file name public string get_basename () { + assert_nonnull (file); if (is_file_temporary) { return _("New Document"); } else { @@ -611,9 +500,13 @@ namespace Scratch.Services { } // Set InfoBars message - public void set_message (Gtk.MessageType type, string label, - string? button1 = null, owned VoidFunc? callback1 = null, - string? button2 = null, owned VoidFunc? callback2 = null) { + public void set_message ( + Gtk.MessageType type, string label, + string? button1 = null, + owned VoidFunc? callback1 = null, + string? button2 = null, + owned VoidFunc? callback2 = null + ) { // Show InfoBar info_bar.no_show_all = false; @@ -665,27 +558,32 @@ namespace Scratch.Services { // Hide InfoBar when not needed public void hide_info_bar () { + source_view.focus_in_event.disconnect (on_focus_in); info_bar.no_show_all = true; info_bar.visible = false; + Idle.add (() => { + source_view.focus_in_event.connect (on_focus_in); + return Source.REMOVE; + }); } // SourceView related functions // Undo public void undo () { this.source_view.undo (); - check_undoable_actions (); } // Redo public void redo () { this.source_view.redo (); - check_undoable_actions (); } // Revert public void revert () { this.source_view.set_text (original_content, false); + source_view.buffer.set_modified (false); check_undoable_actions (); + set_saved_status (); } // Get text @@ -735,9 +633,21 @@ namespace Scratch.Services { main_stack.set_visible_child (alert_view); } + private bool on_focus_in () { + // Ignore if saving underway. DocumentManager will perform same + // operations when finished. + if (!working) { + check_file_status (); + check_undoable_actions (); + } + + return false; + } // Check if the file was deleted/changed by an external source + // Called on focus in and after failed saving public void check_file_status () { // If the file does not exist anymore + assert (!working); if (!exists ()) { if (mounted == false) { string message = _( @@ -745,8 +655,11 @@ namespace Scratch.Services { ).printf ("%s".printf (get_basename ())); set_message (Gtk.MessageType.WARNING, message, _("Save As…"), () => { - this.save_as.begin (); - hide_info_bar (); + save_as.begin ((obj, res) => { + if (save_as.end (res)) { + hide_info_bar (); + } + }); }); } else { string message = _( @@ -754,8 +667,11 @@ namespace Scratch.Services { ).printf ("%s".printf (get_basename ())); set_message (Gtk.MessageType.WARNING, message, _("Save"), () => { - this.save.begin (); - hide_info_bar (); + save.begin ((obj, res) => { + if (save.end (res)) { + hide_info_bar (); + } + }); }); } @@ -771,8 +687,11 @@ namespace Scratch.Services { ).printf ("%s".printf (get_basename ())); set_message (Gtk.MessageType.WARNING, message, _("Save changes elsewhere"), () => { - this.save_as.begin (); - hide_info_bar (); + save_as.begin ((obj, res) => { + if (save_as.end (res)) { + hide_info_bar (); + } + }); }); Utils.action_from_group (MainWindow.ACTION_SAVE, actions).set_enabled (false); @@ -795,26 +714,41 @@ namespace Scratch.Services { return; } - if (source_view.buffer.text == new_buffer.text) { + if (last_save_content == new_buffer.text) { return; } +warning ("last save content not equal to new_buffer.text"); + // In case of conflict, either discard current changes and load external changes + // or continue. If continuing, the user can later rename this document to keep + // external changes or overwrite them by saving with the same name. + string message; if (!source_view.buffer.get_modified ()) { - if (Scratch.settings.get_boolean ("autosave")) { - source_view.set_text (new_buffer.text, false); - } else { - string message = _( - "File \"%s\" was modified by an external application. Do you want to load it again or continue your editing?" - ).printf ("%s".printf (get_basename ())); + message = _( +"File \"%s\" was modified by an external application.\n Do you want to load the external changes or continue and overwrite the external changes if you save this document?" + ).printf ("%s".printf (get_basename ())); + } else { + message = _( +"File \"%s\" was modified by an external application while you were also making changes.\n Do you want to load the external changes and lose your changes or continue and overwrite the external changes if you save this document?" + ).printf ("%s".printf (get_basename ())); + } - set_message (Gtk.MessageType.WARNING, message, _("Load"), () => { - this.source_view.set_text (new_buffer.text, false); - hide_info_bar (); - }, _("Continue"), () => { - hide_info_bar (); - }); + set_message (Gtk.MessageType.WARNING, message, + _("Load"), + () => { + source_view.set_text (new_buffer.text, false); + // Put in "saved" state + last_save_content = new_buffer.text; + source_view.buffer.set_modified (false); + check_undoable_actions (); + set_saved_status (); + hide_info_bar (); + }, + _("Continue"), + () => { + hide_info_bar (); } - } + ); }); } } @@ -825,17 +759,30 @@ namespace Scratch.Services { Utils.action_from_group (MainWindow.ACTION_UNDO, actions).set_enabled (source_buffer.can_undo); Utils.action_from_group (MainWindow.ACTION_REDO, actions).set_enabled (source_buffer.can_redo); Utils.action_from_group (MainWindow.ACTION_REVERT, actions).set_enabled ( - original_content != source_buffer.text + //This reverts to original loaded content, not to last saved content! + source_view.buffer.text != original_content ); } - // Set saved status - public void set_saved_status (bool val) { - this.saved = val; + // Two functions Used by SearchBar when search/replacing as well as + // DocumentManager while saving in order to prevent user changing the + // the document during critical operations, and to update things after. + public void before_undoable_change () { + source_view.set_editable (false); + } + public void after_undoable_change () { + source_view.set_editable (true); + set_saved_status (); - string unsaved_identifier = "* "; + if (outline != null) { + outline.parse_symbols (); + } + } - if (!val) { + // Show whether there are unsaved changes in the tab label + public void set_saved_status () { + string unsaved_identifier = "* "; + if (source_view.buffer.get_modified ()) { if (!(unsaved_identifier in this.label)) { tab_name = unsaved_identifier + this.label; } @@ -844,77 +791,28 @@ namespace Scratch.Services { } } - // Backup functions - private void create_backup () { - if (!can_write ()) { - return; - } - - var backup = File.new_for_path (this.file.get_path () + "~"); - - if (!backup.query_exists ()) { - try { - file.copy (backup, FileCopyFlags.NONE); - } catch (Error e) { - warning ("Cannot create backup copy for file \"%s\": %s", get_basename (), e.message); - } - } - } - - private void delete_backup (string? backup_path = null) { - string backup_file; - - if (backup_path == null) { - backup_file = file.get_path () + "~"; - } else { - backup_file = backup_path; - } - - debug ("Backup file deleting: %s", backup_file); - var backup = File.new_for_path (backup_file); - if (backup == null || !backup.query_exists ()) { - debug ("Backup file doesn't exists: %s", backup.get_path ()); - return; - } - - try { - backup.delete (); - debug ("Backup file deleted: %s", backup_file); - } catch (Error e) { - warning ("Cannot delete backup for file \"%s\": %s", get_basename (), e.message); - } - } - - private bool delete_temporary_file (bool force = false) { - if (!is_file_temporary || (get_text ().length > 0 && !force)) { - return false; - } - - try { - file.delete (); - return true; - } catch (Error e) { - warning ("Cannot delete temporary file \"%s\": %s", file.get_uri (), e.message); - } - - return false; - } - - // Return true if the file is writable - public bool can_write () { + // Return true if the file is writable. Keep testing as may change + public bool can_write (GLib.File test_file = this.file) { FileInfo info; - bool writable = false; - try { - info = this.file.query_info (FileAttribute.ACCESS_CAN_WRITE, FileQueryInfoFlags.NONE, null); - writable = info.get_attribute_boolean (FileAttribute.ACCESS_CAN_WRITE); - return writable; + info = test_file.query_info ( + FileAttribute.ACCESS_CAN_WRITE, + FileQueryInfoFlags.NONE, + null + ); + writable = info.get_attribute_boolean ( + FileAttribute.ACCESS_CAN_WRITE + ); } catch (Error e) { - warning ("query_info failed, but filename appears to be correct, allowing as new file"); - writable = true; - return writable; + critical ( + "Error determining write access: %s. Not allowing write", + e.message + ); + writable = false; } + + return writable; } // Return true if the file exists @@ -976,62 +874,5 @@ namespace Scratch.Services { text.buffer.place_cursor (iter); text.scroll_to_iter (iter, 0.0, true, 0.5, 0.5); } - - /* Pull the buffer into an array and then work out which parts are to be deleted. - * Do not strip line currently being edited unless forced */ - private void strip_trailing_spaces () { - if (!loaded || source_view.language == null) { - return; - } - - var source_buffer = (Gtk.SourceBuffer)source_view.buffer; - Gtk.TextIter iter; - - var cursor_pos = source_buffer.cursor_position; - source_buffer.get_iter_at_offset (out iter, cursor_pos); - var orig_line = iter.get_line (); - var orig_offset = iter.get_line_offset (); - - var text = source_buffer.text; - - string[] lines = Regex.split_simple ("""[\r\n]""", text); - if (lines.length == 0) { // Can legitimately happen at startup or new document - return; - } - - if (lines.length != source_buffer.get_line_count ()) { - critical ("Mismatch between line counts when stripping trailing spaces, not continuing"); - debug ("lines.length %u, buffer lines %u \n %s", lines.length, source_buffer.get_line_count (), text); - return; - } - - MatchInfo info; - Gtk.TextIter start_delete, end_delete; - Regex whitespace; - - try { - whitespace = new Regex ("[ \t]+$", 0); - } catch (RegexError e) { - critical ("Error while building regex to replace trailing whitespace: %s", e.message); - return; - } - - for (int line_no = 0; line_no < lines.length; line_no++) { - if (whitespace.match (lines[line_no], 0, out info)) { - - source_buffer.get_iter_at_line (out start_delete, line_no); - start_delete.forward_to_line_end (); - end_delete = start_delete; - end_delete.backward_chars (info.fetch (0).length); - - source_buffer.begin_not_undoable_action (); - source_buffer.@delete (ref start_delete, ref end_delete); - source_buffer.end_not_undoable_action (); - } - } - - source_buffer.get_iter_at_line_offset (out iter, orig_line, orig_offset); - source_buffer.place_cursor (iter); - } } } diff --git a/src/Services/DocumentManager.vala b/src/Services/DocumentManager.vala index 24edde5a4b..344cbf50c0 100644 --- a/src/Services/DocumentManager.vala +++ b/src/Services/DocumentManager.vala @@ -18,9 +18,24 @@ * Authored by: Jeremy Wootten */ - public class Scratch.Services.DocumentManager : Object { +public enum Scratch.SaveReason { + USER_REQUEST, + TAB_CLOSING, + APP_CLOSING, + AUTOSAVE +} +public enum Scratch.SaveStatus { + SAVED, + UNSAVED, + SAVING, + SAVE_ERROR +} + +public class Scratch.Services.DocumentManager : Object { static Gee.HashMultiMap project_restorable_docs_map; static Gee.HashMultiMap project_open_docs_map; + static Gee.HashMap doc_timeout_map; + const uint AUTOSAVE_RATE_MSEC = 1000; static DocumentManager? instance; public static DocumentManager get_instance () { @@ -34,6 +49,7 @@ static construct { project_restorable_docs_map = new Gee.HashMultiMap (); project_open_docs_map = new Gee.HashMultiMap (); + doc_timeout_map = new Gee.HashMap (); } public void make_restorable (Document doc) { @@ -73,4 +89,241 @@ public uint open_for_project (string project_path) { return project_open_docs_map.@get (project_path).size; } - } + + /* Code to manage safe saving of documents */ + /*******************************************/ + + // @force is "true" when tab or app is closing or when user activated "action-save" + // Returns "false" if operation cancelled by user + public async bool save_request (Document doc, Scratch.SaveReason reason) { + if (doc.inhibit_saving) { + return true; + } + + var autosave_on = Scratch.settings.get_boolean ("autosave"); + if (reason == SaveReason.AUTOSAVE) { + if (autosave_on) { + if (!doc_timeout_map.has_key (doc)) { + doc_timeout_map[doc] = Timeout.add (AUTOSAVE_RATE_MSEC, () => { + if (doc.delay_autosaving || doc.working) { + doc.delay_autosaving = false; + return Source.CONTINUE; + } + + // When autosaving should not need to handle errors? + start_to_save.begin (doc, reason); + doc_timeout_map.unset (doc); + return Source.REMOVE; + }); + } else { + doc.delay_autosaving = true; + } + // Do not set saved status when autosave is on + return true; + } else { + remove_autosave_for_doc (doc); + } + + doc.set_saved_status (); + return true; + } + + remove_autosave_for_doc (doc); + + bool confirm; + switch (reason) { + case USER_REQUEST: + case AUTOSAVE: // Should not come here + confirm = false; + break; + + case TAB_CLOSING: + confirm = true; + break; + + case APP_CLOSING: + confirm = false; // Always just save open docs + break; + + default: + assert_not_reached (); + } + + bool save_required = true; + // Only ask user if there are some changes or file is temporary + if (confirm && (doc.content_changed || doc.is_file_temporary)) { + if (!query_save_changes (doc, out save_required)) { + // User cancelled operation + return false; + } + } + + if (!save_required) { + if (doc.is_file_temporary) { + FileHandler.delete_file_and_backup (doc.file); + } else { + FileHandler.delete_backup (doc.file); + } + + return true; + } + + // Save even when no changes as may need to overwrite external changes + return yield start_to_save (doc, reason); + } + + private void remove_autosave_for_doc (Document doc) { + if (doc_timeout_map.has_key (doc)) { + Source.remove (doc_timeout_map[doc]); + doc_timeout_map.unset (doc); + } + } + + private async bool start_to_save (Document doc, SaveReason reason) { + //Assume buffer was editable if a save request was generated + doc.working = true; + var closing = reason == SaveReason.APP_CLOSING || + reason == SaveReason.TAB_CLOSING; + + if (reason != SaveReason.AUTOSAVE && + Scratch.settings.get_boolean ("strip-trailing-on-save")) { + + doc.before_undoable_change (); + strip_trailing_spaces_before_save (doc); + if (!closing) { + // Only call if not closing to avoid terminal errors + doc.after_undoable_change (); + } + } + + FileHandler.create_backup (doc.file); + + // Saving to the location given in the doc source file will be attempted + var is_saved = false; + var save_buffer = new Gtk.SourceBuffer (null); + var source_buffer = (Gtk.SourceBuffer)(doc.source_view.buffer); + save_buffer.text = source_buffer.text; + + // Replace old content with the new one + //TODO Handle cancellables internally + doc.save_cancellable.cancel (); + doc.save_cancellable = new GLib.Cancellable (); + var source_file_saver = new Gtk.SourceFileSaver ( + source_buffer, + doc.source_file + ); + + if (reason == SaveReason.APP_CLOSING) { + GLib.Application.get_default ().hold (); + } + + try { + is_saved = yield source_file_saver.save_async ( + GLib.Priority.DEFAULT, + doc.save_cancellable, + null + ); + + if (is_saved) { + doc.last_save_content = save_buffer.text; + FileHandler.delete_backup (doc.file); + } + } catch (Error e) { + if (e.code != 19) { // Not cancelled + critical ( + "Cannot save \"%s\": %s", + doc.get_basename (), + e.message + ); + } + } finally { + doc.working = false; + doc.set_saved_status (); + + if (reason == SaveReason.APP_CLOSING) { + GLib.Application.get_default ().release (); + } + } + + //NOTE If save failed, the backup file remains on disk, but is not used + return is_saved; + } + + // This is only called once but is split out for clarity + private void strip_trailing_spaces_before_save (Document doc) { + var source_buffer = (Gtk.SourceBuffer)(doc.source_view.buffer); + var text = source_buffer.text; + string[] lines = Regex.split_simple ("""[\r\n]""", text); + if (lines.length == 0) { // Can legitimately happen at startup or new document + return; + } + + if (lines.length != source_buffer.get_line_count ()) { + critical ("Stripping: Mismatch between line counts, not continuing"); + return; + } + + MatchInfo info; + Gtk.TextIter start_delete, end_delete; + Regex whitespace; + + try { + whitespace = new Regex ("[ \t]+$", 0); + } catch (RegexError e) { + critical ("Stripping: error building regex: %s", e.message); + assert_not_reached (); // Regex is constant so trap errors on dev + } + + for (int line_no = 0; line_no < lines.length; line_no++) { + if (whitespace.match (lines[line_no], 0, out info)) { + source_buffer.get_iter_at_line (out start_delete, line_no); + start_delete.forward_to_line_end (); + end_delete = start_delete; + end_delete.backward_chars (info.fetch (0).length); + source_buffer.@delete (ref start_delete, ref end_delete); + } + } + } + + // This is only called once but is split out for clarity + private bool query_save_changes (Document doc, out bool save_changes) { + var parent_window = doc.source_view.get_toplevel () as Gtk.Window; + var dialog = new Granite.MessageDialog ( + _("Save changes to \"%s\" before closing?").printf (doc.get_basename ()), + _("If you don't save, changes will be permanently lost."), + new ThemedIcon ("dialog-warning"), + Gtk.ButtonsType.NONE + ); + dialog.transient_for = parent_window; + var no_save_button = (Gtk.Button) dialog.add_button ( + _("Close Without Saving"), + Gtk.ResponseType.NO + ); + no_save_button.get_style_context ().add_class (Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION); + dialog.add_button (_("Cancel"), Gtk.ResponseType.CANCEL); + dialog.add_button (_("Save"), Gtk.ResponseType.YES); + dialog.set_default_response (Gtk.ResponseType.YES); + int response = dialog.run (); + bool close_document = false; + switch (response) { + case Gtk.ResponseType.CANCEL: + case Gtk.ResponseType.DELETE_EVENT: + save_changes = false; + close_document = false; + break; + case Gtk.ResponseType.YES: + save_changes = true; + close_document = true; + break; + case Gtk.ResponseType.NO: + save_changes = false; + close_document = true; + break; + default: + assert_not_reached (); + } + + dialog.destroy (); + return close_document; + } +} diff --git a/src/Services/FileHandler.vala b/src/Services/FileHandler.vala index 1d2b077fd9..64a092d7c7 100644 --- a/src/Services/FileHandler.vala +++ b/src/Services/FileHandler.vala @@ -19,7 +19,6 @@ ***/ namespace Scratch.Services { - public enum FileOption { EXISTS, IS_DIR, @@ -143,6 +142,37 @@ namespace Scratch.Services { else return false; } + + public static void create_backup (GLib.File file) { + //Create backup file + var backup = File.new_for_path (file.get_path () + "~"); + // Any existing file will be out of date - delete it. + delete_backup (backup); + try { + file.copy (backup, FileCopyFlags.NONE); + } catch (Error e) { + warning ( + "Cannot create backup copy for file \"%s\": %s", + file.get_basename (), + e.message + ); + //Should we return fail now? The actual save will probably fail too + } + } + + public static void delete_file_and_backup (GLib.File file) { + delete_backup (file); + try { + file.delete (); + } catch (Error e) {} + } + + public static void delete_backup (GLib.File file) { + var backup = File.new_for_path (file.get_path () + "~"); + try { + backup.delete (); + } catch (Error e) {} + } } } diff --git a/src/Widgets/DocumentView.vala b/src/Widgets/DocumentView.vala index 30ed6016e6..5e63122e7e 100644 --- a/src/Widgets/DocumentView.vala +++ b/src/Widgets/DocumentView.vala @@ -2,7 +2,7 @@ /*** BEGIN LICENSE - Copyright (C) 2013 Mario Guerriero + Copyright (C) 2023 elementary LLC. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License version 3, as published by the Free Software Foundation. @@ -69,17 +69,22 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { }); close_tab_requested.connect ((tab) => { - var document = tab as Services.Document; - if (!document.is_file_temporary && document.file != null) { - tab.restore_data = document.get_uri (); - } + var doc = tab as Services.Document; + doc.do_close.begin (false, (obj, res) => { + if (doc.do_close.end (res)) { + if (!doc.is_file_temporary && doc.file != null) { + tab.restore_data = doc.get_uri (); + remove_tab (doc); + } + } + }); - return document.do_close (); + return true; }); tab_switched.connect ((old_tab, new_tab) => { /* The 'document_change' signal is emitted when the document is focused. We do not need to emit it here */ - save_focused_document_uri (new_tab as Services.Document); + remember_focused_document_uri (new_tab as Services.Document); }); tab_restored.connect ((label, restore_data, icon) => { @@ -175,7 +180,6 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { private void insert_document (Scratch.Services.Document doc, int pos) { insert_tab (doc, pos); if (Scratch.saved_state.get_boolean ("outline-visible")) { - warning ("setting outline visible"); doc.show_outline (true); } } @@ -191,7 +195,7 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { current_document = doc; doc.focus (); - save_opened_files (); + remember_opened_files (); } catch (Error e) { critical (e.message); } @@ -211,7 +215,7 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { current_document = doc; doc.focus (); - save_opened_files (); + remember_opened_files (); } catch (Error e) { critical ("Cannot insert clipboard: %s", clipboard); } @@ -249,7 +253,8 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { if (cursor_position > 0) { doc.source_view.cursor_position = cursor_position; } - save_opened_files (); + + remember_opened_files (); }); return false; @@ -265,9 +270,7 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { var doc = new Services.Document (window.actions, file); doc.source_view.set_text (original.get_text ()); doc.source_view.language = original.source_view.language; - if (Scratch.settings.get_boolean ("autosave")) { - doc.save.begin (true); - } + // Document will be either autosaved or treeted as unsaved document insert_document (doc, -1); current_document = doc; @@ -304,17 +307,12 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { } public void close_document (Services.Document doc) { - remove_tab (doc); - doc.do_close (); + close_tab_requested (doc); } public void close_current_document () { var doc = current_document; - if (doc != null) { - if (close_tab_requested (doc)) { - remove_tab (doc); - } - } + close_tab_requested (doc); } public void request_placeholder_if_empty () { @@ -380,7 +378,7 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { } if (!is_closing) { - save_opened_files (); + remember_opened_files (); } } @@ -408,7 +406,7 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { doc.focus (); - save_opened_files (); + remember_opened_files (); } private bool on_focus_in_event () { @@ -442,7 +440,7 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { } } - public void save_opened_files () { + public void remember_opened_files () { if (privacy_settings.get_boolean ("remember-recent-files")) { var vb = new VariantBuilder (new VariantType ("a(si)")); tabs.foreach ((tab) => { @@ -456,7 +454,7 @@ public class Scratch.Widgets.DocumentView : Granite.Widgets.DynamicNotebook { } } - private void save_focused_document_uri (Services.Document? current_document) { + private void remember_focused_document_uri (Services.Document? current_document) { if (privacy_settings.get_boolean ("remember-recent-files")) { var file_uri = ""; diff --git a/src/Widgets/SearchBar.vala b/src/Widgets/SearchBar.vala index 52e5a2dd4a..37da93b035 100644 --- a/src/Widgets/SearchBar.vala +++ b/src/Widgets/SearchBar.vala @@ -245,7 +245,8 @@ namespace Scratch.Widgets { } string replace_string = replace_entry.text; - this.window.get_current_document ().toggle_changed_handlers (false); + this.window.get_current_document ().before_undoable_change (); + // this.window.get_current_document ().toggle_changed_handlers (false); try { cancel_update_search_occurence_label (); search_context.replace_all (replace_string, replace_string.length); @@ -256,7 +257,8 @@ namespace Scratch.Widgets { critical (e.message); } - this.window.get_current_document ().toggle_changed_handlers (true); + // this.window.get_current_document ().toggle_changed_handlers (true); + this.window.get_current_document ().after_undoable_change (); } private void on_search_entry_text_changed () {