| 🛟 Updated. 28d5985 k33g 19h ago | 1 | // Project settings: the editor's own directory, which a project keeps its |
| 2 | // editor settings in, and the menu items that create and open it. |
| 3 | |
| 4 | package app |
| 5 | |
| 6 | import ( |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | |
| 📦 Turbo Core f3ade8d k33g 12h ago | 11 | "rickub.com/turbo-editors/turbo-core/settings" |
| 12 | "rickub.com/turbo-editors/turbo-core/snippets" |
| 13 | "rickub.com/turbo-editors/turbo-core/tools" |
| 14 | "rickub.com/turbo-editors/turbo-core/ui" |
| 🛟 Updated. 28d5985 k33g 19h ago | 15 | ) |
| 16 | |
| 17 | // CreateProjectSettings writes settings.toml into the editor's own directory |
| 18 | // in the working directory, filled in with the theme currently in use, and opens it. |
| 19 | // |
| 20 | // The file is opened rather than merely created because a settings file is |
| 21 | // only useful once it has been read: creating it and saying nothing would |
| 22 | // leave the user to guess what is in it. A project that already has one is not |
| 23 | // overwritten — it is opened, which is what someone choosing this menu item |
| 24 | // twice almost certainly wanted. |
| 25 | func (a *App) CreateProjectSettings() { |
| 26 | create := func(project string) (string, error) { return settings.Create(a.profile, project, a.themeName) } |
| 27 | if path, ok := a.createProjectFile("Project settings", create, settings.ErrExists); ok { |
| 28 | a.settingsPath = path |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | // createProjectFile writes one of the project's files in the editor's own |
| 33 | // directory, reports |
| 34 | // what happened on the status bar, and opens it. |
| 35 | // |
| 36 | // The three files — settings, snippets, tools — are created by the same dance: |
| 37 | // find the working directory, write unless it is already there, say which |
| 38 | // happened, open it either way. It is written once because the second copy of |
| 39 | // it was already one too many. |
| 40 | // |
| 41 | // It returns the path and whether there is now a file there, which is true both |
| 42 | // for one just created and for one that already existed: a caller wanting to |
| 43 | // remember the path wants it in both cases. |
| 44 | func (a *App) createProjectFile(title string, create func(string) (string, error), alreadyThere error) (string, bool) { |
| 45 | project, err := os.Getwd() |
| 46 | if err != nil { |
| 47 | a.ShowMessage(title, "Cannot tell which directory this is:\n"+err.Error()) |
| 48 | return "", false |
| 49 | } |
| 50 | |
| 51 | path, err := create(project) |
| 52 | switch { |
| 53 | case errors.Is(err, alreadyThere): |
| 54 | a.Message("Already there: " + a.shortPath(path)) |
| 55 | case err != nil: |
| 56 | a.ShowMessage(title, err.Error()) |
| 57 | return "", false |
| 58 | default: |
| 59 | a.Message("Created " + a.shortPath(path)) |
| 60 | } |
| 61 | |
| 62 | a.Open(path) |
| 63 | return path, true |
| 64 | } |
| 65 | |
| 66 | // projectFile describes one of the three files a project keeps in the editor's |
| 67 | // own directory, in the terms the menus need: what to call it, where the item |
| 68 | // that writes it lives, and how to find it. |
| 69 | // |
| 70 | // The three behave identically — create it, open it, grey out whichever of the |
| 71 | // two does not apply — so the behaviour is written once and the differences are |
| 72 | // values. |
| 73 | type projectFile struct { |
| 74 | // title heads the dialog shown when there is nothing to open. |
| 75 | title string |
| 76 | // relative is how the file is named to a person: ".turbo-go/tools.toml". |
| 77 | relative string |
| 78 | // creator is the menu path of the item that writes it, so that the dialog |
| 79 | // can say where to go rather than only what is missing. |
| 80 | creator string |
| 81 | exists func(projectDir string) bool |
| 82 | path func(projectDir string) string |
| 83 | } |
| 84 | |
| 85 | // settingsFile, toolsFile and snippetsFile are the three of them. They are |
| 86 | // methods rather than package-level values because every path is built from the |
| 87 | // profile, which is per editor. |
| 88 | func (a *App) settingsFile() projectFile { |
| 89 | return projectFile{ |
| 90 | title: "Project settings", |
| 91 | relative: filepath.Join(a.profile.ProjectDir(), settings.FileName), |
| 92 | creator: "Options ▸ Create project settings", |
| 93 | exists: func(dir string) bool { return settings.Exists(a.profile, dir) }, |
| 94 | path: func(dir string) string { return settings.Path(a.profile, dir) }, |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func (a *App) toolsFile() projectFile { |
| 99 | return projectFile{ |
| 100 | title: a.toolsMenuName(), |
| 101 | relative: filepath.Join(a.profile.ProjectDir(), tools.FileName), |
| 102 | creator: a.toolsMenuName() + " ▸ " + ui.PlainLabel(createToolsLabel), |
| 103 | exists: func(dir string) bool { return tools.Exists(a.profile, dir) }, |
| 104 | path: func(dir string) string { return tools.Path(a.profile, dir) }, |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func (a *App) snippetsFile() projectFile { |
| 109 | return projectFile{ |
| 110 | title: "Snippets", |
| 111 | relative: filepath.Join(a.profile.ProjectDir(), snippets.FileName), |
| 112 | creator: "Snippets ▸ " + ui.PlainLabel(createSnippetsLabel), |
| 113 | exists: func(dir string) bool { return snippets.Exists(a.profile, dir) }, |
| 114 | path: func(dir string) string { return snippets.ProjectPath(a.profile, dir) }, |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // openProjectFile opens one of the project's own files for editing. |
| 119 | // |
| 120 | // A project without one is told so rather than having one made for it: creating |
| 121 | // is a separate item, so that choosing "open" can never put a directory into |
| 122 | // somebody's repository by itself. The item is greyed out in that case anyway — |
| 123 | // the dialog is for the caller that is not a menu. |
| 124 | func (a *App) openProjectFile(file projectFile) { |
| 125 | project, err := os.Getwd() |
| 126 | if err != nil || !file.exists(project) { |
| 127 | a.ShowMessage(file.title, "This project has no "+file.relative+" yet.\n\n"+file.creator+" makes one.") |
| 128 | return |
| 129 | } |
| 130 | a.Open(file.path(project)) |
| 131 | } |
| 132 | |
| 133 | // hasProjectFile reports whether the project has one of them. |
| 134 | func (a *App) hasProjectFile(file projectFile) bool { |
| 135 | project, err := os.Getwd() |
| 136 | return err == nil && file.exists(project) |
| 137 | } |
| 138 | |
| 139 | // OpenProjectSettings opens the project's settings file for editing. |
| 140 | // |
| 141 | // a.OpenProjectSettings() // opens .turbo-go/settings.toml |
| 142 | func (a *App) OpenProjectSettings() { a.openProjectFile(a.settingsFile()) } |
| 143 | |
| 144 | // OpenTools opens the project's tools file for editing. |
| 145 | // |
| 146 | // a.OpenTools() // opens .turbo-go/tools.toml |
| 147 | func (a *App) OpenTools() { a.openProjectFile(a.toolsFile()) } |
| 148 | |
| 149 | // OpenSnippets opens the project's snippets file for editing. |
| 150 | // |
| 151 | // The project's, not the user's: this is the file the item beside it creates, |
| 152 | // and a menu that sometimes opened one file and sometimes another would be a |
| 153 | // menu nobody could predict. |
| 154 | // |
| 155 | // a.OpenSnippets() // opens .turbo-go/snippets.toml |
| 156 | func (a *App) OpenSnippets() { a.openProjectFile(a.snippetsFile()) } |
| 157 | |
| 158 | // HasProjectSettings, HasProjectTools and HasProjectSnippets report whether the |
| 159 | // project has each file. They are what decide, for every one of the six menu |
| 160 | // items, which of the pair is greyed out: you can create the file you have not |
| 161 | // got, and open the one you have. |
| 162 | // |
| 163 | // {Label: "~C~reate tools file", Enabled: not(a.HasProjectTools)} |
| 164 | // {Label: "~O~pen tools file", Enabled: a.HasProjectTools} |
| 165 | func (a *App) HasProjectSettings() bool { return a.hasProjectFile(a.settingsFile()) } |
| 166 | |
| 167 | func (a *App) HasProjectTools() bool { return a.hasProjectFile(a.toolsFile()) } |
| 168 | |
| 169 | func (a *App) HasProjectSnippets() bool { return a.hasProjectFile(a.snippetsFile()) } |
| 170 | |
| 171 | // not turns a condition into its opposite, for the menu items that are enabled |
| 172 | // exactly when their partner is not. |
| 173 | func not(condition func() bool) func() bool { |
| 174 | return func() bool { return !condition() } |
| 175 | } |
| 176 | |
| 177 | // reapplySettings re-reads the project's settings when the file just written is |
| 178 | // them, so that a change takes effect at once rather than at the next start-up. |
| 179 | // |
| 180 | // Editing settings.toml in the editor and saving it used to do nothing until |
| 181 | // the editor was restarted: UseSettings was called once, from main, and nothing |
| 182 | // called it again. The file is recognised by its path rather than by a |
| 183 | // remembered handle, so that *creating* it counts too — a project that had no |
| 184 | // settings file has no path remembered. |
| 185 | // |
| 186 | // Only autosave is re-applied. The theme is deliberately left alone: Options ▸ |
| 187 | // Theme is the live way to change it and already writes the choice back into |
| 188 | // this file, and a -theme flag given on the command line is the more explicit |
| 189 | // statement of the two for the session it was given in. |
| 190 | func (a *App) reapplySettings(path string) { |
| 191 | project, err := os.Getwd() |
| 192 | if err != nil || !samePath(path, settings.Path(a.profile, project)) { |
| 193 | return |
| 194 | } |
| 195 | |
| 196 | loaded, err := settings.Load(a.profile, project) |
| 197 | if err != nil { |
| 198 | // Saved but not in force, which is a different thing from either |
| 199 | // "saved" or "cannot save", and the only one of the three that leaves |
| 200 | // the editor behaving unlike the file in front of you. |
| 201 | a.Message("Saved, but not applied: " + err.Error()) |
| 202 | return |
| 203 | } |
| 204 | |
| 205 | a.settingsPath = settings.Path(a.profile, project) |
| 206 | a.SetAutosave(loaded.Autosave, loaded.AutosaveDelay) |
| 207 | a.Message("Applied " + a.shortPath(path) + " — " + describeAutosave(loaded)) |
| 208 | } |
| 209 | |
| 210 | // describeAutosave says what a settings file has just asked for, in the few |
| 211 | // words a status bar has room for. |
| 212 | // |
| 213 | // describeAutosave(settings.Settings{Autosave: true, AutosaveDelay: 2 * time.Second}) |
| 214 | // // "autosave on (2s)" |
| 215 | func describeAutosave(s settings.Settings) string { |
| 216 | if !s.Autosave { |
| 217 | return "autosave off" |
| 218 | } |
| 219 | return "autosave on (" + s.AutosaveDelay.String() + ")" |
| 220 | } |
| 221 | |
| 222 | // samePath reports whether two paths name the same file, comparing them as |
| 223 | // absolute paths so that "./.turbo-go/settings.toml" and the absolute spelling |
| 224 | // of it are recognised as one file. |
| 225 | func samePath(one, other string) bool { |
| 226 | oneAbs, err := filepath.Abs(one) |
| 227 | if err != nil { |
| 228 | return one == other |
| 229 | } |
| 230 | otherAbs, err := filepath.Abs(other) |
| 231 | if err != nil { |
| 232 | return one == other |
| 233 | } |
| 234 | return oneAbs == otherAbs |
| 235 | } |
| 236 | |
| 237 | // rememberTheme writes the theme into the project's settings file, when the |
| 238 | // project has one. |
| 239 | // |
| 240 | // A project with no settings file is left without one: creating it is an |
| 241 | // explicit choice, and a theme picked to try it out should not put a new |
| 242 | // directory into someone's repository. |
| 243 | func (a *App) rememberTheme(name string) { |
| 244 | if a.settingsPath == "" { |
| 245 | return |
| 246 | } |
| 247 | if err := settings.SetTheme(a.settingsPath, name); err != nil { |
| 248 | a.Message("Theme set for this session only: " + err.Error()) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // shortPath returns a path relative to the working directory when it is under |
| 253 | // it, because ".turbo-go/settings.toml" reads better in a status bar than the |
| 254 | // absolute path of the same file. |
| 255 | func (a *App) shortPath(path string) string { |
| 256 | working, err := os.Getwd() |
| 257 | if err != nil { |
| 258 | return path |
| 259 | } |
| 260 | if relative, err := filepath.Rel(working, path); err == nil && !filepath.IsAbs(relative) { |
| 261 | return relative |
| 262 | } |
| 263 | return path |
| 264 | } |