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  }