github.com/aretext/aretext@v1.3.0/state/document.go (about) 1 package state 2 3 import ( 4 "errors" 5 "fmt" 6 "io/fs" 7 "log" 8 "os" 9 "path/filepath" 10 "sort" 11 "strings" 12 13 "github.com/aretext/aretext/config" 14 "github.com/aretext/aretext/file" 15 "github.com/aretext/aretext/locate" 16 "github.com/aretext/aretext/menu" 17 "github.com/aretext/aretext/syntax" 18 "github.com/aretext/aretext/text" 19 "github.com/aretext/aretext/undo" 20 ) 21 22 // NewDocument opens a new document at the given path. 23 // Returns an error if the file already exists or the directory doesn't exist. 24 // This won't create a new file on disk until the user saves it. 25 func NewDocument(state *EditorState, path string) error { 26 err := file.ValidateCreate(path) 27 if err != nil { 28 return err 29 } 30 // Initialize the editor with the file path. 31 // This won't create the file on disk until the user saves it. 32 // It's possible that some other process created a file at the path 33 // before or after it's loaded, but this is handled gracefully. 34 LoadDocument(state, path, false, func(_ LocatorParams) uint64 { return 0 }) 35 return nil 36 } 37 38 // RenameDocument moves a document to a different file path. 39 // Returns an error if the file already exists or the directory doesn't exist. 40 func RenameDocument(state *EditorState, newPath string) error { 41 // Validate that we can create a file at the new path. 42 // This isn't 100% reliable, since some other process could create a file 43 // at the target path between this check and the rename below, but it at least 44 // reduces the risk of overwriting another file. 45 err := file.ValidateCreate(newPath) 46 if err != nil { 47 return err 48 } 49 50 // Move the file on disk. Ignore fs.ErrNotExist which can happen if 51 // the file was never saved to the old path. 52 // 53 // The rename won't trigger a reload of the old document because: 54 // 1. file.Watcher's check loop ignores fs.ErrNotExist. 55 // 2. LoadDocument below starts a new file watcher, so the main event loop 56 // won't check the old file.Watcher's changed channel anyway. 57 oldPath := state.fileWatcher.Path() 58 err = os.Rename(oldPath, newPath) 59 if err != nil && !errors.Is(err, fs.ErrNotExist) { 60 return err 61 } 62 63 // Load the document at the new path, retaining the original cursor position. 64 cursorPos := state.documentBuffer.cursor.position 65 LoadDocument(state, newPath, false, func(_ LocatorParams) uint64 { return cursorPos }) 66 return nil 67 } 68 69 // LoadDocument loads a file into the editor. 70 func LoadDocument(state *EditorState, path string, requireExists bool, cursorLoc Locator) { 71 timelineState := currentTimelineState(state) 72 fileExists, err := loadDocumentAndResetState(state, path, requireExists) 73 if err != nil { 74 // If this is the first document loaded into the editor, set a watcher 75 // even if the load failed. This retains the attempted path so the user 76 // can try saving or reloading the document later. 77 if state.fileWatcher.Path() == "" { 78 state.fileWatcher = file.NewWatcherForNewFile(file.DefaultPollInterval, path) 79 } 80 81 reportLoadError(state, err, path) 82 return 83 } 84 85 if !timelineState.Empty() { 86 state.fileTimeline.TransitionFrom(timelineState) 87 } 88 89 setCursorAfterLoad(state, cursorLoc) 90 91 if fileExists { 92 reportOpenSuccess(state, path) 93 } else { 94 reportCreateSuccess(state, path) 95 } 96 } 97 98 // ReloadDocument reloads the current document. 99 func ReloadDocument(state *EditorState) { 100 path := state.fileWatcher.Path() 101 102 // Store the configuration we want to preserve. 103 oldTextTree := state.documentBuffer.textTree 104 oldText := oldTextTree.String() 105 oldTextOriginLineNum := oldTextTree.LineNumForPosition(state.documentBuffer.view.textOrigin) 106 oldCursorLineNum, oldCursorCol := locate.PosToLineNumAndCol(oldTextTree, state.documentBuffer.cursor.position) 107 oldSearch := state.documentBuffer.search 108 oldAutoIndent := state.documentBuffer.autoIndent 109 oldShowTabs := state.documentBuffer.showTabs 110 oldShowSpaces := state.documentBuffer.showSpaces 111 oldShowLineNum := state.documentBuffer.showLineNum 112 oldLineNumberMode := state.documentBuffer.lineNumberMode 113 114 // Reload the document. 115 _, err := loadDocumentAndResetState(state, path, true) 116 if err != nil { 117 reportLoadError(state, err, path) 118 return 119 } 120 121 // Attempt to restore the original cursor and scroll positions, aligned to the new document. 122 newTextTree := state.documentBuffer.textTree 123 newTreeReader := newTextTree.ReaderAtPosition(0) 124 oldReader := strings.NewReader(oldText) 125 lineMatches, err := text.Align(oldReader, &newTreeReader) 126 if err != nil { 127 panic(err) // Should never happen since we're reading from in-memory strings. 128 } 129 state.documentBuffer.cursor.position = locate.LineNumAndColToPos( 130 newTextTree, 131 translateLineNum(lineMatches, oldCursorLineNum), 132 oldCursorCol, 133 ) 134 state.documentBuffer.view.textOrigin = newTextTree.LineStartPosition( 135 translateLineNum(lineMatches, oldTextOriginLineNum), 136 ) 137 ScrollViewToCursor(state) 138 139 // Restore search query, direction, and history. 140 state.documentBuffer.search = searchState{ 141 query: oldSearch.query, 142 direction: oldSearch.direction, 143 history: oldSearch.history, 144 } 145 146 // Restore other configuration that might have been toggled with menu commands. 147 state.documentBuffer.autoIndent = oldAutoIndent 148 state.documentBuffer.showTabs = oldShowTabs 149 state.documentBuffer.showSpaces = oldShowSpaces 150 state.documentBuffer.showLineNum = oldShowLineNum 151 state.documentBuffer.lineNumberMode = oldLineNumberMode 152 153 reportReloadSuccess(state, path) 154 } 155 156 func translateLineNum(lineMatches []text.LineMatch, lineNum uint64) uint64 { 157 matchIdx := sort.Search(len(lineMatches), func(i int) bool { 158 return lineMatches[i].LeftLineNum >= lineNum 159 }) 160 161 if matchIdx < len(lineMatches) && lineMatches[matchIdx].LeftLineNum == lineNum { 162 alignedLineNum := lineMatches[matchIdx].RightLineNum 163 log.Printf("Aligned line %d in old document with line %d in new document\n", lineNum, alignedLineNum) 164 return lineMatches[matchIdx].RightLineNum 165 } else { 166 log.Printf("Could not find alignment for line number %d\n", lineNum) 167 return lineNum 168 } 169 } 170 171 // LoadPrevDocument loads the previous document from the timeline in the editor. 172 // The cursor is moved to the start of the line from when the document was last open. 173 func LoadPrevDocument(state *EditorState) { 174 prev := state.fileTimeline.PeekBackward() 175 if prev.Empty() { 176 SetStatusMsg(state, StatusMsg{ 177 Style: StatusMsgStyleError, 178 Text: "No previous document to open", 179 }) 180 return 181 } 182 183 timelineState := currentTimelineState(state) 184 path := prev.Path 185 _, err := loadDocumentAndResetState(state, path, false) 186 if err != nil { 187 reportLoadError(state, err, path) 188 return 189 } 190 191 state.fileTimeline.TransitionBackwardFrom(timelineState) 192 setCursorAfterLoad(state, func(p LocatorParams) uint64 { 193 return locate.LineNumAndColToPos(p.TextTree, prev.LineNum, prev.Col) 194 }) 195 reportOpenSuccess(state, path) 196 } 197 198 // LoadNextDocument loads the next document from the timeline in the editor. 199 // The cursor is moved to the start of the line from when the document was last open. 200 func LoadNextDocument(state *EditorState) { 201 next := state.fileTimeline.PeekForward() 202 if next.Empty() { 203 SetStatusMsg(state, StatusMsg{ 204 Style: StatusMsgStyleError, 205 Text: "No next document to open", 206 }) 207 return 208 } 209 210 timelineState := currentTimelineState(state) 211 path := next.Path 212 _, err := loadDocumentAndResetState(state, path, false) 213 if err != nil { 214 reportLoadError(state, err, path) 215 return 216 } 217 218 state.fileTimeline.TransitionForwardFrom(timelineState) 219 setCursorAfterLoad(state, func(p LocatorParams) uint64 { 220 return locate.LineNumAndColToPos(p.TextTree, next.LineNum, next.Col) 221 }) 222 reportOpenSuccess(state, path) 223 } 224 225 func currentTimelineState(state *EditorState) file.TimelineState { 226 buffer := state.documentBuffer 227 lineNum, col := locate.PosToLineNumAndCol(buffer.textTree, buffer.cursor.position) 228 return file.TimelineState{ 229 Path: state.fileWatcher.Path(), 230 LineNum: lineNum, 231 Col: col, 232 } 233 } 234 235 func loadDocumentAndResetState(state *EditorState, path string, requireExists bool) (fileExists bool, err error) { 236 cfg := state.configRuleSet.ConfigForPath(path) 237 tree, watcher, err := file.Load(path, file.DefaultPollInterval) 238 if errors.Is(err, fs.ErrNotExist) && !requireExists { 239 tree = text.NewTree() 240 watcher = file.NewWatcherForNewFile(file.DefaultPollInterval, path) 241 } else if err != nil { 242 return false, err 243 } else { 244 fileExists = true 245 } 246 247 CancelTaskIfRunning(state) 248 state.documentLoadCount++ 249 state.documentBuffer.textTree = tree 250 state.fileWatcher.Stop() 251 state.fileWatcher = watcher 252 state.inputMode = InputModeNormal 253 state.documentBuffer.cursor = cursorState{} 254 state.documentBuffer.view.textOrigin = 0 255 state.documentBuffer.selector.Clear() 256 state.documentBuffer.search = searchState{} 257 state.documentBuffer.tabSize = uint64(cfg.TabSize) // safe b/c we validated the config. 258 state.documentBuffer.tabExpand = cfg.TabExpand 259 state.documentBuffer.showTabs = cfg.ShowTabs 260 state.documentBuffer.showSpaces = cfg.ShowSpaces 261 state.documentBuffer.autoIndent = cfg.AutoIndent 262 state.documentBuffer.showLineNum = cfg.ShowLineNumbers 263 state.documentBuffer.lineNumberMode = config.LineNumberMode(cfg.LineNumberMode) 264 state.documentBuffer.lineWrapAllowCharBreaks = bool(cfg.LineWrap == config.LineWrapCharacter) 265 state.documentBuffer.undoLog = undo.NewLog() 266 state.menu = &MenuState{} 267 state.customMenuItems = customMenuItems(cfg) 268 state.dirPatternsToHide = cfg.HideDirectories 269 state.styles = cfg.Styles 270 setSyntaxAndRetokenize(state.documentBuffer, syntax.Language(cfg.SyntaxLanguage)) 271 272 return fileExists, nil 273 } 274 275 func setCursorAfterLoad(state *EditorState, cursorLoc Locator) { 276 // First, scroll to the last line. 277 MoveCursor(state, func(p LocatorParams) uint64 { 278 return locate.StartOfLastLine(p.TextTree) 279 }) 280 ScrollViewToCursor(state) 281 282 // Then, scroll up to the target location. 283 // This ensures that the target line appears near the top 284 // of the view instead of near the bottom. 285 MoveCursor(state, cursorLoc) 286 ScrollViewToCursor(state) 287 } 288 289 func customMenuItems(cfg config.Config) []menu.Item { 290 // Deduplicate commands with the same name. 291 // Later commands take priority. 292 uniqueItemMap := make(map[string]menu.Item, len(cfg.MenuCommands)) 293 for _, cmd := range cfg.MenuCommands { 294 uniqueItemMap[cmd.Name] = menu.Item{ 295 Name: cmd.Name, 296 Action: actionForCustomMenuItem(cmd), 297 } 298 } 299 300 // Convert the map to a slice. 301 items := make([]menu.Item, 0, len(uniqueItemMap)) 302 for _, item := range uniqueItemMap { 303 items = append(items, item) 304 } 305 306 // Sort the slice ascending by name. 307 // This isn't strictly necessary since menu search will reorder 308 // the commands based on the user's search query, but do it anyway 309 // so the output is deterministic. 310 sort.SliceStable(items, func(i, j int) bool { 311 return items[i].Name < items[j].Name 312 }) 313 314 return items 315 } 316 317 func actionForCustomMenuItem(cmd config.MenuCommandConfig) func(*EditorState) { 318 if cmd.Save { 319 return func(state *EditorState) { 320 AbortIfFileChanged(state, func(state *EditorState) { 321 SaveDocumentIfUnsavedChanges(state) 322 RunShellCmd(state, cmd.ShellCmd, cmd.Mode) 323 }) 324 } 325 } else { 326 return func(state *EditorState) { 327 RunShellCmd(state, cmd.ShellCmd, cmd.Mode) 328 } 329 } 330 } 331 332 func reportOpenSuccess(state *EditorState, path string) { 333 log.Printf("Successfully opened file from %q", path) 334 msg := fmt.Sprintf("Opened %s", file.RelativePathCwd(path)) 335 SetStatusMsg(state, StatusMsg{ 336 Style: StatusMsgStyleSuccess, 337 Text: msg, 338 }) 339 } 340 341 func reportCreateSuccess(state *EditorState, path string) { 342 log.Printf("Successfully created file at %q", path) 343 msg := fmt.Sprintf("New file %s", file.RelativePathCwd(path)) 344 SetStatusMsg(state, StatusMsg{ 345 Style: StatusMsgStyleSuccess, 346 Text: msg, 347 }) 348 } 349 350 func reportReloadSuccess(state *EditorState, path string) { 351 log.Printf("Successfully reloaded file from %q", path) 352 msg := fmt.Sprintf("Reloaded %s", file.RelativePathCwd(path)) 353 SetStatusMsg(state, StatusMsg{ 354 Style: StatusMsgStyleSuccess, 355 Text: msg, 356 }) 357 } 358 359 func reportLoadError(state *EditorState, err error, path string) { 360 log.Printf("Error loading file at %q: %v\n", path, err) 361 SetStatusMsg(state, StatusMsg{ 362 Style: StatusMsgStyleError, 363 Text: fmt.Sprintf("Could not open %q: %s", file.RelativePathCwd(path), err), 364 }) 365 } 366 367 // SaveDocument saves the currently loaded document to disk. 368 func SaveDocument(state *EditorState) { 369 path := state.fileWatcher.Path() 370 tree := state.documentBuffer.textTree 371 newWatcher, err := file.Save(path, tree, file.DefaultPollInterval) 372 if err != nil { 373 reportSaveError(state, err, path) 374 return 375 } 376 377 state.fileWatcher.Stop() 378 state.fileWatcher = newWatcher 379 state.documentBuffer.undoLog.TrackSave() 380 reportSaveSuccess(state, path) 381 } 382 383 // SaveDocumentIfUnsavedChanges saves the document only if it has been edited 384 // or the file does not exist on disk. 385 func SaveDocumentIfUnsavedChanges(state *EditorState) { 386 path := state.fileWatcher.Path() 387 _, err := os.Stat(path) 388 undoLog := state.documentBuffer.undoLog 389 if undoLog.HasUnsavedChanges() || errors.Is(err, os.ErrNotExist) { 390 SaveDocument(state) 391 } 392 } 393 394 func reportSaveError(state *EditorState, err error, path string) { 395 log.Printf("Error saving file to %q: %v", path, err) 396 SetStatusMsg(state, StatusMsg{ 397 Style: StatusMsgStyleError, 398 Text: fmt.Sprintf("Could not save %q: %s", file.RelativePathCwd(path), err), 399 }) 400 } 401 402 func reportSaveSuccess(state *EditorState, path string) { 403 log.Printf("Successfully wrote file to %q", path) 404 SetStatusMsg(state, StatusMsg{ 405 Style: StatusMsgStyleSuccess, 406 Text: fmt.Sprintf("Saved %s", path), 407 }) 408 } 409 410 const DefaultUnsavedChangesAbortMsg = `Document has unsaved changes. Either save them ("force save") or discard them ("force reload") and try again` 411 412 // AbortIfUnsavedChanges executes a function only if the document does not have unsaved changes and shows an error status msg otherwise. 413 func AbortIfUnsavedChanges(state *EditorState, abortMsg string, f func(*EditorState)) { 414 if state.documentBuffer.undoLog.HasUnsavedChanges() { 415 log.Printf("Aborting operation because document has unsaved changes\n") 416 if abortMsg != "" { 417 SetStatusMsg(state, StatusMsg{ 418 Style: StatusMsgStyleError, 419 Text: abortMsg, 420 }) 421 } 422 return 423 } 424 425 // Document has no unsaved changes, so execute the operation. 426 f(state) 427 } 428 429 // AbortIfFileChanged aborts with an error message if the file has changed on disk. 430 // Specifically, abort if the file was moved/deleted or its content checksum has changed. 431 func AbortIfFileChanged(state *EditorState, f func(*EditorState)) { 432 path := state.fileWatcher.Path() 433 filename := filepath.Base(path) 434 435 movedOrDeleted, err := state.fileWatcher.CheckFileMovedOrDeleted() 436 if err != nil { 437 log.Printf("Aborting operation because error occurred checking if file was moved or deleted: %s\n", err) 438 SetStatusMsg(state, StatusMsg{ 439 Style: StatusMsgStyleError, 440 Text: fmt.Sprintf("Could not check file: %s", err), 441 }) 442 return 443 } 444 445 if movedOrDeleted { 446 log.Printf("Aborting operation because file was moved or deleted\n") 447 SetStatusMsg(state, StatusMsg{ 448 Style: StatusMsgStyleError, 449 Text: fmt.Sprintf("File %s was moved or deleted. Use \"force save\" to save the file at the current path", filename), 450 }) 451 return 452 } 453 454 changed, err := state.fileWatcher.CheckFileContentsChanged() 455 if err != nil && !errors.Is(err, fs.ErrNotExist) { 456 log.Printf("Aborting operation because error occurred checking the file contents: %s\n", err) 457 SetStatusMsg(state, StatusMsg{ 458 Style: StatusMsgStyleError, 459 Text: fmt.Sprintf("Could not checksum file: %s", err), 460 }) 461 return 462 } 463 464 if changed { 465 log.Printf("Aborting operation because file changed on disk\n") 466 SetStatusMsg(state, StatusMsg{ 467 Style: StatusMsgStyleError, 468 Text: fmt.Sprintf("File %s has changed since last save. Use \"force save\" to overwrite.", filename), 469 }) 470 return 471 } 472 473 // All checks passed, so execute the action. 474 f(state) 475 }