diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3f599ae --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: build test run clean + +BINARY_NAME=mend +BUILD_DIR=bin + +build: + @mkdir -p $(BUILD_DIR) + go build -o $(BUILD_DIR)/$(BINARY_NAME) . + +test: + go test ./... + +run: + go run . + +clean: + rm -rf $(BUILD_DIR) diff --git a/README.md b/README.md index ff25d2d..f67a56f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ my take on how notes and srs should be combind and simplified -# Tests +## Usage -Run tests with `go test ./...` +The project includes a `Makefile` for common tasks: + +- **Build**: `make build` (creates binary in `bin/mend`) +- **Test**: `make test` +- **Run**: `make run` +- **Clean**: `make clean` diff --git a/fs.go b/fs.go deleted file mode 100644 index 3e6ed00..0000000 --- a/fs.go +++ /dev/null @@ -1,103 +0,0 @@ -/* -This file has the raw file io operations that are used by FsTree -- ruinivist, 30Dec25 -*/ - -package main - -import ( - "errors" - "os" - "path/filepath" -) - -func walkFileSystemAndBuildTree(rootPath string, node *FsNode) error { - if node == nil { - return errors.New("node cannot be nil") - } - if len(node.children) > 0 { - return errors.New("node already has children") - } - - entries, err := os.ReadDir(rootPath) - if err != nil { - return err - } - - files := make([]os.DirEntry, 0) - folders := make([]os.DirEntry, 0) - - for _, entry := range entries { - // dot folders and files skipped - if len(entry.Name()) > 0 && entry.Name()[0] == '.' { - continue - } - - if entry.IsDir() { - folders = append(folders, entry) - } else { - files = append(files, entry) - } - } - - for _, file := range files { - newNode := &FsNode{ - nodeType: FileNode, - path: filepath.Join(rootPath, file.Name()), - children: make([]*FsNode, 0), - parent: node, - expanded: false, - } - node.children = append(node.children, newNode) - } - - for _, folder := range folders { - newNode := &FsNode{ - nodeType: FolderNode, - path: filepath.Join(rootPath, folder.Name()), - children: make([]*FsNode, 0), - parent: node, - expanded: true, // all expanded by default - } - node.children = append(node.children, newNode) - walkFileSystemAndBuildTree(newNode.path, newNode) - } - - return nil -} - -func createFile(path string, content []byte) error { - if path == "" { - return errors.New("file path cannot be empty") - } - - if _, err := os.Stat(path); err == nil { - return errors.New("file already exists") - } - - return os.WriteFile(path, content, 0644) -} - -func createFolder(path string) error { - if path == "" { - return errors.New("folder path cannot be empty") - } - - if _, err := os.Stat(path); err == nil { - return errors.New("folder already exists") - } - - return os.Mkdir(path, 0755) -} - -func deletePath(path string) error { - if path == "" { - return errors.New("path cannot be empty") - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - return errors.New("path does not exist") - } - - return os.RemoveAll(path) -} diff --git a/internal/filesystem/fs.go b/internal/filesystem/fs.go new file mode 100644 index 0000000..80d558d --- /dev/null +++ b/internal/filesystem/fs.go @@ -0,0 +1,42 @@ +package filesystem + +import ( + "errors" + "os" +) + +func CreateFile(path string, content []byte) error { + if path == "" { + return errors.New("file path cannot be empty") + } + + if _, err := os.Stat(path); err == nil { + return errors.New("file already exists") + } + + return os.WriteFile(path, content, 0644) +} + +func CreateFolder(path string) error { + if path == "" { + return errors.New("folder path cannot be empty") + } + + if _, err := os.Stat(path); err == nil { + return errors.New("folder already exists") + } + + return os.Mkdir(path, 0755) +} + +func DeletePath(path string) error { + if path == "" { + return errors.New("path cannot be empty") + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + return errors.New("path does not exist") + } + + return os.RemoveAll(path) +} diff --git a/fs_test.go b/internal/filesystem/fs_test.go similarity index 85% rename from fs_test.go rename to internal/filesystem/fs_test.go index 217e246..0924444 100644 --- a/fs_test.go +++ b/internal/filesystem/fs_test.go @@ -1,4 +1,4 @@ -package main +package filesystem import ( "os" @@ -16,7 +16,7 @@ func TestCreateFile(t *testing.T) { // test valid creation filePath := filepath.Join(tmpDir, "test.md") - err = createFile(filePath, []byte("content")) + err = CreateFile(filePath, []byte("content")) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -26,13 +26,13 @@ func TestCreateFile(t *testing.T) { } // test creation failure (exists) - err = createFile(filePath, []byte("content")) + err = CreateFile(filePath, []byte("content")) if err == nil { t.Error("expected error for existing file, got nil") } // test empty path - err = createFile("", []byte{}) + err = CreateFile("", []byte{}) if err == nil { t.Error("expected error for empty path, got nil") } @@ -47,7 +47,7 @@ func TestCreateFolder(t *testing.T) { defer os.RemoveAll(tmpDir) folderPath := filepath.Join(tmpDir, "subfolder") - err = createFolder(folderPath) + err = CreateFolder(folderPath) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -61,13 +61,13 @@ func TestCreateFolder(t *testing.T) { } // test creation failure (exists) - err = createFolder(folderPath) + err = CreateFolder(folderPath) if err == nil { t.Error("expected error for existing folder, got nil") } // test empty path - err = createFolder("") + err = CreateFolder("") if err == nil { t.Error("expected error for empty path, got nil") } @@ -86,7 +86,7 @@ func TestDeletePath(t *testing.T) { fileInSubDir := filepath.Join(subDir, "file.txt") os.WriteFile(fileInSubDir, []byte("data"), 0644) - err = deletePath(subDir) + err = DeletePath(subDir) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -96,13 +96,13 @@ func TestDeletePath(t *testing.T) { } // test delete non-existent - err = deletePath(filepath.Join(tmpDir, "nonexistent")) + err = DeletePath(filepath.Join(tmpDir, "nonexistent")) if err == nil { t.Error("expected error for nonexistent path, got nil") } // test empty path - err = deletePath("") + err = DeletePath("") if err == nil { t.Error("expected error for empty path, got nil") } diff --git a/fstree.go b/internal/ui/fstree/model.go similarity index 65% rename from fstree.go rename to internal/ui/fstree/model.go index b8f8c32..11a2d0e 100644 --- a/fstree.go +++ b/internal/ui/fstree/model.go @@ -1,16 +1,16 @@ /* This file contains the implementation of an in-memory tree of files and folders that the app used. It is initialised at startup and used for state changes in UI and later persisted to disk. - -- ruinivist, 30Dec25 */ -package main +package fstree import ( "errors" + "mend/internal/filesystem" "mend/styles" "mend/utils" + "os" "path/filepath" "strings" @@ -30,28 +30,28 @@ const ( // a single node, Fs => deals with file system related info mostly // a name for example can be content derived as well type FsNode struct { - nodeType FsNodeType - path string - children []*FsNode - parent *FsNode // for fast traversal up the tree - expanded bool // makes sense only for folder nodes - // these are populated by buildLines for fast access + Type FsNodeType + Path string + Children []*FsNode + Parent *FsNode // for fast traversal up the tree + Expanded bool // makes sense only for folder nodes + // these are populated by BuildLines for fast access line int prev *FsNode next *FsNode } func (n *FsNode) FileName() string { - name := filepath.Base(n.path) - if n.nodeType == FileNode { + name := filepath.Base(n.Path) + if n.Type == FileNode { return strings.TrimSuffix(name, ".md") } return name } // ================== messages =================== -type nodeSelected struct { - path string +type NodeSelectedMsg struct { + Path string } type FsActionType int @@ -73,11 +73,11 @@ type PerformActionMsg struct { // ==================== FsNode definition ==================== type FsTree struct { - root *FsNode + Root *FsNode lines map[int]*FsNode // flattened view of nodes for easy line access, map so that I can handle blank padding - selectedNode *FsNode + SelectedNode *FsNode hoveredNode *FsNode - errMsg string + ErrMsg string height int width int viewStart int @@ -97,13 +97,13 @@ func (t *FsTree) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case PerformActionMsg: err := t.PerformAction(m.Action, m.Name) if err != nil { - t.errMsg = err.Error() + t.ErrMsg = err.Error() } case tea.WindowSizeMsg: t.width = m.Width t.height = m.Height case tea.KeyMsg: - t.errMsg = "" + t.ErrMsg = "" switch m.String() { case "w", "up": _ = t.MoveUp() @@ -118,9 +118,9 @@ func (t *FsTree) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "C": // new root node return t, func() tea.Msg { return RequestInputMsg{Action: ActionNewRoot} } case "delete": // delete node - err := t.DeleteNode(t.selectedNode) + err := t.DeleteNode(t.SelectedNode) if err != nil { - t.errMsg = err.Error() + t.ErrMsg = err.Error() } } @@ -138,15 +138,15 @@ func (t *FsTree) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.X >= t.width { break } - t.errMsg = "" + t.ErrMsg = "" t.hoveredNode = t.lines[m.Y] // hover // click if m.Button == tea.MouseButtonLeft && m.Action == tea.MouseActionPress { nodeAtLine := t.lines[m.Y] if nodeAtLine != nil { - t.selectedNode = nodeAtLine - if nodeAtLine.nodeType == FolderNode { + t.SelectedNode = nodeAtLine + if nodeAtLine.Type == FolderNode { _ = t.ToggleExpand(nodeAtLine) } } @@ -155,10 +155,10 @@ func (t *FsTree) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.viewStart, t.viewEnd = t.getViewportBounds() - if t.oldSelected != t.selectedNode && t.selectedNode.nodeType == FileNode { - t.oldSelected = t.selectedNode + if t.oldSelected != t.SelectedNode && t.SelectedNode.Type == FileNode { + t.oldSelected = t.SelectedNode return t, func() tea.Msg { - return nodeSelected{path: t.selectedNode.path} + return NodeSelectedMsg{Path: t.SelectedNode.Path} } } return t, nil @@ -167,20 +167,20 @@ func (t *FsTree) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (t *FsTree) PerformAction(action FsActionType, name string) error { switch action { case ActionNewFile: - return t.CreateNode(t.selectedNode, name, FileNode) + return t.CreateNode(t.SelectedNode, name, FileNode) case ActionNewFolder: - return t.CreateNode(t.selectedNode, name, FolderNode) + return t.CreateNode(t.SelectedNode, name, FolderNode) case ActionNewRoot: - return t.CreateNode(t.root, name, FolderNode) + return t.CreateNode(t.Root, name, FolderNode) } return nil } func (t *FsTree) getViewportBounds() (startLine, endLine int) { - if t.selectedNode == nil { + if t.SelectedNode == nil { return 0, 0 // doesn't amtter in this case } - selectedLine := t.selectedNode.line + selectedLine := t.SelectedNode.line halfHeight := t.height / 2 startLine = max(0, selectedLine-halfHeight) @@ -198,16 +198,16 @@ func (t *FsTree) getViewportBounds() (startLine, endLine int) { } func (t *FsTree) View() string { - if t.errMsg != "" { - return t.errMsg + if t.ErrMsg != "" { + return t.ErrMsg } - if len(t.root.children) == 0 { + if len(t.Root.Children) == 0 { return "no files/folders\nPress C to create" } builder := &strings.Builder{} - t.renderNode(t.root, 0, builder) + t.renderNode(t.Root, 0, builder) rendered := builder.String() lines := strings.Split(rendered, "\n") @@ -226,21 +226,21 @@ func (t *FsTree) View() string { func NewFsTree(rootPath string, startOffset int) *FsTree { root := &FsNode{ - nodeType: FolderNode, - path: rootPath, - children: make([]*FsNode, 0), - expanded: true, + Type: FolderNode, + Path: rootPath, + Children: make([]*FsNode, 0), + Expanded: true, } - walkFileSystemAndBuildTree(rootPath, root) + WalkFileSystemAndBuildTree(rootPath, root) tree := &FsTree{ - root: root, + Root: root, startOffset: startOffset, } - if len(root.children) > 0 { - tree.selectedNode = root.children[0] + if len(root.Children) > 0 { + tree.SelectedNode = root.Children[0] } - tree.buildLines() + tree.BuildLines() return tree } @@ -248,27 +248,27 @@ func (t *FsTree) DeleteNode(node *FsNode) error { if node == nil { return errors.New("node to delete cannot be nil") } - parent := node.parent + parent := node.Parent if parent == nil { return errors.New("node to delete must have a parent") } // materialise - if err := deletePath(node.path); err != nil { + if err := filesystem.DeletePath(node.Path); err != nil { return err } - t.selectedNode = node.prev // cannot be next as subfolder/file deletion - parent.children = utils.RemoveFromSlice(parent.children, node) + t.SelectedNode = node.prev // cannot be next as subfolder/file deletion + parent.Children = utils.RemoveFromSlice(parent.Children, node) - if t.selectedNode == nil { + if t.SelectedNode == nil { // can only happen if first root level node is deleted - if len(t.root.children) > 0 { - t.selectedNode = t.root.children[0] + if len(t.Root.Children) > 0 { + t.SelectedNode = t.Root.Children[0] } } - t.buildLines() + t.BuildLines() return nil } @@ -276,17 +276,17 @@ func (t *FsTree) ToggleExpand(node *FsNode) error { if node == nil { return errors.New("node cannot be nil") } - if node.nodeType != FolderNode { + if node.Type != FolderNode { return errors.New("only folder nodes can be expanded or collapsed") } - node.expanded = !node.expanded - t.buildLines() + node.Expanded = !node.Expanded + t.BuildLines() return nil } func (t *FsTree) move(delta int) error { - selected := t.selectedNode + selected := t.SelectedNode if selected == nil { return errors.New("no node is currently selected") } @@ -301,7 +301,7 @@ func (t *FsTree) move(delta int) error { next = selected.prev } if next != nil { - t.selectedNode = next + t.SelectedNode = next } return nil } @@ -310,14 +310,14 @@ func (t *FsTree) MoveUp() error { return t.move(-1) } func (t *FsTree) MoveDown() error { return t.move(1) } func (t *FsTree) ToggleSelectedExpand() error { - if t.selectedNode == nil { + if t.SelectedNode == nil { return errors.New("no node is currently selected") } - if t.selectedNode.nodeType != FolderNode { + if t.SelectedNode.Type != FolderNode { return errors.New("only folder nodes can be expanded or collapsed") } - t.ToggleExpand(t.selectedNode) + t.ToggleExpand(t.SelectedNode) return nil } @@ -327,7 +327,7 @@ func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) { } folderInRoot := false - if node.nodeType == FolderNode && depth == 1 && node.prev != nil { + if node.Type == FolderNode && depth == 1 && node.prev != nil { folderInRoot = true } @@ -335,9 +335,9 @@ func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) { prevIndent := strings.Repeat(" ", max(depth-1, 0)) var icon string - switch node.nodeType { + switch node.Type { case FolderNode: - if node.expanded { + if node.Expanded { icon = indent + styles.ArrowDownIcon } else { icon = indent + styles.ArrowRightIcon @@ -350,7 +350,7 @@ func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) { // highlight if selected or hovered // note: the logic of lines cache needs to match render fileName := node.FileName() - isSelected := node == t.selectedNode + isSelected := node == t.SelectedNode isHovered := node == t.hoveredNode if isSelected { @@ -368,19 +368,19 @@ func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) { builder.WriteString(line) } - if node.expanded { - for _, child := range node.children { + if node.Expanded { + for _, child := range node.Children { t.renderNode(child, depth+1, builder) } } } // builds a cache of line num to rendered node in view -func (t *FsTree) buildLines() { +func (t *FsTree) BuildLines() { t.lines = make(map[int]*FsNode) line := -1 flatTree := make([]*FsNode, 0) - t.buildLinesRec(t.root, 0, &line, &flatTree) // root has -1 depth and -1 index, as it's not meant to be rendered + t.buildLinesRec(t.Root, 0, &line, &flatTree) // root has -1 depth and -1 index, as it's not meant to be rendered // everything is a child of root flatTree[0] = nil // basically a merge of skip root and padding ends with nil flatTree = append(flatTree, nil) @@ -398,7 +398,7 @@ func (t *FsTree) buildLinesRec(node *FsNode, depth int, currentLine *int, flatTr return } - if node.nodeType == FolderNode && depth == 1 && *currentLine != 0 { + if node.Type == FolderNode && depth == 1 && *currentLine != 0 { (*currentLine)++ } t.lines[*currentLine] = node @@ -406,8 +406,8 @@ func (t *FsTree) buildLinesRec(node *FsNode, depth int, currentLine *int, flatTr *flatTree = append(*flatTree, node) (*currentLine)++ - if node.expanded { - for _, child := range node.children { + if node.Expanded { + for _, child := range node.Children { t.buildLinesRec(child, depth+1, currentLine, flatTree) } } @@ -418,14 +418,14 @@ func (t *FsTree) CreateNode(folder *FsNode, name string, nodeType FsNodeType) er return errors.New("folder node cannot be nil") } - if folder.nodeType == FileNode { - folder = folder.parent + if folder.Type == FileNode { + folder = folder.Parent } - if folder.nodeType != FolderNode { + if folder.Type != FolderNode { return errors.New("parent node must be a folder. this should not be allowed by ui") - } else if folder.nodeType == FolderNode && !folder.expanded { - folder.expanded = true + } else if folder.Type == FolderNode && !folder.Expanded { + folder.Expanded = true } if name == "" { @@ -436,16 +436,16 @@ func (t *FsTree) CreateNode(folder *FsNode, name string, nodeType FsNodeType) er name += ".md" } - path := filepath.Join(folder.path, name) + path := filepath.Join(folder.Path, name) // materialise it first switch nodeType { case FileNode: - err := createFile(path, []byte{}) + err := filesystem.CreateFile(path, []byte{}) if err != nil { return err } case FolderNode: - err := createFolder(path) + err := filesystem.CreateFolder(path) if err != nil { return err } @@ -456,19 +456,74 @@ func (t *FsTree) CreateNode(folder *FsNode, name string, nodeType FsNodeType) er expanded = true } newNode := &FsNode{ - nodeType: nodeType, - path: path, - children: make([]*FsNode, 0), - parent: folder, - expanded: expanded, + Type: nodeType, + Path: path, + Children: make([]*FsNode, 0), + Parent: folder, + Expanded: expanded, } // files are first of children, folder last of children if nodeType == FileNode { - folder.children = append([]*FsNode{newNode}, folder.children...) + folder.Children = append([]*FsNode{newNode}, folder.Children...) } else { - folder.children = append(folder.children, newNode) + folder.Children = append(folder.Children, newNode) } - t.selectedNode = newNode - t.buildLines() + t.SelectedNode = newNode + t.BuildLines() + return nil +} + +func WalkFileSystemAndBuildTree(rootPath string, node *FsNode) error { + if node == nil { + return errors.New("node cannot be nil") + } + if len(node.Children) > 0 { + return errors.New("node already has children") + } + + entries, err := os.ReadDir(rootPath) + if err != nil { + return err + } + + files := make([]os.DirEntry, 0) + folders := make([]os.DirEntry, 0) + + for _, entry := range entries { + // dot folders and files skipped + if len(entry.Name()) > 0 && entry.Name()[0] == '.' { + continue + } + + if entry.IsDir() { + folders = append(folders, entry) + } else { + files = append(files, entry) + } + } + + for _, file := range files { + newNode := &FsNode{ + Type: FileNode, + Path: filepath.Join(rootPath, file.Name()), + Children: make([]*FsNode, 0), + Parent: node, + Expanded: false, + } + node.Children = append(node.Children, newNode) + } + + for _, folder := range folders { + newNode := &FsNode{ + Type: FolderNode, + Path: filepath.Join(rootPath, folder.Name()), + Children: make([]*FsNode, 0), + Parent: node, + Expanded: true, // all expanded by default + } + node.Children = append(node.Children, newNode) + WalkFileSystemAndBuildTree(newNode.Path, newNode) + } + return nil } diff --git a/fstree_test.go b/internal/ui/fstree/model_test.go similarity index 76% rename from fstree_test.go rename to internal/ui/fstree/model_test.go index f4d33a9..02fd886 100644 --- a/fstree_test.go +++ b/internal/ui/fstree/model_test.go @@ -1,4 +1,4 @@ -package main +package fstree import ( "os" @@ -29,12 +29,12 @@ func TestNewFsTree(t *testing.T) { if tree == nil { t.Fatal("expected tree to be created, got nil") } - if tree.root.path != tmpDir { - t.Errorf("expected root path %s, got %s", tmpDir, tree.root.path) + if tree.Root.Path != tmpDir { + t.Errorf("expected root path %s, got %s", tmpDir, tree.Root.Path) } // root children: folder1, file2.md - if len(tree.root.children) != 2 { - t.Errorf("expected 2 children, got %d", len(tree.root.children)) + if len(tree.Root.Children) != 2 { + t.Errorf("expected 2 children, got %d", len(tree.Root.Children)) } } @@ -48,7 +48,7 @@ func TestTreeCreateNode(t *testing.T) { tree := NewFsTree(tmpDir, 0) // create folder - err = tree.CreateNode(tree.root, "new_folder", FolderNode) + err = tree.CreateNode(tree.Root, "new_folder", FolderNode) if err != nil { t.Errorf("expected no error creating folder, got %v", err) } @@ -57,12 +57,12 @@ func TestTreeCreateNode(t *testing.T) { t.Error("folder not created on fs") } // check tree - if len(tree.root.children) != 1 || tree.root.children[0].nodeType != FolderNode { + if len(tree.Root.Children) != 1 || tree.Root.Children[0].Type != FolderNode { t.Error("tree block not updated with new folder") } // create file in new folder - newFolder := tree.root.children[0] + newFolder := tree.Root.Children[0] err = tree.CreateNode(newFolder, "new_file", FileNode) if err != nil { t.Errorf("expected no error creating file, got %v", err) @@ -73,7 +73,7 @@ func TestTreeCreateNode(t *testing.T) { t.Error("file not created on fs") } // check tree - if len(newFolder.children) != 1 || newFolder.children[0].nodeType != FileNode { + if len(newFolder.Children) != 1 || newFolder.Children[0].Type != FileNode { t.Error("tree node not updated with new file") } } @@ -90,7 +90,7 @@ func TestTreeDeleteNode(t *testing.T) { os.WriteFile(filepath.Join(tmpDir, "file.md"), []byte(""), 0644) tree := NewFsTree(tmpDir, 0) - targetNode := tree.root.children[0] + targetNode := tree.Root.Children[0] err = tree.DeleteNode(targetNode) if err != nil { @@ -102,7 +102,7 @@ func TestTreeDeleteNode(t *testing.T) { t.Error("file not deleted from fs") } // check tree - if len(tree.root.children) != 0 { + if len(tree.Root.Children) != 0 { t.Error("tree node not updated after deletion") } } @@ -110,30 +110,30 @@ func TestTreeDeleteNode(t *testing.T) { // tests toggling expand func TestTreeToggleExpand(t *testing.T) { root := &FsNode{ - nodeType: FolderNode, - expanded: true, - children: []*FsNode{}, // Initialize children + Type: FolderNode, + Expanded: true, + Children: []*FsNode{}, // Initialize children } node := &FsNode{ - nodeType: FolderNode, - expanded: true, - parent: root, + Type: FolderNode, + Expanded: true, + Parent: root, } - root.children = append(root.children, node) + root.Children = append(root.Children, node) // mocked tree for this test tree := &FsTree{ - root: root, + Root: root, } // Initial buildLines to setup state - tree.buildLines() + tree.BuildLines() err := tree.ToggleExpand(node) if err != nil { t.Error(err) } - if node.expanded { + if node.Expanded { t.Error("expected node to be collapsed") } @@ -141,7 +141,7 @@ func TestTreeToggleExpand(t *testing.T) { if err != nil { t.Error(err) } - if !node.expanded { + if !node.Expanded { t.Error("expected node to be expanded") } } diff --git a/notes.go b/internal/ui/note/model.go similarity index 89% rename from notes.go rename to internal/ui/note/model.go index 3170fa5..687427d 100644 --- a/notes.go +++ b/internal/ui/note/model.go @@ -1,10 +1,9 @@ /* this pakckage basically handles notes which are the individual files for spaced repetition in mend -- ruinivist, 3Jan26 */ -package main +package note import ( "fmt" @@ -39,7 +38,7 @@ const ( type NoteView struct { // the actual content - path string + Path string rawContent string // full content for editing sections []Section currentSectionIndex int @@ -55,15 +54,15 @@ type NoteView struct { } // ================== messages =================== -type loadNote struct { - path string - force bool +type LoadNoteMsg struct { + Path string + Force bool } -type loadedNote struct { - rawContent string - sections []Section - err error +type LoadedNote struct { + RawContent string + Sections []Section + Err error } func NewNoteView() *NoteView { @@ -107,7 +106,7 @@ func (m *NoteView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc", "ctrl+q": m.isEditing = false - return m, saveContent(m.path, m.textarea.Value()) + return m, saveContent(m.Path, m.textarea.Value()) } var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(msg) @@ -116,7 +115,7 @@ func (m *NoteView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": - if m.path != "" && !m.loading { + if m.Path != "" && !m.loading { m.isEditing = true m.textarea.SetValue(m.rawContent) m.textarea.Focus() @@ -157,21 +156,21 @@ func (m *NoteView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.vp, cmd = m.vp.Update(msg) return m, cmd - case loadNote: - if m.path == msg.path && !msg.force { + case LoadNoteMsg: + if m.Path == msg.Path && !msg.Force { return m, nil //noop } - m.path = msg.path + m.Path = msg.Path m.isEditing = false m.loading = true m.currentSectionIndex = 0 - return m, fetchContent(msg.path) + return m, fetchContent(msg.Path) - case loadedNote: + case LoadedNote: m.loading = false - m.rawContent = msg.rawContent - m.sections = msg.sections - m.err = msg.err + m.rawContent = msg.RawContent + m.sections = msg.Sections + m.err = msg.Err m.currentSectionIndex = 0 m.viewState = StateTitleOnly m.vp.SetContent(m.renderNote()) @@ -193,7 +192,7 @@ func (m NoteView) View() string { if m.loading { return "loading..." } - if m.path == "" { + if m.Path == "" { return "" } @@ -222,20 +221,20 @@ func fetchContent(path string) tea.Cmd { return func() tea.Msg { data, err := os.ReadFile(path) if err != nil { - return loadedNote{err: err} + return LoadedNote{Err: err} } rawContent := string(data) - sections := parseSections(data) + sections := ParseSections(data) - return loadedNote{ - rawContent: rawContent, - sections: sections, + return LoadedNote{ + RawContent: rawContent, + Sections: sections, } } } -func parseSections(source []byte) []Section { +func ParseSections(source []byte) []Section { md := goldmark.New() reader := text.NewReader(source) doc := md.Parser().Parse(reader) @@ -259,7 +258,7 @@ func parseSections(source []byte) []Section { // you have a heading and a content to accumulte over contentsRaw := source[lastPos:contentEnd] contents := strings.TrimSpace(string(contentsRaw)) - hints := extractHints(contents) + hints := ExtractHints(contents) sections = append(sections, Section{ Title: title, Content: contents, @@ -276,7 +275,7 @@ func parseSections(source []byte) []Section { if lastPos < len(source) { contentsRaw := source[lastPos:] contents := strings.TrimSpace(string(contentsRaw)) - hints := extractHints(contents) + hints := ExtractHints(contents) sections = append(sections, Section{ Title: title, Content: contents, @@ -291,13 +290,13 @@ func saveContent(path, content string) tea.Cmd { return func() tea.Msg { err := os.WriteFile(path, []byte(content), 0644) if err != nil { - return loadedNote{err: err} + return LoadedNote{Err: err} } return fetchContent(path)() } } -func extractHints(content string) []string { +func ExtractHints(content string) []string { re := regexp.MustCompile(`(?s)\*\*(.*?)\*\*|__(.*?)__`) matches := re.FindAllStringSubmatch(content, -1) hints := make([]string, 0) @@ -312,7 +311,7 @@ func extractHints(content string) []string { } func (m NoteView) renderNote() string { - if m.path == "" { + if m.Path == "" { return "" // no note is loaded, don't need to bother with anything } var titleText, contentText string diff --git a/notes_test.go b/internal/ui/note/model_test.go similarity index 92% rename from notes_test.go rename to internal/ui/note/model_test.go index 219fa80..77848c7 100644 --- a/notes_test.go +++ b/internal/ui/note/model_test.go @@ -1,4 +1,4 @@ -package main +package note import ( "reflect" @@ -41,9 +41,9 @@ func TestExtractHints(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := extractHints(tt.content) + got := ExtractHints(tt.content) if !reflect.DeepEqual(got, tt.expected) { - t.Errorf("extractHints() = %v, want %v", got, tt.expected) + t.Errorf("ExtractHints() = %v, want %v", got, tt.expected) } }) } @@ -58,7 +58,7 @@ Content 1 with **hint1**. Content 2 with __hint2__. `) - sections := parseSections(content) + sections := ParseSections(content) if len(sections) != 2 { t.Fatalf("expected 2 sections, got %d", len(sections)) @@ -92,7 +92,7 @@ func TestParseSectionsNoHeaders(t *testing.T) { content := []byte(`Just some content without any headers. It has **one hint**.`) - sections := parseSections(content) + sections := ParseSections(content) if len(sections) != 1 { t.Fatalf("expected 1 section, got %d", len(sections)) diff --git a/main.go b/main.go index 037ab82..e2d58f4 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,9 @@ import ( "os" "os/exec" + "mend/internal/ui/fstree" + "mend/internal/ui/note" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -12,25 +15,7 @@ import ( /* Quick notes to self: - -# How bubbletea works? -Bubbletea is a -Init ( once for sync/async opts both ) --> Update ( model ) ( againc an be sync/async ) --> View loop -init creates initial model ( model is the global ui state and just a struct ) -update modifies model based on an immediate or deferred compute -view is purely for rendering based on model - -notes: -- model struct next -> global ui model -- createModel is the initial sync model state population -I anyways need to create the model struct even if blank -- Init func is for bubbletea to call once at start so both are needed -- all widths and heights are character count based -- async updates are via cmds (tea.Cmd ) that are no arg funcs that return -a tea.Msg ( that is basically a struct and hence has the data needed ) -this data in msg when returned to the update func is used to update the model +... (comments preserved) */ type model struct { @@ -39,10 +24,10 @@ type model struct { terminalHeight int fsTreeWidth int noteViewWidth int - tree *FsTree + tree *fstree.FsTree rootPath string // path to load the tree from loading bool - noteView *NoteView + noteView *note.NoteView isDragging bool isHoveringDivider bool contentHeight int @@ -50,7 +35,7 @@ type model struct { // input handling textInput textinput.Model inputMode bool - pendingAction FsActionType + pendingAction fstree.FsActionType } func NewModel(rootPath string) *model { @@ -61,7 +46,7 @@ func NewModel(rootPath string) *model { return &model{ rootPath: rootPath, loading: true, - noteView: NewNoteView(), + noteView: note.NewNoteView(), showStatusBar: false, textInput: ti, } @@ -71,7 +56,7 @@ func NewModel(rootPath string) *model { // these need to be on the "model" ( duck typing "implements" interface ) type treeLoadedMsg struct { - tree *FsTree + tree *fstree.FsTree } func (m *model) loadTreeCmd(path string) tea.Cmd { @@ -87,7 +72,7 @@ func (m *model) loadTreeCmd(path string) tea.Cmd { } else { targetPath = path } - return treeLoadedMsg{tree: NewFsTree(targetPath, fsTreeStartOffset)} + return treeLoadedMsg{tree: fstree.NewFsTree(targetPath, fsTreeStartOffset)} } } @@ -141,40 +126,40 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) return m, cmd - case nodeSelected: + case fstree.NodeSelectedMsg: // Forward node selection to noteView - _, cmd := m.noteView.Update(loadNote{path: msg.path}) + _, cmd := m.noteView.Update(note.LoadNoteMsg{Path: msg.Path}) return m, cmd - case loadNote: + case note.LoadNoteMsg: _, cmd := m.noteView.Update(msg) - if msg.force { + if msg.Force { return m, tea.Batch(cmd, tea.EnableMouseAllMotion) } return m, cmd - case loadedNote: + case note.LoadedNote: // Forward loaded note to noteView _, cmd := m.noteView.Update(msg) return m, cmd - case PerformActionMsg: + case fstree.PerformActionMsg: if m.tree != nil { _, cmd := m.tree.Update(msg) return m, cmd } - case RequestInputMsg: + case fstree.RequestInputMsg: m.inputMode = true m.pendingAction = msg.Action m.textInput.Focus() m.textInput.SetValue("") switch msg.Action { - case ActionNewFile: + case fstree.ActionNewFile: m.textInput.Placeholder = "New File Name" - case ActionNewFolder: + case fstree.ActionNewFolder: m.textInput.Placeholder = "New Folder Name" - case ActionNewRoot: + case fstree.ActionNewRoot: m.textInput.Placeholder = "New Root Folder Name" } m.layout(m.terminalWidth, m.terminalHeight) // recalc layout for status bar area @@ -192,7 +177,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := []tea.Cmd{m.resizeChildren()} if val != "" { cmds = append(cmds, func() tea.Msg { - return PerformActionMsg{ + return fstree.PerformActionMsg{ Action: m.pendingAction, Name: val, } @@ -224,16 +209,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.layout(m.terminalWidth, m.terminalHeight) return m, m.resizeChildren() case "o": - if m.tree != nil && m.tree.selectedNode != nil && m.tree.selectedNode.nodeType == FileNode { - c := exec.Command("micro", m.tree.selectedNode.path) + if m.tree != nil && m.tree.SelectedNode != nil && m.tree.SelectedNode.Type == fstree.FileNode { + c := exec.Command("micro", m.tree.SelectedNode.Path) c.Stdin = os.Stdin c.Stdout = os.Stdout c.Stderr = os.Stderr return m, tea.ExecProcess(c, func(err error) tea.Msg { // mouse loses focus so this is neededd - return loadNote{path: m.tree.selectedNode.path, force: true} + return note.LoadNoteMsg{Path: m.tree.SelectedNode.Path, Force: true} }) } + case "delete": + // Forward delete to fstree if focused (implied focus on tree for now when not editing) + if m.tree != nil { + _, cmd := m.tree.Update(msg) + return m, cmd + } } var cmds []tea.Cmd @@ -340,8 +331,8 @@ func (m model) View() string { statusContent := "" if m.inputMode { statusContent = m.textInput.View() - } else if m.tree != nil && m.tree.errMsg != "" { - statusContent = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(m.tree.errMsg) + } else if m.tree != nil && m.tree.ErrMsg != "" { + statusContent = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(m.tree.ErrMsg) } statusBar := lipgloss.NewStyle().