feat: created (tested) program_builder.
All checks were successful
Run Test Suite / test (push) Successful in 15s
All checks were successful
Run Test Suite / test (push) Successful in 15s
Also adjusted some of the IO tests for writing and force writing.
This commit is contained in:
parent
8364d8b880
commit
d4980c5532
@ -1,49 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/editor"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/program"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// main: Entry point for the Gim text editor. Creates a buffer and window,
|
||||
// initializes the editor model, and runs the BubbleTea TUI program.
|
||||
func main() {
|
||||
// TODO: Read OS args and open file
|
||||
|
||||
// TODO: Need to implement the force bang handling!!! Opencode has this
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
ReadOnly().
|
||||
Build()
|
||||
|
||||
win := core.NewWindowBuilder().
|
||||
WithBuffer(&buf).
|
||||
WithOptions(core.NewDefaultWinOptions()).
|
||||
Build()
|
||||
|
||||
model := editor.NewModelBuilder().
|
||||
AddBuffer(&buf).
|
||||
AddWindow(&win).
|
||||
WithActiveWindowId(win.Id).
|
||||
Build()
|
||||
|
||||
m, _ := tea.NewProgram(model, tea.WithAltScreen()).Run()
|
||||
|
||||
final, ok := m.(*editor.Model)
|
||||
if ok {
|
||||
fmt.Printf("PRINTING WINDOWS: %+v\n", final.Windows())
|
||||
fmt.Printf("PRINTING ACTIVE WINDOW: %+v\n", final.ActiveWindow())
|
||||
for _, win := range final.Windows() {
|
||||
fmt.Printf("\t%+v\n", *win.Buffer)
|
||||
}
|
||||
|
||||
fmt.Printf("PRINTING BUFFERS: %+v\n", final.Buffers())
|
||||
fmt.Printf("PRINTING ACTIVE BUFFER: %+v\n", final.ActiveBuffer())
|
||||
// <exe> <filename>
|
||||
args := os.Args[1:]
|
||||
|
||||
var prog *tea.Program
|
||||
if len(args) < 1 {
|
||||
prog = program.NewProgramBuilder().
|
||||
EmptyProgram().
|
||||
WithOpt(tea.WithAltScreen()).
|
||||
Build()
|
||||
} else {
|
||||
fmt.Printf("PRINTING ALL: %+v\n", m)
|
||||
prog = program.NewProgramBuilder().
|
||||
FileProgram(args[0]).
|
||||
WithOpt(tea.WithAltScreen()).
|
||||
Build()
|
||||
}
|
||||
|
||||
if _, err := prog.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -184,6 +184,8 @@ func cmdEdit(m action.Model, args []string, force bool) tea.Cmd {
|
||||
|
||||
var lines []string
|
||||
|
||||
// BUG: We are unable to open and edit files owned by root. How do we handle that?
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
@ -2945,3 +2945,939 @@ func TestCmdWriteQuitAll(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// Force Write Tests (w!)
|
||||
// ==================================================
|
||||
|
||||
func TestCmdWriteForce(t *testing.T) {
|
||||
t.Run("force writes readonly buffer to its own file", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "readonly.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"forced content"}).
|
||||
ReadOnly().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{}, true)
|
||||
|
||||
// Force should bypass readonly check
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Verify file was written
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("file not written: %v", err)
|
||||
}
|
||||
|
||||
expected := "forced content\n"
|
||||
if string(content) != expected {
|
||||
t.Errorf("got %q, want %q", string(content), expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force writes readonly buffer to argument file", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalFile := filepath.Join(tmpDir, "readonly_original.txt")
|
||||
newFile := filepath.Join(tmpDir, "new_destination.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(originalFile).
|
||||
WithLines([]string{"readonly content"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{newFile}, true)
|
||||
|
||||
// Should succeed with force
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Verify new file was created
|
||||
content, err := os.ReadFile(newFile)
|
||||
if err != nil {
|
||||
t.Fatalf("new file not created: %v", err)
|
||||
}
|
||||
|
||||
expected := "readonly content\n"
|
||||
if string(content) != expected {
|
||||
t.Errorf("got %q, want %q", string(content), expected)
|
||||
}
|
||||
|
||||
// Original should not exist (never written)
|
||||
if _, err := os.Stat(originalFile); !os.IsNotExist(err) {
|
||||
t.Error("original file should not exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force writes scratch buffer to argument file", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
targetFile := filepath.Join(tmpDir, "from_scratch.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.ScatchBuffer).
|
||||
WithFilename("scratch").
|
||||
WithLines([]string{"scratch content", "line 2"}).
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{targetFile}, true)
|
||||
|
||||
// Force with filename should work for scratch buffer
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Verify file was written
|
||||
content, err := os.ReadFile(targetFile)
|
||||
if err != nil {
|
||||
t.Fatalf("file not written: %v", err)
|
||||
}
|
||||
|
||||
expected := "scratch content\nline 2\n"
|
||||
if string(content) != expected {
|
||||
t.Errorf("got %q, want %q", string(content), expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force writes readonly scratch buffer to argument file", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
targetFile := filepath.Join(tmpDir, "from_readonly_scratch.txt")
|
||||
|
||||
// Readonly scratch buffer - double protection
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.ScatchBuffer).
|
||||
WithFilename("scratch").
|
||||
WithLines([]string{"protected content"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{targetFile}, true)
|
||||
|
||||
// Force should bypass BOTH readonly AND scratch checks
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error with force: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Verify file was written
|
||||
content, err := os.ReadFile(targetFile)
|
||||
if err != nil {
|
||||
t.Fatalf("file not written: %v", err)
|
||||
}
|
||||
|
||||
expected := "protected content\n"
|
||||
if string(content) != expected {
|
||||
t.Errorf("got %q, want %q", string(content), expected)
|
||||
}
|
||||
|
||||
// Verify modified flag was cleared
|
||||
if buf.Modified {
|
||||
t.Error("modified flag should be cleared after successful write")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write scratch buffer without filename still errors", func(t *testing.T) {
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.ScatchBuffer).
|
||||
WithFilename("").
|
||||
WithLines([]string{"content"}).
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{}, true)
|
||||
|
||||
// Force doesn't help if there's no filename to write to
|
||||
if m.commandError == nil {
|
||||
t.Error("expected error when no filename provided")
|
||||
}
|
||||
|
||||
if !strings.Contains(m.commandError.Error(), "no file name") {
|
||||
t.Errorf("error should mention no file name: %v", m.commandError)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write to invalid path still errors", func(t *testing.T) {
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename("/invalid/nonexistent/path/file.txt").
|
||||
WithLines([]string{"content"}).
|
||||
ReadOnly().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{}, true)
|
||||
|
||||
// Force bypasses buffer checks but not OS-level checks
|
||||
if m.commandError == nil {
|
||||
t.Error("expected error for invalid path even with force")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write without force flag still errors on readonly", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "readonly.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"content"}).
|
||||
ReadOnly().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
// force=false should still prevent write
|
||||
cmdWrite(m, []string{}, false)
|
||||
|
||||
if m.commandError == nil {
|
||||
t.Error("expected error for readonly without force flag")
|
||||
}
|
||||
|
||||
if !strings.Contains(m.commandError.Error(), "readonly") {
|
||||
t.Errorf("error should mention readonly: %v", m.commandError)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write clears modified flag even for readonly buffer", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "test.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"content"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
if !buf.Modified {
|
||||
t.Fatal("precondition: buffer should be modified")
|
||||
}
|
||||
|
||||
cmdWrite(m, []string{}, true)
|
||||
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Modified flag should be cleared
|
||||
if buf.Modified {
|
||||
t.Error("modified flag should be cleared after force write")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write preserves buffer type", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
targetFile := filepath.Join(tmpDir, "output.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.ScatchBuffer).
|
||||
WithFilename("scratch").
|
||||
WithLines([]string{"content"}).
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{targetFile}, true)
|
||||
|
||||
// Buffer type should remain ScratchBuffer
|
||||
if buf.Type != core.ScatchBuffer {
|
||||
t.Errorf("buffer type changed to %v, should remain ScatchBuffer", buf.Type)
|
||||
}
|
||||
|
||||
// Filename should NOT change (Vim behavior)
|
||||
if buf.Filename != "scratch" {
|
||||
t.Errorf("buffer filename changed to %q, should remain 'scratch'", buf.Filename)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write with empty scratch buffer", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "empty.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.ScatchBuffer).
|
||||
WithFilename("").
|
||||
WithLines([]string{}).
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{filename}, true)
|
||||
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Should create empty file
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("file not created: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != "" {
|
||||
t.Errorf("expected empty file, got %q", string(content))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// Force WriteAll Tests (wall!)
|
||||
// ==================================================
|
||||
|
||||
func TestCmdWriteAllForce(t *testing.T) {
|
||||
t.Run("force writes all modified readonly buffers", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
file1 := filepath.Join(tmpDir, "readonly1.txt")
|
||||
file2 := filepath.Join(tmpDir, "readonly2.txt")
|
||||
|
||||
buf1 := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(file1).
|
||||
WithLines([]string{"content 1"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
buf2 := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(file2).
|
||||
WithLines([]string{"content 2"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf1)
|
||||
m.buffers = append(m.buffers, &buf2)
|
||||
|
||||
cmdWriteAll(m, []string{}, true)
|
||||
|
||||
// Should succeed with force
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Both files should be written
|
||||
content1, err := os.ReadFile(file1)
|
||||
if err != nil {
|
||||
t.Errorf("file1 not written: %v", err)
|
||||
} else if string(content1) != "content 1\n" {
|
||||
t.Errorf("file1 content = %q", string(content1))
|
||||
}
|
||||
|
||||
content2, err := os.ReadFile(file2)
|
||||
if err != nil {
|
||||
t.Errorf("file2 not written: %v", err)
|
||||
} else if string(content2) != "content 2\n" {
|
||||
t.Errorf("file2 content = %q", string(content2))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force writeall still errors on scratch buffer without filename argument", func(t *testing.T) {
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.ScatchBuffer).
|
||||
WithFilename("").
|
||||
WithLines([]string{"content"}).
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWriteAll(m, []string{}, true)
|
||||
|
||||
// Force can bypass scratch type check, but if buffer has no filename
|
||||
// it should still error with "no file name provided"
|
||||
if m.commandError == nil {
|
||||
t.Error("expected error for scratch buffer without filename")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force writeall with mix of protected buffer types", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
readonlyFile := filepath.Join(tmpDir, "readonly.txt")
|
||||
normalFile := filepath.Join(tmpDir, "normal.txt")
|
||||
|
||||
readonlyBuf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(readonlyFile).
|
||||
WithLines([]string{"readonly content"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
normalBuf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(normalFile).
|
||||
WithLines([]string{"normal content"}).
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
unmodifiedBuf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filepath.Join(tmpDir, "unmodified.txt")).
|
||||
WithLines([]string{"unchanged"}).
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&readonlyBuf)
|
||||
m.buffers = append(m.buffers, &normalBuf, &unmodifiedBuf)
|
||||
|
||||
cmdWriteAll(m, []string{}, true)
|
||||
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Both modified files should be written
|
||||
if _, err := os.Stat(readonlyFile); os.IsNotExist(err) {
|
||||
t.Error("readonly file should be written with force")
|
||||
}
|
||||
if _, err := os.Stat(normalFile); os.IsNotExist(err) {
|
||||
t.Error("normal file should be written")
|
||||
}
|
||||
|
||||
// Unmodified should be skipped (doesn't exist)
|
||||
unmodifiedFile := filepath.Join(tmpDir, "unmodified.txt")
|
||||
if _, err := os.Stat(unmodifiedFile); !os.IsNotExist(err) {
|
||||
t.Error("unmodified file should not be written")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force writeall clears modified flags", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
file1 := filepath.Join(tmpDir, "file1.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(file1).
|
||||
WithLines([]string{"content"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWriteAll(m, []string{}, true)
|
||||
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
if buf.Modified {
|
||||
t.Error("modified flag should be cleared")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// Force WriteQuit Tests (wq!)
|
||||
// ==================================================
|
||||
|
||||
func TestCmdWriteQuitForce(t *testing.T) {
|
||||
t.Run("force write-quit with readonly buffer", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "readonly.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"content"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmd := cmdWriteQuit(m, []string{}, true)
|
||||
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// File should be written
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
t.Errorf("file not written: %v", err)
|
||||
} else if string(content) != "content\n" {
|
||||
t.Errorf("content = %q", string(content))
|
||||
}
|
||||
|
||||
// Should quit
|
||||
if cmd == nil {
|
||||
t.Fatal("expected quit command")
|
||||
}
|
||||
|
||||
msg := cmd()
|
||||
if _, ok := msg.(tea.QuitMsg); !ok {
|
||||
t.Errorf("expected tea.QuitMsg, got %T", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write-quit with scratch buffer and filename arg", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "output.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.ScatchBuffer).
|
||||
WithFilename("scratch").
|
||||
WithLines([]string{"scratch data"}).
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmd := cmdWriteQuit(m, []string{filename}, true)
|
||||
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// File should be written
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
t.Errorf("file not written: %v", err)
|
||||
} else if string(content) != "scratch data\n" {
|
||||
t.Errorf("content = %q", string(content))
|
||||
}
|
||||
|
||||
if cmd == nil {
|
||||
t.Error("expected quit command")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write-quit without filename on scratch buffer still errors", func(t *testing.T) {
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.ScatchBuffer).
|
||||
WithFilename("").
|
||||
WithLines([]string{"content"}).
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmd := cmdWriteQuit(m, []string{}, true)
|
||||
|
||||
// Can't write without a filename even with force
|
||||
if m.commandError == nil {
|
||||
t.Error("expected error when no filename")
|
||||
}
|
||||
|
||||
if cmd != nil {
|
||||
t.Error("should not quit on error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// Force WriteQuitAll Tests (wqall! / wqa! / xa!)
|
||||
// ==================================================
|
||||
|
||||
func TestCmdWriteQuitAllForce(t *testing.T) {
|
||||
t.Run("force write-quit-all with readonly buffers", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
file1 := filepath.Join(tmpDir, "readonly1.txt")
|
||||
file2 := filepath.Join(tmpDir, "readonly2.txt")
|
||||
|
||||
buf1 := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(file1).
|
||||
WithLines([]string{"content 1"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
buf2 := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(file2).
|
||||
WithLines([]string{"content 2"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf1)
|
||||
m.buffers = append(m.buffers, &buf2)
|
||||
|
||||
cmd := cmdWriteQuitAll(m, []string{}, true)
|
||||
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Both files should be written
|
||||
content1, _ := os.ReadFile(file1)
|
||||
if string(content1) != "content 1\n" {
|
||||
t.Errorf("file1 content = %q", string(content1))
|
||||
}
|
||||
|
||||
content2, _ := os.ReadFile(file2)
|
||||
if string(content2) != "content 2\n" {
|
||||
t.Errorf("file2 content = %q", string(content2))
|
||||
}
|
||||
|
||||
// Should quit
|
||||
if cmd == nil {
|
||||
t.Fatal("expected quit command")
|
||||
}
|
||||
|
||||
msg := cmd()
|
||||
if _, ok := msg.(tea.QuitMsg); !ok {
|
||||
t.Errorf("expected tea.QuitMsg, got %T", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write-quit-all with mix of buffer types", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
readonlyFile := filepath.Join(tmpDir, "readonly.txt")
|
||||
normalFile := filepath.Join(tmpDir, "normal.txt")
|
||||
|
||||
readonlyBuf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(readonlyFile).
|
||||
WithLines([]string{"readonly"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
normalBuf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(normalFile).
|
||||
WithLines([]string{"normal"}).
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
unmodifiedBuf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename("unmodified.txt").
|
||||
WithLines([]string{"unchanged"}).
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&readonlyBuf)
|
||||
m.buffers = append(m.buffers, &normalBuf, &unmodifiedBuf)
|
||||
|
||||
cmd := cmdWriteQuitAll(m, []string{}, true)
|
||||
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Modified buffers should be written
|
||||
if _, err := os.Stat(readonlyFile); os.IsNotExist(err) {
|
||||
t.Error("readonly file should be written")
|
||||
}
|
||||
if _, err := os.Stat(normalFile); os.IsNotExist(err) {
|
||||
t.Error("normal file should be written")
|
||||
}
|
||||
|
||||
if cmd == nil {
|
||||
t.Error("expected quit command")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write-quit-all still errors on buffer without filename", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
validFile := filepath.Join(tmpDir, "valid.txt")
|
||||
|
||||
validBuf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(validFile).
|
||||
WithLines([]string{"content"}).
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
noNameBuf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename("").
|
||||
WithLines([]string{"content"}).
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&validBuf)
|
||||
m.buffers = append(m.buffers, &noNameBuf)
|
||||
|
||||
cmd := cmdWriteQuitAll(m, []string{}, true)
|
||||
|
||||
// Force can't help when there's no filename
|
||||
if m.commandError == nil {
|
||||
t.Error("expected error for buffer without filename")
|
||||
}
|
||||
|
||||
if cmd != nil {
|
||||
t.Error("should not quit when write fails")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force write-quit-all clears all modified flags", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
file1 := filepath.Join(tmpDir, "file1.txt")
|
||||
file2 := filepath.Join(tmpDir, "file2.txt")
|
||||
|
||||
buf1 := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(file1).
|
||||
WithLines([]string{"content"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
buf2 := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(file2).
|
||||
WithLines([]string{"content"}).
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf1)
|
||||
m.buffers = append(m.buffers, &buf2)
|
||||
|
||||
cmdWriteQuitAll(m, []string{}, true)
|
||||
|
||||
if buf1.Modified {
|
||||
t.Error("buf1 modified flag should be cleared")
|
||||
}
|
||||
if buf2.Modified {
|
||||
t.Error("buf2 modified flag should be cleared")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// Edge Case Tests
|
||||
// ==================================================
|
||||
|
||||
func TestEdgeCases(t *testing.T) {
|
||||
t.Run("write to file that becomes readonly during editing", func(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("test requires non-root user")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "test.txt")
|
||||
|
||||
// Create file
|
||||
os.WriteFile(filename, []byte("original"), 0644)
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"modified content"}).
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
// Make file readonly on filesystem (different from buffer readonly flag)
|
||||
os.Chmod(filename, 0444)
|
||||
defer os.Chmod(filename, 0644) // Cleanup
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
// Regular write should fail (OS permission denied)
|
||||
cmdWrite(m, []string{}, false)
|
||||
|
||||
if m.commandError == nil {
|
||||
t.Error("expected error for OS-level readonly file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write unmodified readonly buffer is allowed with force", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "readonly.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"content"}).
|
||||
ReadOnly().
|
||||
Build()
|
||||
buf.Modified = false
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{}, true)
|
||||
|
||||
// Force write should work even for unmodified readonly
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
content, _ := os.ReadFile(filename)
|
||||
if string(content) != "content\n" {
|
||||
t.Errorf("file not written correctly")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("buffer with both readonly and modified flags", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "test.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"edited readonly content"}).
|
||||
ReadOnly().
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
// Without force - should error
|
||||
cmdWrite(m, []string{}, false)
|
||||
if m.commandError == nil {
|
||||
t.Error("expected error without force")
|
||||
}
|
||||
|
||||
// With force - should succeed
|
||||
m.commandError = nil
|
||||
cmdWrite(m, []string{}, true)
|
||||
if m.commandError != nil {
|
||||
t.Errorf("force write failed: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Modified flag should be cleared
|
||||
if buf.Modified {
|
||||
t.Error("modified flag should be cleared")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write buffer with very long filename", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
// Create a long but valid filename (255 chars is typical limit)
|
||||
longName := strings.Repeat("a", 200) + ".txt"
|
||||
filename := filepath.Join(tmpDir, longName)
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"content"}).
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{}, false)
|
||||
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||
t.Error("file with long name not created")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write buffer with unicode in filename", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "テスト_файл_测试.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"unicode filename test"}).
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmdWrite(m, []string{}, false)
|
||||
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Verify file exists and content is correct
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("file with unicode name not created: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != "unicode filename test\n" {
|
||||
t.Errorf("content = %q", string(content))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple sequential writes to same file", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "multi_write.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"version 1"}).
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
// First write
|
||||
cmdWrite(m, []string{}, false)
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("first write failed: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Modify buffer
|
||||
buf.SetLine(0, "version 2")
|
||||
buf.SetModified(true)
|
||||
|
||||
// Second write
|
||||
cmdWrite(m, []string{}, false)
|
||||
if m.commandError != nil {
|
||||
t.Fatalf("second write failed: %v", m.commandError)
|
||||
}
|
||||
|
||||
// Verify final content
|
||||
content, _ := os.ReadFile(filename)
|
||||
if string(content) != "version 2\n" {
|
||||
t.Errorf("final content = %q", string(content))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force quit does not write", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filename := filepath.Join(tmpDir, "not_written.txt")
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithLines([]string{"unsaved content"}).
|
||||
Modified().
|
||||
Build()
|
||||
|
||||
m := newMockModelWithBuffer(&buf)
|
||||
|
||||
cmd := cmdQuit(m, []string{}, true)
|
||||
|
||||
// Should quit without error
|
||||
if m.commandError != nil {
|
||||
t.Errorf("unexpected error: %v", m.commandError)
|
||||
}
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("expected quit command")
|
||||
}
|
||||
|
||||
// File should NOT be written
|
||||
if _, err := os.Stat(filename); !os.IsNotExist(err) {
|
||||
t.Error("file should not be written with force quit")
|
||||
}
|
||||
|
||||
// Buffer should still be modified (not saved)
|
||||
if !buf.Modified {
|
||||
t.Error("buffer should still be modified (force quit doesn't save)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ func writeBuffer(m action.Model, buf *core.Buffer, args []string, force bool) (t
|
||||
return nil, fmt.Errorf("cannot write to 'readonly' buffer")
|
||||
}
|
||||
|
||||
if buf.Type == core.ScatchBuffer {
|
||||
if !force && buf.Type == core.ScatchBuffer {
|
||||
return nil, fmt.Errorf("cannot write to buffer of type 'ScratchBuffer'")
|
||||
}
|
||||
|
||||
|
||||
120
internal/program/program_builder.go
Normal file
120
internal/program/program_builder.go
Normal file
@ -0,0 +1,120 @@
|
||||
package program
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/editor"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type ProgramBuilder struct {
|
||||
model *editor.Model
|
||||
opts []tea.ProgramOption
|
||||
}
|
||||
|
||||
func NewProgramBuilder() *ProgramBuilder {
|
||||
return &ProgramBuilder{
|
||||
model: &editor.Model{},
|
||||
opts: []tea.ProgramOption{},
|
||||
}
|
||||
}
|
||||
|
||||
// ProgramBuilder.FileProgram: Sets the internal state of the builder to the required
|
||||
// state to start the program (editor) with a filename. This is what will happen when
|
||||
// a user runs 'gim <filename>'.
|
||||
func (p *ProgramBuilder) FileProgram(filename string) *ProgramBuilder {
|
||||
// Only difference: open the file
|
||||
ext := filepath.Ext(filename)
|
||||
|
||||
file, err := os.Open(filename)
|
||||
notFound := errors.Is(err, os.ErrNotExist)
|
||||
if err != nil && !notFound {
|
||||
// TODO: Handle this
|
||||
panic(fmt.Errorf("Failed to find file: %w", err))
|
||||
}
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithFiletype(ext).
|
||||
Build()
|
||||
|
||||
win := core.NewWindowBuilder().
|
||||
WithBuffer(&buf).
|
||||
WithOptions(core.NewDefaultWinOptions()).
|
||||
Build()
|
||||
|
||||
p.model = editor.NewModelBuilder().
|
||||
AddBuffer(&buf).
|
||||
AddWindow(&win).
|
||||
WithActiveWindowId(win.Id).
|
||||
Build()
|
||||
|
||||
// If we did not find the file, all we need to do is set the filename and type
|
||||
if notFound {
|
||||
return p
|
||||
}
|
||||
|
||||
// Otherwise we have to create everything, then read the file (since we need settings)
|
||||
// COPIED FROM `internal/command/handlers.go`
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
|
||||
// BUG: This is bad, we don't want to this, but we have to
|
||||
cleaned := strings.ReplaceAll(line, "\t", strings.Repeat(" ", p.model.Settings().TabStop))
|
||||
lines = append(lines, cleaned)
|
||||
}
|
||||
|
||||
// Only setting lines if we found some
|
||||
if len(lines) > 0 {
|
||||
p.model.ActiveBuffer().SetLines(lines)
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// ProgramBuilder.EmptyProgram: Sets the internal state of the builder to the required
|
||||
// state to start the program (editor) in the current directory. This is what will
|
||||
// happen when a user runs 'gim'.
|
||||
func (p *ProgramBuilder) EmptyProgram() *ProgramBuilder {
|
||||
buf := core.NewBufferBuilder().
|
||||
ReadOnly().
|
||||
Build()
|
||||
|
||||
win := core.NewWindowBuilder().
|
||||
WithBuffer(&buf).
|
||||
WithOptions(core.NewDefaultWinOptions()).
|
||||
Build()
|
||||
|
||||
p.model = editor.NewModelBuilder().
|
||||
AddBuffer(&buf).
|
||||
AddWindow(&win).
|
||||
WithActiveWindowId(win.Id).
|
||||
Build()
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// ProgramBuilder.WithOpt: Add an option to the list of options that will be used when
|
||||
// the program is built.
|
||||
func (p *ProgramBuilder) WithOpt(opt tea.ProgramOption) *ProgramBuilder {
|
||||
p.opts = append(p.opts, opt)
|
||||
return p
|
||||
}
|
||||
|
||||
// ProgramBuilder.Build: Build and return the configured tea.Program instance.
|
||||
func (p *ProgramBuilder) Build() *tea.Program {
|
||||
return tea.NewProgram(p.model, p.opts...)
|
||||
}
|
||||
1066
internal/program/program_builder_test.go
Normal file
1066
internal/program/program_builder_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user