| 🛟 Updated. 28d5985 k33g 17h ago | 1 | // Automatic saving: writing modified files a short while after the user stops |
| 2 | // typing, when the project has asked for it. |
| 3 | |
| 4 | package app |
| 5 | |
| 6 | import ( |
| 7 | "path/filepath" |
| 8 | "time" |
| 9 | |
| 10 | "codeberg.org/turbo-editors/turbo-core/editor" |
| 11 | "codeberg.org/turbo-editors/turbo-core/ui" |
| 12 | ) |
| 13 | |
| 14 | // autosave is the state of the pending automatic save. |
| 15 | // |
| 16 | // There is one deadline for the whole editor rather than one per window. The |
| 17 | // setting is "save a short while after you stop typing", and typing stops once |
| 18 | // — a per-window deadline would save a file the user has already moved on from |
| 19 | // at a different moment from the one they are looking at, for no gain anyone |
| 20 | // could see. |
| 21 | type autosave struct { |
| 22 | enabled bool |
| 23 | delay time.Duration |
| 24 | |
| 25 | // due is when the pending save comes due; the zero time means none is. |
| 26 | due time.Time |
| 27 | // timer nudges the event loop when the deadline arrives. It is only a |
| 28 | // nudge: the deadline above is what decides, so a nudge that goes missing |
| 29 | // costs a late save, never a lost one. |
| 30 | timer *time.Timer |
| 31 | } |
| 32 | |
| 33 | // SetAutosave turns automatic saving on or off, and says how long to wait |
| 34 | // after the last keystroke. |
| 35 | // |
| 36 | // A delay of zero or less keeps whatever delay was already set, so a project |
| 37 | // that turns autosave on without naming a delay gets the default rather than a |
| 38 | // save on every keystroke. |
| 39 | // |
| 40 | // a.SetAutosave(s.Autosave, s.AutosaveDelay) |
| 41 | func (a *App) SetAutosave(enabled bool, delay time.Duration) { |
| 42 | a.autosave.enabled = enabled |
| 43 | if delay > 0 { |
| 44 | a.autosave.delay = delay |
| 45 | } |
| 46 | if !enabled { |
| 47 | a.cancelAutosave() |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // AutosaveEnabled reports whether files are being saved automatically. |
| 52 | func (a *App) AutosaveEnabled() bool { return a.autosave.enabled } |
| 53 | |
| 54 | // noteEdit restarts the idle countdown. It runs after every edit. |
| 55 | func (a *App) noteEdit() { |
| 56 | if !a.autosave.enabled { |
| 57 | return |
| 58 | } |
| 59 | |
| 60 | a.autosave.due = a.now().Add(a.autosave.delay) |
| 61 | a.armAutosave() |
| 62 | } |
| 63 | |
| 64 | // armAutosave asks the event loop to come round again once the delay has |
| 65 | // passed, because it would otherwise sit blocked in PollEvent with no reason |
| 66 | // to wake and no way to notice that the deadline arrived. |
| 67 | func (a *App) armAutosave() { |
| 68 | if a.autosave.timer != nil { |
| 69 | a.autosave.timer.Stop() |
| 70 | } |
| 71 | a.autosave.timer = time.AfterFunc(a.autosave.delay, a.wake) |
| 72 | } |
| 73 | |
| 74 | // cancelAutosave forgets the pending save and stops the timer behind it. |
| 75 | func (a *App) cancelAutosave() { |
| 76 | a.autosave.due = time.Time{} |
| 77 | if a.autosave.timer != nil { |
| 78 | a.autosave.timer.Stop() |
| 79 | a.autosave.timer = nil |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // saveDueDocuments writes every modified file whose idle delay has run out. |
| 84 | // |
| 85 | // It runs at the top of every turn of the event loop rather than from the |
| 86 | // timer, for the same reason announceOpenDocuments does: the timer's wake-up |
| 87 | // is a PostEvent, and PostEvent drops what does not fit in its queue. State |
| 88 | // the loop checks for itself cannot go missing; a message can. |
| 89 | func (a *App) saveDueDocuments() { |
| 90 | if a.autosave.due.IsZero() || a.now().Before(a.autosave.due) { |
| 91 | return |
| 92 | } |
| 93 | |
| 94 | // Cleared before writing, not after: a file that cannot be written must |
| 95 | // not be retried every delay for as long as the editor is open. |
| 96 | a.cancelAutosave() |
| 97 | for _, window := range a.desktop.Windows() { |
| 98 | if view, ok := editorViewOf(window); ok { |
| 99 | a.autosaveOne(view) |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // autosaveOne writes one view's file, if it is one automatic saving can write. |
| 105 | // |
| 106 | // A window with no file name is left alone: naming it is a question, and a |
| 107 | // feature whose whole point is not to interrupt must not open a dialog. |
| 108 | func (a *App) autosaveOne(view *editor.View) { |
| 109 | if !view.Buffer().Modified() || view.Buffer().Path() == "" { |
| 110 | return |
| 111 | } |
| 112 | a.writeQuietly(view) |
| 113 | } |
| 114 | |
| 115 | // writeQuietly saves a view and reports on the status bar rather than in a |
| 116 | // dialog, reporting whether it worked. |
| 117 | // |
| 118 | // A modal would be wrong here twice over: nobody asked for this save, and a |
| 119 | // dialog that reappears every few seconds because a file is read-only is worse |
| 120 | // than the problem it describes. |
| 121 | func (a *App) writeQuietly(view *editor.View) bool { |
| 122 | path := view.Buffer().Path() |
| 123 | if err := view.Buffer().SaveAs(path); err != nil { |
| 124 | a.Message("Cannot save " + filepath.Base(path) + ": " + err.Error()) |
| 125 | return false |
| 126 | } |
| 127 | |
| 128 | a.afterSave(view, path) |
| 129 | return true |
| 130 | } |
| 131 | |
| 132 | // savedByAutosave writes a window's file so that closing it need not ask about |
| 133 | // unsaved work, and reports whether there is nothing left to ask about. |
| 134 | // |
| 135 | // It is false whenever there is still a real question — autosave is off, the |
| 136 | // file has never been named, or the write failed — so the caller falls back to |
| 137 | // asking rather than closing over work it could not save. |
| 138 | func (a *App) savedByAutosave(window *ui.Window) bool { |
| 139 | view, ok := editorViewOf(window) |
| 140 | if !ok || !a.autosave.enabled || !view.Buffer().Modified() { |
| 141 | return false |
| 142 | } |
| 143 | if view.Buffer().Path() == "" { |
| 144 | return false |
| 145 | } |
| 146 | return a.writeQuietly(view) |
| 147 | } |