This commit is contained in:
2026-01-18 02:10:48 +00:00
parent 89e62ad65a
commit e9225b0018
10 changed files with 309 additions and 303 deletions
+17
View File
@@ -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)
+7 -2
View File
@@ -1,5 +1,10 @@
my take on how notes and srs should be combind and simplified 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`
-103
View File
@@ -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)
}
+42
View File
@@ -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)
}
+10 -10
View File
@@ -1,4 +1,4 @@
package main package filesystem
import ( import (
"os" "os"
@@ -16,7 +16,7 @@ func TestCreateFile(t *testing.T) {
// test valid creation // test valid creation
filePath := filepath.Join(tmpDir, "test.md") filePath := filepath.Join(tmpDir, "test.md")
err = createFile(filePath, []byte("content")) err = CreateFile(filePath, []byte("content"))
if err != nil { if err != nil {
t.Errorf("expected no error, got %v", err) t.Errorf("expected no error, got %v", err)
} }
@@ -26,13 +26,13 @@ func TestCreateFile(t *testing.T) {
} }
// test creation failure (exists) // test creation failure (exists)
err = createFile(filePath, []byte("content")) err = CreateFile(filePath, []byte("content"))
if err == nil { if err == nil {
t.Error("expected error for existing file, got nil") t.Error("expected error for existing file, got nil")
} }
// test empty path // test empty path
err = createFile("", []byte{}) err = CreateFile("", []byte{})
if err == nil { if err == nil {
t.Error("expected error for empty path, got nil") t.Error("expected error for empty path, got nil")
} }
@@ -47,7 +47,7 @@ func TestCreateFolder(t *testing.T) {
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
folderPath := filepath.Join(tmpDir, "subfolder") folderPath := filepath.Join(tmpDir, "subfolder")
err = createFolder(folderPath) err = CreateFolder(folderPath)
if err != nil { if err != nil {
t.Errorf("expected no error, got %v", err) t.Errorf("expected no error, got %v", err)
} }
@@ -61,13 +61,13 @@ func TestCreateFolder(t *testing.T) {
} }
// test creation failure (exists) // test creation failure (exists)
err = createFolder(folderPath) err = CreateFolder(folderPath)
if err == nil { if err == nil {
t.Error("expected error for existing folder, got nil") t.Error("expected error for existing folder, got nil")
} }
// test empty path // test empty path
err = createFolder("") err = CreateFolder("")
if err == nil { if err == nil {
t.Error("expected error for empty path, got 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") fileInSubDir := filepath.Join(subDir, "file.txt")
os.WriteFile(fileInSubDir, []byte("data"), 0644) os.WriteFile(fileInSubDir, []byte("data"), 0644)
err = deletePath(subDir) err = DeletePath(subDir)
if err != nil { if err != nil {
t.Errorf("expected no error, got %v", err) t.Errorf("expected no error, got %v", err)
} }
@@ -96,13 +96,13 @@ func TestDeletePath(t *testing.T) {
} }
// test delete non-existent // test delete non-existent
err = deletePath(filepath.Join(tmpDir, "nonexistent")) err = DeletePath(filepath.Join(tmpDir, "nonexistent"))
if err == nil { if err == nil {
t.Error("expected error for nonexistent path, got nil") t.Error("expected error for nonexistent path, got nil")
} }
// test empty path // test empty path
err = deletePath("") err = DeletePath("")
if err == nil { if err == nil {
t.Error("expected error for empty path, got nil") t.Error("expected error for empty path, got nil")
} }
+143 -88
View File
@@ -1,16 +1,16 @@
/* /*
This file contains the implementation of an in-memory tree of files and folders that the app used. 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. It is initialised at startup and used for state changes in UI and later persisted to disk.
- ruinivist, 30Dec25
*/ */
package main package fstree
import ( import (
"errors" "errors"
"mend/internal/filesystem"
"mend/styles" "mend/styles"
"mend/utils" "mend/utils"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -30,28 +30,28 @@ const (
// a single node, Fs => deals with file system related info mostly // a single node, Fs => deals with file system related info mostly
// a name for example can be content derived as well // a name for example can be content derived as well
type FsNode struct { type FsNode struct {
nodeType FsNodeType Type FsNodeType
path string Path string
children []*FsNode Children []*FsNode
parent *FsNode // for fast traversal up the tree Parent *FsNode // for fast traversal up the tree
expanded bool // makes sense only for folder nodes Expanded bool // makes sense only for folder nodes
// these are populated by buildLines for fast access // these are populated by BuildLines for fast access
line int line int
prev *FsNode prev *FsNode
next *FsNode next *FsNode
} }
func (n *FsNode) FileName() string { func (n *FsNode) FileName() string {
name := filepath.Base(n.path) name := filepath.Base(n.Path)
if n.nodeType == FileNode { if n.Type == FileNode {
return strings.TrimSuffix(name, ".md") return strings.TrimSuffix(name, ".md")
} }
return name return name
} }
// ================== messages =================== // ================== messages ===================
type nodeSelected struct { type NodeSelectedMsg struct {
path string Path string
} }
type FsActionType int type FsActionType int
@@ -73,11 +73,11 @@ type PerformActionMsg struct {
// ==================== FsNode definition ==================== // ==================== FsNode definition ====================
type FsTree struct { 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 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 hoveredNode *FsNode
errMsg string ErrMsg string
height int height int
width int width int
viewStart int viewStart int
@@ -97,13 +97,13 @@ func (t *FsTree) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case PerformActionMsg: case PerformActionMsg:
err := t.PerformAction(m.Action, m.Name) err := t.PerformAction(m.Action, m.Name)
if err != nil { if err != nil {
t.errMsg = err.Error() t.ErrMsg = err.Error()
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
t.width = m.Width t.width = m.Width
t.height = m.Height t.height = m.Height
case tea.KeyMsg: case tea.KeyMsg:
t.errMsg = "" t.ErrMsg = ""
switch m.String() { switch m.String() {
case "w", "up": case "w", "up":
_ = t.MoveUp() _ = t.MoveUp()
@@ -118,9 +118,9 @@ func (t *FsTree) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "C": // new root node case "C": // new root node
return t, func() tea.Msg { return RequestInputMsg{Action: ActionNewRoot} } return t, func() tea.Msg { return RequestInputMsg{Action: ActionNewRoot} }
case "delete": // delete node case "delete": // delete node
err := t.DeleteNode(t.selectedNode) err := t.DeleteNode(t.SelectedNode)
if err != nil { 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 { if m.X >= t.width {
break break
} }
t.errMsg = "" t.ErrMsg = ""
t.hoveredNode = t.lines[m.Y] // hover t.hoveredNode = t.lines[m.Y] // hover
// click // click
if m.Button == tea.MouseButtonLeft && m.Action == tea.MouseActionPress { if m.Button == tea.MouseButtonLeft && m.Action == tea.MouseActionPress {
nodeAtLine := t.lines[m.Y] nodeAtLine := t.lines[m.Y]
if nodeAtLine != nil { if nodeAtLine != nil {
t.selectedNode = nodeAtLine t.SelectedNode = nodeAtLine
if nodeAtLine.nodeType == FolderNode { if nodeAtLine.Type == FolderNode {
_ = t.ToggleExpand(nodeAtLine) _ = t.ToggleExpand(nodeAtLine)
} }
} }
@@ -155,10 +155,10 @@ func (t *FsTree) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
t.viewStart, t.viewEnd = t.getViewportBounds() t.viewStart, t.viewEnd = t.getViewportBounds()
if t.oldSelected != t.selectedNode && t.selectedNode.nodeType == FileNode { if t.oldSelected != t.SelectedNode && t.SelectedNode.Type == FileNode {
t.oldSelected = t.selectedNode t.oldSelected = t.SelectedNode
return t, func() tea.Msg { return t, func() tea.Msg {
return nodeSelected{path: t.selectedNode.path} return NodeSelectedMsg{Path: t.SelectedNode.Path}
} }
} }
return t, nil 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 { func (t *FsTree) PerformAction(action FsActionType, name string) error {
switch action { switch action {
case ActionNewFile: case ActionNewFile:
return t.CreateNode(t.selectedNode, name, FileNode) return t.CreateNode(t.SelectedNode, name, FileNode)
case ActionNewFolder: case ActionNewFolder:
return t.CreateNode(t.selectedNode, name, FolderNode) return t.CreateNode(t.SelectedNode, name, FolderNode)
case ActionNewRoot: case ActionNewRoot:
return t.CreateNode(t.root, name, FolderNode) return t.CreateNode(t.Root, name, FolderNode)
} }
return nil return nil
} }
func (t *FsTree) getViewportBounds() (startLine, endLine int) { func (t *FsTree) getViewportBounds() (startLine, endLine int) {
if t.selectedNode == nil { if t.SelectedNode == nil {
return 0, 0 // doesn't amtter in this case return 0, 0 // doesn't amtter in this case
} }
selectedLine := t.selectedNode.line selectedLine := t.SelectedNode.line
halfHeight := t.height / 2 halfHeight := t.height / 2
startLine = max(0, selectedLine-halfHeight) startLine = max(0, selectedLine-halfHeight)
@@ -198,16 +198,16 @@ func (t *FsTree) getViewportBounds() (startLine, endLine int) {
} }
func (t *FsTree) View() string { func (t *FsTree) View() string {
if t.errMsg != "" { if t.ErrMsg != "" {
return 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" return "no files/folders\nPress C to create"
} }
builder := &strings.Builder{} builder := &strings.Builder{}
t.renderNode(t.root, 0, builder) t.renderNode(t.Root, 0, builder)
rendered := builder.String() rendered := builder.String()
lines := strings.Split(rendered, "\n") lines := strings.Split(rendered, "\n")
@@ -226,21 +226,21 @@ func (t *FsTree) View() string {
func NewFsTree(rootPath string, startOffset int) *FsTree { func NewFsTree(rootPath string, startOffset int) *FsTree {
root := &FsNode{ root := &FsNode{
nodeType: FolderNode, Type: FolderNode,
path: rootPath, Path: rootPath,
children: make([]*FsNode, 0), Children: make([]*FsNode, 0),
expanded: true, Expanded: true,
} }
walkFileSystemAndBuildTree(rootPath, root) WalkFileSystemAndBuildTree(rootPath, root)
tree := &FsTree{ tree := &FsTree{
root: root, Root: root,
startOffset: startOffset, startOffset: startOffset,
} }
if len(root.children) > 0 { if len(root.Children) > 0 {
tree.selectedNode = root.children[0] tree.SelectedNode = root.Children[0]
} }
tree.buildLines() tree.BuildLines()
return tree return tree
} }
@@ -248,27 +248,27 @@ func (t *FsTree) DeleteNode(node *FsNode) error {
if node == nil { if node == nil {
return errors.New("node to delete cannot be nil") return errors.New("node to delete cannot be nil")
} }
parent := node.parent parent := node.Parent
if parent == nil { if parent == nil {
return errors.New("node to delete must have a parent") return errors.New("node to delete must have a parent")
} }
// materialise // materialise
if err := deletePath(node.path); err != nil { if err := filesystem.DeletePath(node.Path); err != nil {
return err return err
} }
t.selectedNode = node.prev // cannot be next as subfolder/file deletion t.SelectedNode = node.prev // cannot be next as subfolder/file deletion
parent.children = utils.RemoveFromSlice(parent.children, node) 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 // can only happen if first root level node is deleted
if len(t.root.children) > 0 { if len(t.Root.Children) > 0 {
t.selectedNode = t.root.children[0] t.SelectedNode = t.Root.Children[0]
} }
} }
t.buildLines() t.BuildLines()
return nil return nil
} }
@@ -276,17 +276,17 @@ func (t *FsTree) ToggleExpand(node *FsNode) error {
if node == nil { if node == nil {
return errors.New("node cannot be 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") return errors.New("only folder nodes can be expanded or collapsed")
} }
node.expanded = !node.expanded node.Expanded = !node.Expanded
t.buildLines() t.BuildLines()
return nil return nil
} }
func (t *FsTree) move(delta int) error { func (t *FsTree) move(delta int) error {
selected := t.selectedNode selected := t.SelectedNode
if selected == nil { if selected == nil {
return errors.New("no node is currently selected") return errors.New("no node is currently selected")
} }
@@ -301,7 +301,7 @@ func (t *FsTree) move(delta int) error {
next = selected.prev next = selected.prev
} }
if next != nil { if next != nil {
t.selectedNode = next t.SelectedNode = next
} }
return nil 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) MoveDown() error { return t.move(1) }
func (t *FsTree) ToggleSelectedExpand() error { func (t *FsTree) ToggleSelectedExpand() error {
if t.selectedNode == nil { if t.SelectedNode == nil {
return errors.New("no node is currently selected") 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") return errors.New("only folder nodes can be expanded or collapsed")
} }
t.ToggleExpand(t.selectedNode) t.ToggleExpand(t.SelectedNode)
return nil return nil
} }
@@ -327,7 +327,7 @@ func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) {
} }
folderInRoot := false folderInRoot := false
if node.nodeType == FolderNode && depth == 1 && node.prev != nil { if node.Type == FolderNode && depth == 1 && node.prev != nil {
folderInRoot = true folderInRoot = true
} }
@@ -335,9 +335,9 @@ func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) {
prevIndent := strings.Repeat(" ", max(depth-1, 0)) prevIndent := strings.Repeat(" ", max(depth-1, 0))
var icon string var icon string
switch node.nodeType { switch node.Type {
case FolderNode: case FolderNode:
if node.expanded { if node.Expanded {
icon = indent + styles.ArrowDownIcon icon = indent + styles.ArrowDownIcon
} else { } else {
icon = indent + styles.ArrowRightIcon icon = indent + styles.ArrowRightIcon
@@ -350,7 +350,7 @@ func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) {
// highlight if selected or hovered // highlight if selected or hovered
// note: the logic of lines cache needs to match render // note: the logic of lines cache needs to match render
fileName := node.FileName() fileName := node.FileName()
isSelected := node == t.selectedNode isSelected := node == t.SelectedNode
isHovered := node == t.hoveredNode isHovered := node == t.hoveredNode
if isSelected { if isSelected {
@@ -368,19 +368,19 @@ func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) {
builder.WriteString(line) builder.WriteString(line)
} }
if node.expanded { if node.Expanded {
for _, child := range node.children { for _, child := range node.Children {
t.renderNode(child, depth+1, builder) t.renderNode(child, depth+1, builder)
} }
} }
} }
// builds a cache of line num to rendered node in view // 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) t.lines = make(map[int]*FsNode)
line := -1 line := -1
flatTree := make([]*FsNode, 0) 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 // everything is a child of root
flatTree[0] = nil // basically a merge of skip root and padding ends with nil flatTree[0] = nil // basically a merge of skip root and padding ends with nil
flatTree = append(flatTree, nil) flatTree = append(flatTree, nil)
@@ -398,7 +398,7 @@ func (t *FsTree) buildLinesRec(node *FsNode, depth int, currentLine *int, flatTr
return return
} }
if node.nodeType == FolderNode && depth == 1 && *currentLine != 0 { if node.Type == FolderNode && depth == 1 && *currentLine != 0 {
(*currentLine)++ (*currentLine)++
} }
t.lines[*currentLine] = node t.lines[*currentLine] = node
@@ -406,8 +406,8 @@ func (t *FsTree) buildLinesRec(node *FsNode, depth int, currentLine *int, flatTr
*flatTree = append(*flatTree, node) *flatTree = append(*flatTree, node)
(*currentLine)++ (*currentLine)++
if node.expanded { if node.Expanded {
for _, child := range node.children { for _, child := range node.Children {
t.buildLinesRec(child, depth+1, currentLine, flatTree) 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") return errors.New("folder node cannot be nil")
} }
if folder.nodeType == FileNode { if folder.Type == FileNode {
folder = folder.parent 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") return errors.New("parent node must be a folder. this should not be allowed by ui")
} else if folder.nodeType == FolderNode && !folder.expanded { } else if folder.Type == FolderNode && !folder.Expanded {
folder.expanded = true folder.Expanded = true
} }
if name == "" { if name == "" {
@@ -436,16 +436,16 @@ func (t *FsTree) CreateNode(folder *FsNode, name string, nodeType FsNodeType) er
name += ".md" name += ".md"
} }
path := filepath.Join(folder.path, name) path := filepath.Join(folder.Path, name)
// materialise it first // materialise it first
switch nodeType { switch nodeType {
case FileNode: case FileNode:
err := createFile(path, []byte{}) err := filesystem.CreateFile(path, []byte{})
if err != nil { if err != nil {
return err return err
} }
case FolderNode: case FolderNode:
err := createFolder(path) err := filesystem.CreateFolder(path)
if err != nil { if err != nil {
return err return err
} }
@@ -456,19 +456,74 @@ func (t *FsTree) CreateNode(folder *FsNode, name string, nodeType FsNodeType) er
expanded = true expanded = true
} }
newNode := &FsNode{ newNode := &FsNode{
nodeType: nodeType, Type: nodeType,
path: path, Path: path,
children: make([]*FsNode, 0), Children: make([]*FsNode, 0),
parent: folder, Parent: folder,
expanded: expanded, Expanded: expanded,
} }
// files are first of children, folder last of children // files are first of children, folder last of children
if nodeType == FileNode { if nodeType == FileNode {
folder.children = append([]*FsNode{newNode}, folder.children...) folder.Children = append([]*FsNode{newNode}, folder.Children...)
} else { } else {
folder.children = append(folder.children, newNode) folder.Children = append(folder.Children, newNode)
} }
t.selectedNode = newNode t.SelectedNode = newNode
t.buildLines() 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 return nil
} }
@@ -1,4 +1,4 @@
package main package fstree
import ( import (
"os" "os"
@@ -29,12 +29,12 @@ func TestNewFsTree(t *testing.T) {
if tree == nil { if tree == nil {
t.Fatal("expected tree to be created, got nil") t.Fatal("expected tree to be created, got nil")
} }
if tree.root.path != tmpDir { if tree.Root.Path != tmpDir {
t.Errorf("expected root path %s, got %s", tmpDir, tree.root.path) t.Errorf("expected root path %s, got %s", tmpDir, tree.Root.Path)
} }
// root children: folder1, file2.md // root children: folder1, file2.md
if len(tree.root.children) != 2 { if len(tree.Root.Children) != 2 {
t.Errorf("expected 2 children, got %d", len(tree.root.children)) t.Errorf("expected 2 children, got %d", len(tree.Root.Children))
} }
} }
@@ -48,7 +48,7 @@ func TestTreeCreateNode(t *testing.T) {
tree := NewFsTree(tmpDir, 0) tree := NewFsTree(tmpDir, 0)
// create folder // create folder
err = tree.CreateNode(tree.root, "new_folder", FolderNode) err = tree.CreateNode(tree.Root, "new_folder", FolderNode)
if err != nil { if err != nil {
t.Errorf("expected no error creating folder, got %v", err) 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") t.Error("folder not created on fs")
} }
// check tree // 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") t.Error("tree block not updated with new folder")
} }
// create file in new folder // create file in new folder
newFolder := tree.root.children[0] newFolder := tree.Root.Children[0]
err = tree.CreateNode(newFolder, "new_file", FileNode) err = tree.CreateNode(newFolder, "new_file", FileNode)
if err != nil { if err != nil {
t.Errorf("expected no error creating file, got %v", err) 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") t.Error("file not created on fs")
} }
// check tree // 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") 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) os.WriteFile(filepath.Join(tmpDir, "file.md"), []byte(""), 0644)
tree := NewFsTree(tmpDir, 0) tree := NewFsTree(tmpDir, 0)
targetNode := tree.root.children[0] targetNode := tree.Root.Children[0]
err = tree.DeleteNode(targetNode) err = tree.DeleteNode(targetNode)
if err != nil { if err != nil {
@@ -102,7 +102,7 @@ func TestTreeDeleteNode(t *testing.T) {
t.Error("file not deleted from fs") t.Error("file not deleted from fs")
} }
// check tree // check tree
if len(tree.root.children) != 0 { if len(tree.Root.Children) != 0 {
t.Error("tree node not updated after deletion") t.Error("tree node not updated after deletion")
} }
} }
@@ -110,30 +110,30 @@ func TestTreeDeleteNode(t *testing.T) {
// tests toggling expand // tests toggling expand
func TestTreeToggleExpand(t *testing.T) { func TestTreeToggleExpand(t *testing.T) {
root := &FsNode{ root := &FsNode{
nodeType: FolderNode, Type: FolderNode,
expanded: true, Expanded: true,
children: []*FsNode{}, // Initialize children Children: []*FsNode{}, // Initialize children
} }
node := &FsNode{ node := &FsNode{
nodeType: FolderNode, Type: FolderNode,
expanded: true, Expanded: true,
parent: root, Parent: root,
} }
root.children = append(root.children, node) root.Children = append(root.Children, node)
// mocked tree for this test // mocked tree for this test
tree := &FsTree{ tree := &FsTree{
root: root, Root: root,
} }
// Initial buildLines to setup state // Initial buildLines to setup state
tree.buildLines() tree.BuildLines()
err := tree.ToggleExpand(node) err := tree.ToggleExpand(node)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
if node.expanded { if node.Expanded {
t.Error("expected node to be collapsed") t.Error("expected node to be collapsed")
} }
@@ -141,7 +141,7 @@ func TestTreeToggleExpand(t *testing.T) {
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
if !node.expanded { if !node.Expanded {
t.Error("expected node to be expanded") t.Error("expected node to be expanded")
} }
} }
+31 -32
View File
@@ -1,10 +1,9 @@
/* /*
this pakckage basically handles notes which are the individual files for spaced this pakckage basically handles notes which are the individual files for spaced
repetition in mend repetition in mend
- ruinivist, 3Jan26
*/ */
package main package note
import ( import (
"fmt" "fmt"
@@ -39,7 +38,7 @@ const (
type NoteView struct { type NoteView struct {
// the actual content // the actual content
path string Path string
rawContent string // full content for editing rawContent string // full content for editing
sections []Section sections []Section
currentSectionIndex int currentSectionIndex int
@@ -55,15 +54,15 @@ type NoteView struct {
} }
// ================== messages =================== // ================== messages ===================
type loadNote struct { type LoadNoteMsg struct {
path string Path string
force bool Force bool
} }
type loadedNote struct { type LoadedNote struct {
rawContent string RawContent string
sections []Section Sections []Section
err error Err error
} }
func NewNoteView() *NoteView { func NewNoteView() *NoteView {
@@ -107,7 +106,7 @@ func (m *NoteView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc", "ctrl+q": case "esc", "ctrl+q":
m.isEditing = false m.isEditing = false
return m, saveContent(m.path, m.textarea.Value()) return m, saveContent(m.Path, m.textarea.Value())
} }
var cmd tea.Cmd var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg) 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() { switch msg.String() {
case "enter": case "enter":
if m.path != "" && !m.loading { if m.Path != "" && !m.loading {
m.isEditing = true m.isEditing = true
m.textarea.SetValue(m.rawContent) m.textarea.SetValue(m.rawContent)
m.textarea.Focus() 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) m.vp, cmd = m.vp.Update(msg)
return m, cmd return m, cmd
case loadNote: case LoadNoteMsg:
if m.path == msg.path && !msg.force { if m.Path == msg.Path && !msg.Force {
return m, nil //noop return m, nil //noop
} }
m.path = msg.path m.Path = msg.Path
m.isEditing = false m.isEditing = false
m.loading = true m.loading = true
m.currentSectionIndex = 0 m.currentSectionIndex = 0
return m, fetchContent(msg.path) return m, fetchContent(msg.Path)
case loadedNote: case LoadedNote:
m.loading = false m.loading = false
m.rawContent = msg.rawContent m.rawContent = msg.RawContent
m.sections = msg.sections m.sections = msg.Sections
m.err = msg.err m.err = msg.Err
m.currentSectionIndex = 0 m.currentSectionIndex = 0
m.viewState = StateTitleOnly m.viewState = StateTitleOnly
m.vp.SetContent(m.renderNote()) m.vp.SetContent(m.renderNote())
@@ -193,7 +192,7 @@ func (m NoteView) View() string {
if m.loading { if m.loading {
return "loading..." return "loading..."
} }
if m.path == "" { if m.Path == "" {
return "" return ""
} }
@@ -222,20 +221,20 @@ func fetchContent(path string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return loadedNote{err: err} return LoadedNote{Err: err}
} }
rawContent := string(data) rawContent := string(data)
sections := parseSections(data) sections := ParseSections(data)
return loadedNote{ return LoadedNote{
rawContent: rawContent, RawContent: rawContent,
sections: sections, Sections: sections,
} }
} }
} }
func parseSections(source []byte) []Section { func ParseSections(source []byte) []Section {
md := goldmark.New() md := goldmark.New()
reader := text.NewReader(source) reader := text.NewReader(source)
doc := md.Parser().Parse(reader) doc := md.Parser().Parse(reader)
@@ -259,7 +258,7 @@ func parseSections(source []byte) []Section {
// you have a heading and a content to accumulte over // you have a heading and a content to accumulte over
contentsRaw := source[lastPos:contentEnd] contentsRaw := source[lastPos:contentEnd]
contents := strings.TrimSpace(string(contentsRaw)) contents := strings.TrimSpace(string(contentsRaw))
hints := extractHints(contents) hints := ExtractHints(contents)
sections = append(sections, Section{ sections = append(sections, Section{
Title: title, Title: title,
Content: contents, Content: contents,
@@ -276,7 +275,7 @@ func parseSections(source []byte) []Section {
if lastPos < len(source) { if lastPos < len(source) {
contentsRaw := source[lastPos:] contentsRaw := source[lastPos:]
contents := strings.TrimSpace(string(contentsRaw)) contents := strings.TrimSpace(string(contentsRaw))
hints := extractHints(contents) hints := ExtractHints(contents)
sections = append(sections, Section{ sections = append(sections, Section{
Title: title, Title: title,
Content: contents, Content: contents,
@@ -291,13 +290,13 @@ func saveContent(path, content string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
err := os.WriteFile(path, []byte(content), 0644) err := os.WriteFile(path, []byte(content), 0644)
if err != nil { if err != nil {
return loadedNote{err: err} return LoadedNote{Err: err}
} }
return fetchContent(path)() return fetchContent(path)()
} }
} }
func extractHints(content string) []string { func ExtractHints(content string) []string {
re := regexp.MustCompile(`(?s)\*\*(.*?)\*\*|__(.*?)__`) re := regexp.MustCompile(`(?s)\*\*(.*?)\*\*|__(.*?)__`)
matches := re.FindAllStringSubmatch(content, -1) matches := re.FindAllStringSubmatch(content, -1)
hints := make([]string, 0) hints := make([]string, 0)
@@ -312,7 +311,7 @@ func extractHints(content string) []string {
} }
func (m NoteView) renderNote() string { func (m NoteView) renderNote() string {
if m.path == "" { if m.Path == "" {
return "" // no note is loaded, don't need to bother with anything return "" // no note is loaded, don't need to bother with anything
} }
var titleText, contentText string var titleText, contentText string
@@ -1,4 +1,4 @@
package main package note
import ( import (
"reflect" "reflect"
@@ -41,9 +41,9 @@ func TestExtractHints(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := extractHints(tt.content) got := ExtractHints(tt.content)
if !reflect.DeepEqual(got, tt.expected) { 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__. Content 2 with __hint2__.
`) `)
sections := parseSections(content) sections := ParseSections(content)
if len(sections) != 2 { if len(sections) != 2 {
t.Fatalf("expected 2 sections, got %d", len(sections)) 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. content := []byte(`Just some content without any headers.
It has **one hint**.`) It has **one hint**.`)
sections := parseSections(content) sections := ParseSections(content)
if len(sections) != 1 { if len(sections) != 1 {
t.Fatalf("expected 1 section, got %d", len(sections)) t.Fatalf("expected 1 section, got %d", len(sections))
+32 -41
View File
@@ -5,6 +5,9 @@ import (
"os" "os"
"os/exec" "os/exec"
"mend/internal/ui/fstree"
"mend/internal/ui/note"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -12,25 +15,7 @@ import (
/* /*
Quick notes to self: Quick notes to self:
... (comments preserved)
# 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
*/ */
type model struct { type model struct {
@@ -39,10 +24,10 @@ type model struct {
terminalHeight int terminalHeight int
fsTreeWidth int fsTreeWidth int
noteViewWidth int noteViewWidth int
tree *FsTree tree *fstree.FsTree
rootPath string // path to load the tree from rootPath string // path to load the tree from
loading bool loading bool
noteView *NoteView noteView *note.NoteView
isDragging bool isDragging bool
isHoveringDivider bool isHoveringDivider bool
contentHeight int contentHeight int
@@ -50,7 +35,7 @@ type model struct {
// input handling // input handling
textInput textinput.Model textInput textinput.Model
inputMode bool inputMode bool
pendingAction FsActionType pendingAction fstree.FsActionType
} }
func NewModel(rootPath string) *model { func NewModel(rootPath string) *model {
@@ -61,7 +46,7 @@ func NewModel(rootPath string) *model {
return &model{ return &model{
rootPath: rootPath, rootPath: rootPath,
loading: true, loading: true,
noteView: NewNoteView(), noteView: note.NewNoteView(),
showStatusBar: false, showStatusBar: false,
textInput: ti, textInput: ti,
} }
@@ -71,7 +56,7 @@ func NewModel(rootPath string) *model {
// these need to be on the "model" ( duck typing "implements" interface ) // these need to be on the "model" ( duck typing "implements" interface )
type treeLoadedMsg struct { type treeLoadedMsg struct {
tree *FsTree tree *fstree.FsTree
} }
func (m *model) loadTreeCmd(path string) tea.Cmd { func (m *model) loadTreeCmd(path string) tea.Cmd {
@@ -87,7 +72,7 @@ func (m *model) loadTreeCmd(path string) tea.Cmd {
} else { } else {
targetPath = path 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 return m, cmd
case nodeSelected: case fstree.NodeSelectedMsg:
// Forward node selection to noteView // 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 return m, cmd
case loadNote: case note.LoadNoteMsg:
_, cmd := m.noteView.Update(msg) _, cmd := m.noteView.Update(msg)
if msg.force { if msg.Force {
return m, tea.Batch(cmd, tea.EnableMouseAllMotion) return m, tea.Batch(cmd, tea.EnableMouseAllMotion)
} }
return m, cmd return m, cmd
case loadedNote: case note.LoadedNote:
// Forward loaded note to noteView // Forward loaded note to noteView
_, cmd := m.noteView.Update(msg) _, cmd := m.noteView.Update(msg)
return m, cmd return m, cmd
case PerformActionMsg: case fstree.PerformActionMsg:
if m.tree != nil { if m.tree != nil {
_, cmd := m.tree.Update(msg) _, cmd := m.tree.Update(msg)
return m, cmd return m, cmd
} }
case RequestInputMsg: case fstree.RequestInputMsg:
m.inputMode = true m.inputMode = true
m.pendingAction = msg.Action m.pendingAction = msg.Action
m.textInput.Focus() m.textInput.Focus()
m.textInput.SetValue("") m.textInput.SetValue("")
switch msg.Action { switch msg.Action {
case ActionNewFile: case fstree.ActionNewFile:
m.textInput.Placeholder = "New File Name" m.textInput.Placeholder = "New File Name"
case ActionNewFolder: case fstree.ActionNewFolder:
m.textInput.Placeholder = "New Folder Name" m.textInput.Placeholder = "New Folder Name"
case ActionNewRoot: case fstree.ActionNewRoot:
m.textInput.Placeholder = "New Root Folder Name" m.textInput.Placeholder = "New Root Folder Name"
} }
m.layout(m.terminalWidth, m.terminalHeight) // recalc layout for status bar area 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()} cmds := []tea.Cmd{m.resizeChildren()}
if val != "" { if val != "" {
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
return PerformActionMsg{ return fstree.PerformActionMsg{
Action: m.pendingAction, Action: m.pendingAction,
Name: val, Name: val,
} }
@@ -224,16 +209,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.layout(m.terminalWidth, m.terminalHeight) m.layout(m.terminalWidth, m.terminalHeight)
return m, m.resizeChildren() return m, m.resizeChildren()
case "o": case "o":
if m.tree != nil && m.tree.selectedNode != nil && m.tree.selectedNode.nodeType == FileNode { if m.tree != nil && m.tree.SelectedNode != nil && m.tree.SelectedNode.Type == fstree.FileNode {
c := exec.Command("micro", m.tree.selectedNode.path) c := exec.Command("micro", m.tree.SelectedNode.Path)
c.Stdin = os.Stdin c.Stdin = os.Stdin
c.Stdout = os.Stdout c.Stdout = os.Stdout
c.Stderr = os.Stderr c.Stderr = os.Stderr
return m, tea.ExecProcess(c, func(err error) tea.Msg { return m, tea.ExecProcess(c, func(err error) tea.Msg {
// mouse loses focus so this is neededd // 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 var cmds []tea.Cmd
@@ -340,8 +331,8 @@ func (m model) View() string {
statusContent := "" statusContent := ""
if m.inputMode { if m.inputMode {
statusContent = m.textInput.View() statusContent = m.textInput.View()
} else if m.tree != nil && m.tree.errMsg != "" { } else if m.tree != nil && m.tree.ErrMsg != "" {
statusContent = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(m.tree.errMsg) statusContent = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(m.tree.ErrMsg)
} }
statusBar := lipgloss.NewStyle(). statusBar := lipgloss.NewStyle().