refactor
This commit is contained in:
@@ -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)
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
@@ -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().
|
||||||
|
|||||||
Reference in New Issue
Block a user