refactor
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tests creating a file in a temp dir
|
||||
func TestCreateFile(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mend_fs_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// test valid creation
|
||||
filePath := filepath.Join(tmpDir, "test.md")
|
||||
err = CreateFile(filePath, []byte("content"))
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
t.Error("file was not created")
|
||||
}
|
||||
|
||||
// test creation failure (exists)
|
||||
err = CreateFile(filePath, []byte("content"))
|
||||
if err == nil {
|
||||
t.Error("expected error for existing file, got nil")
|
||||
}
|
||||
|
||||
// test empty path
|
||||
err = CreateFile("", []byte{})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// tests creating a folder
|
||||
func TestCreateFolder(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mend_fs_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
folderPath := filepath.Join(tmpDir, "subfolder")
|
||||
err = CreateFolder(folderPath)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(folderPath)
|
||||
if os.IsNotExist(err) {
|
||||
t.Error("folder was not created")
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Error("created path is not a directory")
|
||||
}
|
||||
|
||||
// test creation failure (exists)
|
||||
err = CreateFolder(folderPath)
|
||||
if err == nil {
|
||||
t.Error("expected error for existing folder, got nil")
|
||||
}
|
||||
|
||||
// test empty path
|
||||
err = CreateFolder("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// tests recursive deletion
|
||||
func TestDeletePath(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mend_fs_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
os.Mkdir(subDir, 0755)
|
||||
fileInSubDir := filepath.Join(subDir, "file.txt")
|
||||
os.WriteFile(fileInSubDir, []byte("data"), 0644)
|
||||
|
||||
err = DeletePath(subDir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(subDir); !os.IsNotExist(err) {
|
||||
t.Error("directory was not deleted")
|
||||
}
|
||||
|
||||
// test delete non-existent
|
||||
err = DeletePath(filepath.Join(tmpDir, "nonexistent"))
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent path, got nil")
|
||||
}
|
||||
|
||||
// test empty path
|
||||
err = DeletePath("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty path, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
package fstree
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"mend/internal/filesystem"
|
||||
"mend/styles"
|
||||
"mend/utils"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// enums for node types
|
||||
type FsNodeType int
|
||||
|
||||
const (
|
||||
FileNode FsNodeType = iota
|
||||
FolderNode
|
||||
)
|
||||
|
||||
// ==================== FsNode definition ====================
|
||||
// a single node, Fs => deals with file system related info mostly
|
||||
// a name for example can be content derived as well
|
||||
type FsNode struct {
|
||||
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.Type == FileNode {
|
||||
return strings.TrimSuffix(name, ".md")
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// ================== messages ===================
|
||||
type NodeSelectedMsg struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
type FsActionType int
|
||||
|
||||
const (
|
||||
ActionNewFile FsActionType = iota
|
||||
ActionNewFolder
|
||||
ActionNewRoot
|
||||
)
|
||||
|
||||
type RequestInputMsg struct {
|
||||
Action FsActionType
|
||||
}
|
||||
|
||||
type PerformActionMsg struct {
|
||||
Action FsActionType
|
||||
Name string
|
||||
}
|
||||
|
||||
// ==================== FsNode definition ====================
|
||||
type FsTree struct {
|
||||
Root *FsNode
|
||||
lines map[int]*FsNode // flattened view of nodes for easy line access, map so that I can handle blank padding
|
||||
SelectedNode *FsNode
|
||||
hoveredNode *FsNode
|
||||
ErrMsg string
|
||||
height int
|
||||
width int
|
||||
viewStart int
|
||||
viewEnd int
|
||||
totalLines int
|
||||
oldSelected *FsNode
|
||||
startOffset int
|
||||
}
|
||||
|
||||
// ==================== Bubble Tea Interface Implementation ====================
|
||||
func (t *FsTree) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FsTree) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case PerformActionMsg:
|
||||
err := t.PerformAction(m.Action, m.Name)
|
||||
if err != nil {
|
||||
t.ErrMsg = err.Error()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
t.width = m.Width
|
||||
t.height = m.Height
|
||||
case tea.KeyMsg:
|
||||
t.ErrMsg = ""
|
||||
switch m.String() {
|
||||
case "w", "up":
|
||||
_ = t.MoveUp()
|
||||
case "s", "down":
|
||||
_ = t.MoveDown()
|
||||
case "e", "space":
|
||||
_ = t.ToggleSelectedExpand()
|
||||
case "n": // new file
|
||||
return t, func() tea.Msg { return RequestInputMsg{Action: ActionNewFile} }
|
||||
case "N": // new folder
|
||||
return t, func() tea.Msg { return RequestInputMsg{Action: ActionNewFolder} }
|
||||
case "C": // new root node
|
||||
return t, func() tea.Msg { return RequestInputMsg{Action: ActionNewRoot} }
|
||||
case "delete": // delete node
|
||||
err := t.DeleteNode(t.SelectedNode)
|
||||
if err != nil {
|
||||
t.ErrMsg = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
case tea.MouseMsg:
|
||||
if m.Action == tea.MouseActionPress {
|
||||
switch m.Button {
|
||||
case tea.MouseButtonWheelUp:
|
||||
_ = t.MoveUp()
|
||||
case tea.MouseButtonWheelDown:
|
||||
_ = t.MoveDown()
|
||||
}
|
||||
}
|
||||
|
||||
m.Y += t.viewStart - t.startOffset // adjust for viewport
|
||||
if m.X >= t.width {
|
||||
break
|
||||
}
|
||||
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.Type == FolderNode {
|
||||
_ = t.ToggleExpand(nodeAtLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.viewStart, t.viewEnd = t.getViewportBounds()
|
||||
|
||||
if t.oldSelected != t.SelectedNode && t.SelectedNode.Type == FileNode {
|
||||
t.oldSelected = t.SelectedNode
|
||||
return t, func() tea.Msg {
|
||||
return NodeSelectedMsg{Path: t.SelectedNode.Path}
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *FsTree) PerformAction(action FsActionType, name string) error {
|
||||
switch action {
|
||||
case ActionNewFile:
|
||||
return t.CreateNode(t.SelectedNode, name, FileNode)
|
||||
case ActionNewFolder:
|
||||
return t.CreateNode(t.SelectedNode, name, FolderNode)
|
||||
case ActionNewRoot:
|
||||
return t.CreateNode(t.Root, name, FolderNode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FsTree) getViewportBounds() (startLine, endLine int) {
|
||||
if t.SelectedNode == nil {
|
||||
return 0, 0 // doesn't amtter in this case
|
||||
}
|
||||
selectedLine := t.SelectedNode.line
|
||||
halfHeight := t.height / 2
|
||||
|
||||
startLine = max(0, selectedLine-halfHeight)
|
||||
|
||||
endLine = startLine + t.height
|
||||
if endLine > t.totalLines {
|
||||
endLine = t.totalLines
|
||||
startLine = endLine - t.height
|
||||
if startLine < 0 {
|
||||
startLine = 0
|
||||
}
|
||||
}
|
||||
|
||||
return startLine, endLine
|
||||
}
|
||||
|
||||
func (t *FsTree) View() string {
|
||||
if t.ErrMsg != "" {
|
||||
return t.ErrMsg
|
||||
}
|
||||
|
||||
if len(t.Root.Children) == 0 {
|
||||
return "no files/folders\nPress C to create"
|
||||
}
|
||||
|
||||
builder := &strings.Builder{}
|
||||
t.renderNode(t.Root, 0, builder)
|
||||
rendered := builder.String()
|
||||
|
||||
lines := strings.Split(rendered, "\n")
|
||||
|
||||
clampedLines := lines[t.viewStart:t.viewEnd]
|
||||
rendered = strings.Join(clampedLines, "\n")
|
||||
|
||||
rendered = lipgloss.NewStyle().
|
||||
Width(t.width).
|
||||
Render(rendered)
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// ==================== FsTree helper methods ====================
|
||||
|
||||
func NewFsTree(rootPath string, startOffset int) *FsTree {
|
||||
root := &FsNode{
|
||||
Type: FolderNode,
|
||||
Path: rootPath,
|
||||
Children: make([]*FsNode, 0),
|
||||
Expanded: true,
|
||||
}
|
||||
WalkFileSystemAndBuildTree(rootPath, root)
|
||||
|
||||
tree := &FsTree{
|
||||
Root: root,
|
||||
startOffset: startOffset,
|
||||
}
|
||||
if len(root.Children) > 0 {
|
||||
tree.SelectedNode = root.Children[0]
|
||||
}
|
||||
tree.BuildLines()
|
||||
return tree
|
||||
}
|
||||
|
||||
func (t *FsTree) DeleteNode(node *FsNode) error {
|
||||
if node == nil {
|
||||
return errors.New("node to delete cannot be nil")
|
||||
}
|
||||
parent := node.Parent
|
||||
if parent == nil {
|
||||
return errors.New("node to delete must have a parent")
|
||||
}
|
||||
|
||||
// materialise
|
||||
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)
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
t.BuildLines()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FsTree) ToggleExpand(node *FsNode) error {
|
||||
if node == nil {
|
||||
return errors.New("node cannot be nil")
|
||||
}
|
||||
if node.Type != FolderNode {
|
||||
return errors.New("only folder nodes can be expanded or collapsed")
|
||||
}
|
||||
|
||||
node.Expanded = !node.Expanded
|
||||
t.BuildLines()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FsTree) move(delta int) error {
|
||||
selected := t.SelectedNode
|
||||
if selected == nil {
|
||||
return errors.New("no node is currently selected")
|
||||
}
|
||||
if delta != -1 && delta != 1 {
|
||||
return errors.New("delta must be either -1 (up) or 1 (down)")
|
||||
}
|
||||
|
||||
var next *FsNode
|
||||
if delta == 1 {
|
||||
next = selected.next
|
||||
} else {
|
||||
next = selected.prev
|
||||
}
|
||||
if next != nil {
|
||||
t.SelectedNode = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return errors.New("no node is currently selected")
|
||||
}
|
||||
if t.SelectedNode.Type != FolderNode {
|
||||
return errors.New("only folder nodes can be expanded or collapsed")
|
||||
}
|
||||
|
||||
t.ToggleExpand(t.SelectedNode)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FsTree) renderNode(node *FsNode, depth int, builder *strings.Builder) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
folderInRoot := false
|
||||
if node.Type == FolderNode && depth == 1 && node.prev != nil {
|
||||
folderInRoot = true
|
||||
}
|
||||
|
||||
indent := strings.Repeat(" ", depth)
|
||||
prevIndent := strings.Repeat(" ", max(depth-1, 0))
|
||||
|
||||
var icon string
|
||||
switch node.Type {
|
||||
case FolderNode:
|
||||
if node.Expanded {
|
||||
icon = indent + styles.ArrowDownIcon
|
||||
} else {
|
||||
icon = indent + styles.ArrowRightIcon
|
||||
}
|
||||
case FileNode:
|
||||
icon = styles.VerticalLine
|
||||
icon = lipgloss.NewStyle().Faint(true).Render(prevIndent + icon + " ")
|
||||
}
|
||||
|
||||
// highlight if selected or hovered
|
||||
// note: the logic of lines cache needs to match render
|
||||
fileName := node.FileName()
|
||||
isSelected := node == t.SelectedNode
|
||||
isHovered := node == t.hoveredNode
|
||||
|
||||
if isSelected {
|
||||
fileName = lipgloss.NewStyle().Foreground(styles.Highlight).Bold(true).Render(fileName)
|
||||
} else if isHovered {
|
||||
fileName = lipgloss.NewStyle().Foreground(styles.HoverHighlight).Render(fileName)
|
||||
}
|
||||
|
||||
line := icon + " " + fileName + "\n"
|
||||
|
||||
if depth > 0 {
|
||||
if folderInRoot {
|
||||
line = "\n" + line
|
||||
}
|
||||
builder.WriteString(line)
|
||||
}
|
||||
|
||||
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() {
|
||||
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
|
||||
// everything is a child of root
|
||||
flatTree[0] = nil // basically a merge of skip root and padding ends with nil
|
||||
flatTree = append(flatTree, nil)
|
||||
t.totalLines = line
|
||||
|
||||
for i := 1; i < len(flatTree)-1; i++ {
|
||||
flatTree[i].prev = flatTree[i-1]
|
||||
flatTree[i].next = flatTree[i+1]
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: isn't meant to be used directly
|
||||
func (t *FsTree) buildLinesRec(node *FsNode, depth int, currentLine *int, flatTree *[]*FsNode) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if node.Type == FolderNode && depth == 1 && *currentLine != 0 {
|
||||
(*currentLine)++
|
||||
}
|
||||
t.lines[*currentLine] = node
|
||||
node.line = *currentLine
|
||||
*flatTree = append(*flatTree, node)
|
||||
(*currentLine)++
|
||||
|
||||
if node.Expanded {
|
||||
for _, child := range node.Children {
|
||||
t.buildLinesRec(child, depth+1, currentLine, flatTree)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *FsTree) CreateNode(folder *FsNode, name string, nodeType FsNodeType) error {
|
||||
if folder == nil {
|
||||
return errors.New("folder node cannot be nil")
|
||||
}
|
||||
|
||||
if folder.Type == FileNode {
|
||||
folder = folder.Parent
|
||||
}
|
||||
|
||||
if folder.Type != FolderNode {
|
||||
return errors.New("parent node must be a folder. this should not be allowed by ui")
|
||||
} else if folder.Type == FolderNode && !folder.Expanded {
|
||||
folder.Expanded = true
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return errors.New("node name cannot be empty")
|
||||
}
|
||||
|
||||
if nodeType == FileNode && !strings.HasSuffix(name, ".md") {
|
||||
name += ".md"
|
||||
}
|
||||
|
||||
path := filepath.Join(folder.Path, name)
|
||||
// materialise it first
|
||||
switch nodeType {
|
||||
case FileNode:
|
||||
err := filesystem.CreateFile(path, []byte{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case FolderNode:
|
||||
err := filesystem.CreateFolder(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
expanded := false
|
||||
if nodeType == FolderNode {
|
||||
expanded = true
|
||||
}
|
||||
newNode := &FsNode{
|
||||
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...)
|
||||
} else {
|
||||
folder.Children = append(folder.Children, newNode)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package fstree
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tests creating a new fstree
|
||||
func TestNewFsTree(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mend_fstree_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// setup structure:
|
||||
// root/
|
||||
// folder1/
|
||||
// file1.md
|
||||
// file2.md
|
||||
|
||||
os.Mkdir(filepath.Join(tmpDir, "folder1"), 0755)
|
||||
os.WriteFile(filepath.Join(tmpDir, "folder1", "file1.md"), []byte(""), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "file2.md"), []byte(""), 0644)
|
||||
|
||||
tree := NewFsTree(tmpDir, 0)
|
||||
|
||||
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)
|
||||
}
|
||||
// root children: folder1, file2.md
|
||||
if len(tree.Root.Children) != 2 {
|
||||
t.Errorf("expected 2 children, got %d", len(tree.Root.Children))
|
||||
}
|
||||
}
|
||||
|
||||
// tests creating nodes via tree
|
||||
func TestTreeCreateNode(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mend_fstree_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tree := NewFsTree(tmpDir, 0)
|
||||
// create folder
|
||||
err = tree.CreateNode(tree.Root, "new_folder", FolderNode)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error creating folder, got %v", err)
|
||||
}
|
||||
// check fs
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "new_folder")); os.IsNotExist(err) {
|
||||
t.Error("folder not created on fs")
|
||||
}
|
||||
// check tree
|
||||
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]
|
||||
err = tree.CreateNode(newFolder, "new_file", FileNode)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error creating file, got %v", err)
|
||||
}
|
||||
|
||||
// check fs (.md appended automatically)
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "new_folder", "new_file.md")); os.IsNotExist(err) {
|
||||
t.Error("file not created on fs")
|
||||
}
|
||||
// check tree
|
||||
if len(newFolder.Children) != 1 || newFolder.Children[0].Type != FileNode {
|
||||
t.Error("tree node not updated with new file")
|
||||
}
|
||||
}
|
||||
|
||||
// tests deleting nodes
|
||||
func TestTreeDeleteNode(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mend_fstree_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// setup: root/file.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "file.md"), []byte(""), 0644)
|
||||
|
||||
tree := NewFsTree(tmpDir, 0)
|
||||
targetNode := tree.Root.Children[0]
|
||||
|
||||
err = tree.DeleteNode(targetNode)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error deleting node, got %v", err)
|
||||
}
|
||||
|
||||
// check fs
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "file.md")); !os.IsNotExist(err) {
|
||||
t.Error("file not deleted from fs")
|
||||
}
|
||||
// check tree
|
||||
if len(tree.Root.Children) != 0 {
|
||||
t.Error("tree node not updated after deletion")
|
||||
}
|
||||
}
|
||||
|
||||
// tests toggling expand
|
||||
func TestTreeToggleExpand(t *testing.T) {
|
||||
root := &FsNode{
|
||||
Type: FolderNode,
|
||||
Expanded: true,
|
||||
Children: []*FsNode{}, // Initialize children
|
||||
}
|
||||
|
||||
node := &FsNode{
|
||||
Type: FolderNode,
|
||||
Expanded: true,
|
||||
Parent: root,
|
||||
}
|
||||
root.Children = append(root.Children, node)
|
||||
|
||||
// mocked tree for this test
|
||||
tree := &FsTree{
|
||||
Root: root,
|
||||
}
|
||||
// Initial buildLines to setup state
|
||||
tree.BuildLines()
|
||||
|
||||
err := tree.ToggleExpand(node)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if node.Expanded {
|
||||
t.Error("expected node to be collapsed")
|
||||
}
|
||||
|
||||
err = tree.ToggleExpand(node)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if !node.Expanded {
|
||||
t.Error("expected node to be expanded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
this pakckage basically handles notes which are the individual files for spaced
|
||||
repetition in mend
|
||||
*/
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
glStyles "github.com/charmbracelet/glamour/styles"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
type Section struct {
|
||||
Title string
|
||||
Content string
|
||||
Hints []string
|
||||
}
|
||||
|
||||
type ViewState int
|
||||
|
||||
const (
|
||||
StateTitleOnly ViewState = iota
|
||||
StateContent
|
||||
StateHints
|
||||
)
|
||||
|
||||
type NoteView struct {
|
||||
// the actual content
|
||||
Path string
|
||||
rawContent string // full content for editing
|
||||
sections []Section
|
||||
currentSectionIndex int
|
||||
// display layer
|
||||
err error
|
||||
loading bool
|
||||
vp viewport.Model
|
||||
mdRenderer *glamour.TermRenderer
|
||||
viewState ViewState
|
||||
// editing
|
||||
textarea textarea.Model
|
||||
isEditing bool
|
||||
}
|
||||
|
||||
// ================== messages ===================
|
||||
type LoadNoteMsg struct {
|
||||
Path string
|
||||
Force bool
|
||||
}
|
||||
|
||||
type LoadedNote struct {
|
||||
RawContent string
|
||||
Sections []Section
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewNoteView() *NoteView {
|
||||
mdRenderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStylePath(glStyles.TokyoNightStyle),
|
||||
glamour.WithWordWrap(80),
|
||||
)
|
||||
ta := textarea.New()
|
||||
ta.Focus()
|
||||
ta.Prompt = ""
|
||||
ta.ShowLineNumbers = false
|
||||
|
||||
return &NoteView{
|
||||
loading: false,
|
||||
mdRenderer: mdRenderer,
|
||||
vp: viewport.New(0, 0),
|
||||
viewState: StateTitleOnly,
|
||||
textarea: ta,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NoteView) Init() tea.Cmd {
|
||||
return textarea.Blink
|
||||
}
|
||||
|
||||
func (m *NoteView) IsEditing() bool {
|
||||
return m.isEditing
|
||||
}
|
||||
|
||||
func (m *NoteView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.vp.Width = msg.Width
|
||||
m.vp.Height = msg.Height - 1
|
||||
m.textarea.SetWidth(msg.Width)
|
||||
m.textarea.SetHeight(msg.Height)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.isEditing {
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+q":
|
||||
m.isEditing = false
|
||||
return m, saveContent(m.Path, m.textarea.Value())
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if m.Path != "" && !m.loading {
|
||||
m.isEditing = true
|
||||
m.textarea.SetValue(m.rawContent)
|
||||
m.textarea.Focus()
|
||||
return m, textarea.Blink
|
||||
}
|
||||
case " ":
|
||||
switch m.viewState {
|
||||
case StateTitleOnly:
|
||||
m.viewState = StateHints
|
||||
case StateHints:
|
||||
m.viewState = StateContent
|
||||
case StateContent:
|
||||
m.viewState = StateTitleOnly
|
||||
}
|
||||
m.vp.SetContent(m.renderNote())
|
||||
case "pgup":
|
||||
m.vp.PageUp()
|
||||
return m, nil
|
||||
case "pgdown":
|
||||
m.vp.PageDown()
|
||||
return m, nil
|
||||
case "left", "a":
|
||||
if m.currentSectionIndex > 0 {
|
||||
m.currentSectionIndex--
|
||||
m.vp.SetContent(m.renderNote())
|
||||
m.vp.GotoTop()
|
||||
}
|
||||
return m, nil
|
||||
case "right", "d":
|
||||
if m.currentSectionIndex < len(m.sections)-1 {
|
||||
m.currentSectionIndex++
|
||||
m.vp.SetContent(m.renderNote())
|
||||
m.vp.GotoTop()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.vp, cmd = m.vp.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case LoadNoteMsg:
|
||||
if m.Path == msg.Path && !msg.Force {
|
||||
return m, nil //noop
|
||||
}
|
||||
m.Path = msg.Path
|
||||
m.isEditing = false
|
||||
m.loading = true
|
||||
m.currentSectionIndex = 0
|
||||
return m, fetchContent(msg.Path)
|
||||
|
||||
case LoadedNote:
|
||||
m.loading = false
|
||||
m.rawContent = msg.RawContent
|
||||
m.sections = msg.Sections
|
||||
m.err = msg.Err
|
||||
m.currentSectionIndex = 0
|
||||
m.viewState = StateTitleOnly
|
||||
m.vp.SetContent(m.renderNote())
|
||||
|
||||
return m, nil
|
||||
|
||||
case tea.MouseMsg:
|
||||
if m.isEditing {
|
||||
return m, nil // or handle mouse in textarea if supported
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.vp, cmd = m.vp.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m NoteView) View() string {
|
||||
if m.loading {
|
||||
return "loading..."
|
||||
}
|
||||
if m.Path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if m.err != nil {
|
||||
return "Error: " + m.err.Error()
|
||||
}
|
||||
|
||||
if m.isEditing {
|
||||
return m.textarea.View()
|
||||
}
|
||||
|
||||
page := m.currentSectionIndex + 1
|
||||
total := len(m.sections)
|
||||
var footer string
|
||||
if total == 0 {
|
||||
footer = "No sections"
|
||||
} else {
|
||||
footer = fmt.Sprintf("%d/%d", page, total)
|
||||
}
|
||||
footer = lipgloss.NewStyle().Width(m.vp.Width).Align(lipgloss.Right).Render(footer)
|
||||
|
||||
return m.vp.View() + "\n" + footer
|
||||
}
|
||||
|
||||
func fetchContent(path string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return LoadedNote{Err: err}
|
||||
}
|
||||
|
||||
rawContent := string(data)
|
||||
sections := ParseSections(data)
|
||||
|
||||
return LoadedNote{
|
||||
RawContent: rawContent,
|
||||
Sections: sections,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ParseSections(source []byte) []Section {
|
||||
md := goldmark.New()
|
||||
reader := text.NewReader(source)
|
||||
doc := md.Parser().Parse(reader)
|
||||
|
||||
title := "no title"
|
||||
sections := make([]Section, 0)
|
||||
lastPos := 0
|
||||
|
||||
for child := doc.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() == ast.KindHeading {
|
||||
if child.Lines().Len() <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
level := child.(*ast.Heading).Level
|
||||
headingStart := child.Lines().At(0).Start - level - 1
|
||||
headingEnd := child.Lines().At(child.Lines().Len() - 1).Stop
|
||||
|
||||
contentEnd := headingStart
|
||||
if lastPos < contentEnd {
|
||||
// you have a heading and a content to accumulte over
|
||||
contentsRaw := source[lastPos:contentEnd]
|
||||
contents := strings.TrimSpace(string(contentsRaw))
|
||||
hints := ExtractHints(contents)
|
||||
sections = append(sections, Section{
|
||||
Title: title,
|
||||
Content: contents,
|
||||
Hints: hints,
|
||||
})
|
||||
lastPos = headingEnd
|
||||
}
|
||||
// for the next heading
|
||||
title = strings.TrimSpace(string(source[headingStart:headingEnd]))
|
||||
lastPos = headingEnd
|
||||
}
|
||||
}
|
||||
// last section
|
||||
if lastPos < len(source) {
|
||||
contentsRaw := source[lastPos:]
|
||||
contents := strings.TrimSpace(string(contentsRaw))
|
||||
hints := ExtractHints(contents)
|
||||
sections = append(sections, Section{
|
||||
Title: title,
|
||||
Content: contents,
|
||||
Hints: hints,
|
||||
})
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
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 fetchContent(path)()
|
||||
}
|
||||
}
|
||||
|
||||
func ExtractHints(content string) []string {
|
||||
re := regexp.MustCompile(`(?s)\*\*(.*?)\*\*|__(.*?)__`)
|
||||
matches := re.FindAllStringSubmatch(content, -1)
|
||||
hints := make([]string, 0)
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 && match[1] != "" {
|
||||
hints = append(hints, match[1])
|
||||
} else if len(match) > 2 && match[2] != "" {
|
||||
hints = append(hints, match[2])
|
||||
}
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
func (m NoteView) renderNote() string {
|
||||
if m.Path == "" {
|
||||
return "" // no note is loaded, don't need to bother with anything
|
||||
}
|
||||
var titleText, contentText string
|
||||
if len(m.sections) > 0 {
|
||||
titleText = m.sections[m.currentSectionIndex].Title
|
||||
contentText = m.sections[m.currentSectionIndex].Content
|
||||
} else {
|
||||
contentText = m.rawContent
|
||||
}
|
||||
|
||||
title, err1 := m.mdRenderer.Render(titleText)
|
||||
if err1 != nil {
|
||||
title = titleText + "\n\n"
|
||||
}
|
||||
|
||||
var body string
|
||||
var err2 error
|
||||
|
||||
switch m.viewState {
|
||||
case StateContent:
|
||||
body, err2 = m.mdRenderer.Render(contentText)
|
||||
case StateHints:
|
||||
currentHints := []string{}
|
||||
if m.currentSectionIndex < len(m.sections) {
|
||||
currentHints = m.sections[m.currentSectionIndex].Hints
|
||||
}
|
||||
|
||||
if len(currentHints) == 0 {
|
||||
body = "No hints available."
|
||||
} else {
|
||||
// Format hints as a list
|
||||
hintsList := ""
|
||||
for _, h := range currentHints {
|
||||
hintsList += "- " + h + "\n"
|
||||
}
|
||||
body, err2 = m.mdRenderer.Render(hintsList)
|
||||
}
|
||||
case StateTitleOnly:
|
||||
body = "" // TOOD: there was a message here but I removed it, maybe remove enum entry as well
|
||||
// or improve this part ui
|
||||
}
|
||||
|
||||
if err2 != nil {
|
||||
return title + "\nError rendering content."
|
||||
}
|
||||
|
||||
return title + body
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package note
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tests hint extraction from text
|
||||
func TestExtractHints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "single bold hint",
|
||||
content: "This is a **hint**.",
|
||||
expected: []string{"hint"},
|
||||
},
|
||||
{
|
||||
name: "multiple bold hints",
|
||||
content: "**one** and **two**",
|
||||
expected: []string{"one", "two"},
|
||||
},
|
||||
{
|
||||
name: "underscore hints",
|
||||
content: "__one__ and __two__",
|
||||
expected: []string{"one", "two"},
|
||||
},
|
||||
{
|
||||
name: "mixed hints",
|
||||
content: "**one** and __two__",
|
||||
expected: []string{"one", "two"},
|
||||
},
|
||||
{
|
||||
name: "no hints",
|
||||
content: "just plain text",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExtractHints(tt.content)
|
||||
if !reflect.DeepEqual(got, tt.expected) {
|
||||
t.Errorf("ExtractHints() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tests section parsing from markdown
|
||||
func TestParseSections(t *testing.T) {
|
||||
content := []byte(`# Title 1
|
||||
Content 1 with **hint1**.
|
||||
|
||||
# Title 2
|
||||
Content 2 with __hint2__.
|
||||
`)
|
||||
|
||||
sections := ParseSections(content)
|
||||
|
||||
if len(sections) != 2 {
|
||||
t.Fatalf("expected 2 sections, got %d", len(sections))
|
||||
}
|
||||
|
||||
// verify first section
|
||||
if sections[0].Title != "# Title 1" {
|
||||
t.Errorf("expected title '# Title 1', got '%s'", sections[0].Title)
|
||||
}
|
||||
if sections[0].Content != "Content 1 with **hint1**." {
|
||||
t.Errorf("expected content match for section 1, got '%s'", sections[0].Content)
|
||||
}
|
||||
if len(sections[0].Hints) != 1 || sections[0].Hints[0] != "hint1" {
|
||||
t.Error("failed to extract hint from section 1")
|
||||
}
|
||||
|
||||
// verify second section
|
||||
if sections[1].Title != "# Title 2" {
|
||||
t.Errorf("expected title '# Title 2', got '%s'", sections[1].Title)
|
||||
}
|
||||
if sections[1].Content != "Content 2 with __hint2__." {
|
||||
t.Errorf("expected content match for section 2, got '%s'", sections[1].Content)
|
||||
}
|
||||
if len(sections[1].Hints) != 1 || sections[1].Hints[0] != "hint2" {
|
||||
t.Error("failed to extract hint from section 2")
|
||||
}
|
||||
}
|
||||
|
||||
// tests parsing with no headers
|
||||
func TestParseSectionsNoHeaders(t *testing.T) {
|
||||
content := []byte(`Just some content without any headers.
|
||||
It has **one hint**.`)
|
||||
|
||||
sections := ParseSections(content)
|
||||
|
||||
if len(sections) != 1 {
|
||||
t.Fatalf("expected 1 section, got %d", len(sections))
|
||||
}
|
||||
|
||||
if sections[0].Title != "no title" {
|
||||
t.Errorf("expected 'no title', got '%s'", sections[0].Title)
|
||||
}
|
||||
if len(sections[0].Hints) != 1 || sections[0].Hints[0] != "one hint" {
|
||||
t.Error("failed to extract hint")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user