Compare commits

...

10 commits

13 changed files with 1375 additions and 200 deletions

11
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,11 @@
## Description
## Important note
The first commit should be prefixed with one of the following depending on the severity of the changes:
- `chore:` - non-code changes, such as typos in readme or pipeline changes.
- `fix:` - for a small change like a bugfix or other minor things.
- `feat:` - for a new feature.
- `major:` - for a large refactor or breaking changes.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -18,11 +18,69 @@ go build -o bin/org ./cmd/org
## Usage
```bash
org [filename] # Open specific org file
org -f tasks.org # Open using -f flag
org # Opens ./todo.org by default
org # Open ./todo.org (default)
org tasks.org # Open specific org file
org /path/to/work.org # Open specific org file with path
org -m # Multi-file: Load all .org files in current directory
org -m /path/to/dir # Multi-file: Load all .org files in specified directory
org -c # Quick capture mode
org -c "Task description" # Quick capture with pre-filled text
echo "Task" | org # Pipe text to capture
```
### Single-File Mode (Default)
By default, `org` opens `./todo.org` or the file you specify:
```bash
org # Opens ./todo.org
org tasks.org # Opens tasks.org
org ~/work/project.org # Opens specific file
```
### Quick Capture Mode
Use the `-c` or `--capture` flag to quickly add tasks without navigating through the UI:
```bash
org -c # Open directly in capture mode
org -c "Buy groceries" # Capture with pre-filled text
org -c "Write report" tasks.org # Capture to specific file
echo "Meeting notes" | org # Pipe text to capture
echo "Task" | org ~/work.org # Pipe to specific file
```
This is perfect for quickly capturing tasks from scripts, terminal workflows, or shell aliases. The capture mode skips the need to press 'c' once inside the application, making it faster to add quick TODO items.
### Multi-File Mode
Use the `-m` or `--multi` flag to load all `.org` files in a directory as top-level items. Each file appears as a top-level item in the interface, with its contents nested underneath. Changes made to items are automatically saved back to their respective files.
```bash
org -m # Load all .org files in current directory
org -m /path/to/dir # Load all .org files in specified directory
```
**Example:** If you have these files in your directory:
- `work.org` containing work tasks
- `personal.org` containing personal tasks
- `ideas.org` containing project ideas
Running `org -m` will display:
```
* work.org
** TODO Complete project proposal
** PROG Review code changes
* personal.org
** TODO Buy groceries
* ideas.org
** New app concept
```
## Contributing
Feel free to fork and create a pull request if there's any features missing for your own use case!
## Features
### Task Management
@ -51,6 +109,7 @@ org # Opens ./todo.org by default
- **Syntax Highlighting**: Code blocks are automatically highlighted (supports both ```lang and #+BEGIN_SRC formats)
- **Markdown Support**: Use markdown-style code blocks in your notes
- **Drawer Management**: LOGBOOK and PROPERTIES drawers are automatically filtered in list view
- **Fold/Unfold All**: Fold/Unfold all items with shift+tab
### Keybindings
@ -60,21 +119,25 @@ org # Opens ./todo.org by default
| `←/h`, `→/l` | Cycle state backward/forward |
| `t` or `space` | Cycle TODO state |
| `tab` | Fold/unfold item |
| `shift+tab` | Fold/Unfold all items |
| `enter` | Edit notes |
| `c` | Capture new TODO |
| `s` | Add sub-task |
| `D` | Delete item (with confirmation) |
| `R` | Rename item |
| `#` | Add/edit tags |
| `a` | Toggle agenda view |
| `i` | Clock in |
| `o` | Clock out |
| `d` | Set deadline |
| `S` | Set scheduled date |
| `p` | Set priority |
| `e` | Set effort |
| `r` | Toggle reorder mode |
| `shift+↑/↓` | Move item up/down |
| `sift+←/→` | Promote/demote item |
| `,` | Open settings |
| `ctrl+s` | Save |
| `ctrl+s` | Force save |
| `?` | Toggle help |
| `q` or `ctrl+c` | Quit |

View file

@ -1,35 +1,59 @@
package main
import (
"bufio"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"github.com/rwejlgaard/org/internal/config"
"github.com/rwejlgaard/org/internal/model"
"github.com/rwejlgaard/org/internal/parser"
"github.com/rwejlgaard/org/internal/ui"
)
func main() {
var filePath string
flag.StringVar(&filePath, "file", "", "Path to org file (default: ./todo.org)")
flag.StringVar(&filePath, "f", "", "Path to org file (shorthand)")
var multiMode bool
var captureMode bool
flag.BoolVar(&multiMode, "multi", false, "Load all org files in current directory as top-level items")
flag.BoolVar(&multiMode, "m", false, "Load all org files in current directory (shorthand)")
flag.BoolVar(&captureMode, "capture", false, "Start in capture mode")
flag.BoolVar(&captureMode, "c", false, "Start in capture mode (shorthand)")
flag.Parse()
// Check for positional argument first
// Check for positional argument or capture text
var captureText string
if len(flag.Args()) > 0 {
if captureMode {
// First argument is capture text when in capture mode
captureText = flag.Args()[0]
// Second argument (if present) is the file path
if len(flag.Args()) > 1 {
filePath = flag.Args()[1]
}
} else {
// First argument is file path in normal mode
filePath = flag.Args()[0]
}
}
// Check if input is being piped
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) == 0 {
// Data is being piped to stdin
reader := bufio.NewReader(os.Stdin)
pipedText, err := io.ReadAll(reader)
if err == nil && len(pipedText) > 0 {
captureMode = true
captureText = string(pipedText)
// If no file path was provided via args, check if last arg could be a path
if filePath == "" && len(flag.Args()) > 0 {
filePath = flag.Args()[0]
}
// Default to ./todo.org if no file specified
if filePath == "" {
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
os.Exit(1)
}
filePath = filepath.Join(cwd, "todo.org")
}
// Load configuration
@ -39,15 +63,57 @@ func main() {
cfg = config.DefaultConfig()
}
var orgFile *model.OrgFile
if multiMode {
// Multi-file mode: load all .org files in directory
var dirPath string
if filePath != "" {
// Check if provided path is a directory
info, err := os.Stat(filePath)
if err == nil && info.IsDir() {
dirPath = filePath
} else {
// Use directory of the provided file path
dirPath = filepath.Dir(filePath)
}
} else {
// Use current directory
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
os.Exit(1)
}
dirPath = cwd
}
orgFile, err = parser.ParseMultipleOrgFiles(dirPath, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing org files: %v\n", err)
os.Exit(1)
}
} else {
// Single file mode (default)
if filePath == "" {
// Default to ./todo.org
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
os.Exit(1)
}
filePath = filepath.Join(cwd, "todo.org")
}
// Parse the org file
orgFile, err := parser.ParseOrgFile(filePath, cfg)
orgFile, err = parser.ParseOrgFile(filePath, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing org file: %v\n", err)
os.Exit(1)
}
}
// Run the UI
if err := ui.RunUI(orgFile, cfg); err != nil {
if err := ui.RunUI(orgFile, cfg, captureMode, captureText); err != nil {
fmt.Fprintf(os.Stderr, "Error running UI: %v\n", err)
os.Exit(1)
}

View file

@ -26,8 +26,12 @@ type KeybindingsConfig struct {
Right []string `toml:"right"`
ShiftUp []string `toml:"shift_up"`
ShiftDown []string `toml:"shift_down"`
ShiftLeft []string `toml:"shift_left"`
ShiftRight []string `toml:"shift_right"`
Rename []string `toml:"rename"`
CycleState []string `toml:"cycle_state"`
ToggleFold []string `toml:"toggle_fold"`
ToggleFoldAll []string `toml:"toggle_fold_all"`
EditNotes []string `toml:"edit_notes"`
ToggleView []string `toml:"toggle_view"`
Capture []string `toml:"capture"`
@ -38,6 +42,7 @@ type KeybindingsConfig struct {
ClockIn []string `toml:"clock_in"`
ClockOut []string `toml:"clock_out"`
SetDeadline []string `toml:"set_deadline"`
SetScheduled []string `toml:"set_scheduled"`
SetPriority []string `toml:"set_priority"`
SetEffort []string `toml:"set_effort"`
Help []string `toml:"help"`
@ -91,6 +96,9 @@ type UIConfig struct {
HelpTextWidth int `toml:"help_text_width"`
MinTerminalWidth int `toml:"min_terminal_width"`
AgendaDays int `toml:"agenda_days"`
OrgSyntaxHighlighting bool `toml:"org_syntax_highlighting"`
ShowIndentationGuides bool `toml:"show_indentation_guides"`
IndentationGuideColor string `toml:"indentation_guide_color"`
}
// DefaultConfig returns the default configuration
@ -103,8 +111,12 @@ func DefaultConfig() *Config {
Right: []string{"right", "l"},
ShiftUp: []string{"shift+up"},
ShiftDown: []string{"shift+down"},
ShiftLeft: []string{"shift+left"},
ShiftRight: []string{"shift+right"},
Rename: []string{"R"},
CycleState: []string{"t", " "},
ToggleFold: []string{"tab"},
ToggleFoldAll: []string{"shift+tab", "backtab"},
EditNotes: []string{"enter"},
ToggleView: []string{"a"},
Capture: []string{"c"},
@ -115,6 +127,7 @@ func DefaultConfig() *Config {
ClockIn: []string{"i"},
ClockOut: []string{"o"},
SetDeadline: []string{"d"},
SetScheduled: []string{"S"},
SetPriority: []string{"p"},
SetEffort: []string{"e"},
Help: []string{"?"},
@ -158,6 +171,9 @@ func DefaultConfig() *Config {
HelpTextWidth: 22,
MinTerminalWidth: 40,
AgendaDays: 7,
OrgSyntaxHighlighting: true,
ShowIndentationGuides: true,
IndentationGuideColor: "245",
},
}
}
@ -255,12 +271,24 @@ func (c *Config) fillDefaults() {
if len(c.Keybindings.ShiftDown) == 0 {
c.Keybindings.ShiftDown = defaults.Keybindings.ShiftDown
}
if len(c.Keybindings.ShiftLeft) == 0 {
c.Keybindings.ShiftLeft = defaults.Keybindings.ShiftLeft
}
if len(c.Keybindings.ShiftRight) == 0 {
c.Keybindings.ShiftRight = defaults.Keybindings.ShiftRight
}
if len(c.Keybindings.Rename) == 0 {
c.Keybindings.Rename = defaults.Keybindings.Rename
}
if len(c.Keybindings.CycleState) == 0 {
c.Keybindings.CycleState = defaults.Keybindings.CycleState
}
if len(c.Keybindings.ToggleFold) == 0 {
c.Keybindings.ToggleFold = defaults.Keybindings.ToggleFold
}
if len(c.Keybindings.ToggleFoldAll) == 0 {
c.Keybindings.ToggleFoldAll = defaults.Keybindings.ToggleFoldAll
}
if len(c.Keybindings.EditNotes) == 0 {
c.Keybindings.EditNotes = defaults.Keybindings.EditNotes
}
@ -291,6 +319,9 @@ func (c *Config) fillDefaults() {
if len(c.Keybindings.SetDeadline) == 0 {
c.Keybindings.SetDeadline = defaults.Keybindings.SetDeadline
}
if len(c.Keybindings.SetScheduled) == 0 {
c.Keybindings.SetScheduled = defaults.Keybindings.SetScheduled
}
if len(c.Keybindings.SetPriority) == 0 {
c.Keybindings.SetPriority = defaults.Keybindings.SetPriority
}
@ -356,10 +387,11 @@ func (c *Config) fillDefaults() {
// Fill states if empty
if len(c.States.States) == 0 {
c.States.States = defaults.States.States
}
if c.States.DefaultNewTaskState == "" {
// Also set the default new task state since the entire states section is missing
c.States.DefaultNewTaskState = defaults.States.DefaultNewTaskState
}
// Note: We don't fill DefaultNewTaskState if States.States is non-empty because
// an empty string is a valid intentional value meaning "no default state".
// Fill UI if zero values
if c.UI.HelpTextWidth == 0 {
@ -371,6 +403,9 @@ func (c *Config) fillDefaults() {
if c.UI.AgendaDays == 0 {
c.UI.AgendaDays = defaults.UI.AgendaDays
}
if c.UI.IndentationGuideColor == "" {
c.UI.IndentationGuideColor = defaults.UI.IndentationGuideColor
}
}
// BuildKeyBinding creates a key.Binding from config
@ -492,6 +527,8 @@ func (c *Config) UpdateKeybinding(action string, keys []string) error {
c.Keybindings.CycleState = keys
case "toggle_fold":
c.Keybindings.ToggleFold = keys
case "toggle_fold_all":
c.Keybindings.ToggleFoldAll = keys
case "edit_notes":
c.Keybindings.EditNotes = keys
case "capture":
@ -527,8 +564,12 @@ func (c *Config) GetAllKeybindings() map[string][]string {
"right": c.Keybindings.Right,
"shift_up": c.Keybindings.ShiftUp,
"shift_down": c.Keybindings.ShiftDown,
"shift_left": c.Keybindings.ShiftLeft,
"shift_right": c.Keybindings.ShiftRight,
"rename": c.Keybindings.Rename,
"cycle_state": c.Keybindings.CycleState,
"toggle_fold": c.Keybindings.ToggleFold,
"toggle_fold_all": c.Keybindings.ToggleFoldAll,
"edit_notes": c.Keybindings.EditNotes,
"toggle_view": c.Keybindings.ToggleView,
"capture": c.Keybindings.Capture,
@ -539,6 +580,7 @@ func (c *Config) GetAllKeybindings() map[string][]string {
"clock_in": c.Keybindings.ClockIn,
"clock_out": c.Keybindings.ClockOut,
"set_deadline": c.Keybindings.SetDeadline,
"set_scheduled": c.Keybindings.SetScheduled,
"set_priority": c.Keybindings.SetPriority,
"set_effort": c.Keybindings.SetEffort,
"help": c.Keybindings.Help,

View file

@ -21,11 +21,13 @@ type Item struct {
Tags []string // Tags for this item (e.g., :work:urgent:)
Scheduled *time.Time
Deadline *time.Time
Closed *time.Time // Closed timestamp (when task was marked as done)
Effort string // Effort estimate (e.g., "8h", "2d")
Notes []string // Notes/content under the heading
Children []*Item // Sub-items
Folded bool // Whether the item is folded (hides notes and children)
ClockEntries []ClockEntry // Clock in/out entries
SourceFile string // Source file path (used in multi-file mode)
}
// OrgFile represents a parsed org-mode file

View file

@ -3,7 +3,9 @@ package parser
import (
"bufio"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/rwejlgaard/org/internal/config"
@ -14,6 +16,7 @@ import (
var (
scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`)
deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`)
closedPattern = regexp.MustCompile(`CLOSED:\s*\[([^\]]+)\]`)
clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`)
effortPattern = regexp.MustCompile(`^\s*:EFFORT:\s*(.+)$`)
logbookDrawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`)
@ -185,6 +188,13 @@ func ParseOrgFile(path string, cfg *config.Config) (*model.OrgFile, error) {
}
}
// Check for CLOSED
if matches := closedPattern.FindStringSubmatch(line); matches != nil {
if t, err := parseClockTimestamp(matches[1]); err == nil {
currentItem.Closed = &t
}
}
// Check for EFFORT (inside PROPERTIES drawer)
if matches := effortPattern.FindStringSubmatch(line); matches != nil {
currentItem.Effort = strings.TrimSpace(matches[1])
@ -216,3 +226,70 @@ func ParseOrgFile(path string, cfg *config.Config) (*model.OrgFile, error) {
return orgFile, nil
}
// ParseMultipleOrgFiles loads all .org files in a directory and wraps them as top-level items
func ParseMultipleOrgFiles(dirPath string, cfg *config.Config) (*model.OrgFile, error) {
// Find all .org files in the directory
matches, err := filepath.Glob(filepath.Join(dirPath, "*.org"))
if err != nil {
return nil, err
}
// Sort files alphabetically
sort.Strings(matches)
// Create a virtual org file
multiOrgFile := &model.OrgFile{
Path: dirPath, // Store directory path
Items: []*model.Item{},
}
// Parse each file and wrap it as a top-level item
for _, filePath := range matches {
orgFile, err := ParseOrgFile(filePath, cfg)
if err != nil {
// Skip files that can't be parsed
continue
}
// Create a wrapper item for this file
fileName := filepath.Base(filePath)
fileItem := &model.Item{
Level: 1,
State: model.StateNone,
Priority: model.PriorityNone,
Title: fileName,
Tags: []string{},
Notes: []string{},
Children: []*model.Item{},
SourceFile: filePath,
}
// Increment the level of all items from this file and add as children
for _, item := range orgFile.Items {
incrementItemLevel(item)
setSourceFileRecursive(item, filePath)
fileItem.Children = append(fileItem.Children, item)
}
multiOrgFile.Items = append(multiOrgFile.Items, fileItem)
}
return multiOrgFile, nil
}
// incrementItemLevel recursively increments the level of an item and its children
func incrementItemLevel(item *model.Item) {
item.Level++
for _, child := range item.Children {
incrementItemLevel(child)
}
}
// setSourceFileRecursive sets the source file for an item and all its descendants
func setSourceFileRecursive(item *model.Item, filePath string) {
item.SourceFile = filePath
for _, child := range item.Children {
setSourceFileRecursive(child, filePath)
}
}

View file

@ -11,6 +11,18 @@ import (
// Save writes the org file back to disk
func Save(orgFile *model.OrgFile) error {
// Check if this is a multi-file org (directory-based)
// In multi-file mode, top-level items have SourceFile set and represent files
isMultiFile := false
if len(orgFile.Items) > 0 && orgFile.Items[0].SourceFile != "" {
isMultiFile = true
}
if isMultiFile {
return saveMultiFile(orgFile)
}
// Single file mode
file, err := os.Create(orgFile.Path)
if err != nil {
return err
@ -29,6 +41,66 @@ func Save(orgFile *model.OrgFile) error {
return nil
}
// saveMultiFile saves items back to their individual source files
func saveMultiFile(orgFile *model.OrgFile) error {
// Group items by source file
fileItems := make(map[string][]*model.Item)
for _, fileItem := range orgFile.Items {
if fileItem.SourceFile == "" {
continue
}
// The children of this file item are the actual items to save
fileItems[fileItem.SourceFile] = fileItem.Children
}
// Save each file
for filePath, items := range fileItems {
if err := saveItemsToFile(filePath, items); err != nil {
return err
}
}
return nil
}
// saveItemsToFile writes a list of items to a specific file
func saveItemsToFile(filePath string, items []*model.Item) error {
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
writer := bufio.NewWriter(file)
defer writer.Flush()
for _, item := range items {
// Decrement level since we're saving to individual files
decrementedItem := decrementItemLevelForSave(item)
if err := writeItem(writer, decrementedItem); err != nil {
return err
}
}
return nil
}
// decrementItemLevelForSave creates a copy of an item with decremented levels for saving
func decrementItemLevelForSave(item *model.Item) *model.Item {
copied := *item
copied.Level--
copiedChildren := make([]*model.Item, len(item.Children))
for i, child := range item.Children {
copiedChildren[i] = decrementItemLevelForSave(child)
}
copied.Children = copiedChildren
return &copied
}
// writeItem recursively writes an item and its children
func writeItem(writer *bufio.Writer, item *model.Item) error {
// Write heading
@ -56,6 +128,7 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
// Write scheduling info if not already in notes
hasScheduled := false
hasDeadline := false
hasClosed := false
hasLogbook := false
hasProperties := false
for _, note := range item.Notes {
@ -65,6 +138,9 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
if strings.Contains(note, "DEADLINE:") {
hasDeadline = true
}
if strings.Contains(note, "CLOSED:") {
hasClosed = true
}
if strings.Contains(note, ":LOGBOOK:") {
hasLogbook = true
}
@ -73,6 +149,13 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
}
}
if item.Closed != nil && !hasClosed {
closedLine := fmt.Sprintf("CLOSED: [%s]\n", formatClockTimestamp(*item.Closed))
if _, err := writer.WriteString(closedLine); err != nil {
return err
}
}
if item.Scheduled != nil && !hasScheduled {
scheduledLine := fmt.Sprintf("SCHEDULED: <%s>\n", FormatOrgDate(*item.Scheduled))
if _, err := writer.WriteString(scheduledLine); err != nil {

View file

@ -1,6 +1,7 @@
package ui
import (
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
@ -21,11 +22,13 @@ const (
modeCapture
modeAddSubTask
modeSetDeadline
modeSetScheduled
modeSetPriority
modeSetEffort
modeHelp
modeSettings
modeTagEdit
modeRename
)
type uiModel struct {
@ -50,9 +53,10 @@ type uiModel struct {
settingsCursor int // Cursor position in settings view
settingsScroll int // Scroll position in settings view
settingsSection settingsSection // Current settings section/tab
captureCursor int // Store cursor position when entering capture mode
}
func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel {
func InitialModel(orgFile *model.OrgFile, cfg *config.Config, captureMode bool, captureText string) uiModel {
ta := textarea.New()
ta.Placeholder = "Enter notes here (code blocks supported)..."
ta.ShowLineNumbers = false
@ -64,10 +68,16 @@ func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel {
h := help.New()
h.ShowAll = false
mode := modeList
if captureMode {
mode = modeCapture
ti.SetValue(strings.TrimSpace(captureText))
}
return uiModel{
orgFile: orgFile,
cursor: 0,
mode: modeList,
mode: mode,
help: h,
keys: newKeyMapFromConfig(cfg),
styles: newStyleMapFromConfig(cfg),
@ -78,6 +88,9 @@ func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel {
}
func (m uiModel) Init() tea.Cmd {
if m.mode == modeCapture {
return textinput.Blink
}
return nil
}
@ -104,8 +117,13 @@ func (m *uiModel) updateScrollOffset(availableHeight int) {
for i, item := range items {
lineCount := 1 // The item itself
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
// Count note lines (simplified - just count notes)
lineCount += len(item.Notes)
// Count note lines with wrapping
indent := strings.Repeat(" ", item.Level)
noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
lineCount += len(highlightedNotes)
}
itemLineCount[i] = lineCount
}
@ -132,8 +150,12 @@ func (m *uiModel) updateScrollOffset(availableHeight int) {
}
// RunUI starts the terminal UI
func RunUI(orgFile *model.OrgFile, cfg *config.Config) error {
p := tea.NewProgram(InitialModel(orgFile, cfg), tea.WithAltScreen())
func RunUI(orgFile *model.OrgFile, cfg *config.Config, captureMode bool, captureText string) error {
m := InitialModel(orgFile, cfg, captureMode, captureText)
if captureMode {
m.textinput.Focus()
}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run()
return err
}

View file

@ -14,6 +14,9 @@ type keyMap struct {
Right key.Binding
ShiftUp key.Binding
ShiftDown key.Binding
ShiftLeft key.Binding
ShiftRight key.Binding
Rename key.Binding
CycleState key.Binding
ToggleView key.Binding
Quit key.Binding
@ -23,11 +26,13 @@ type keyMap struct {
Delete key.Binding
Save key.Binding
ToggleFold key.Binding
ToggleFoldAll key.Binding
EditNotes key.Binding
ToggleReorder key.Binding
ClockIn key.Binding
ClockOut key.Binding
SetDeadline key.Binding
SetScheduled key.Binding
SetPriority key.Binding
SetEffort key.Binding
Settings key.Binding
@ -63,6 +68,18 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap {
key.WithKeys(kb.ShiftDown...),
key.WithHelp(formatKeyHelp(kb.ShiftDown), "move item down"),
),
ShiftLeft: key.NewBinding(
key.WithKeys(kb.ShiftLeft...),
key.WithHelp(formatKeyHelp(kb.ShiftLeft), "promote item"),
),
ShiftRight: key.NewBinding(
key.WithKeys(kb.ShiftRight...),
key.WithHelp(formatKeyHelp(kb.ShiftRight), "demote item"),
),
Rename: key.NewBinding(
key.WithKeys(kb.Rename...),
key.WithHelp(formatKeyHelp(kb.Rename), "rename item"),
),
CycleState: key.NewBinding(
key.WithKeys(kb.CycleState...),
key.WithHelp(formatKeyHelp(kb.CycleState), "cycle todo state"),
@ -71,6 +88,10 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap {
key.WithKeys(kb.ToggleFold...),
key.WithHelp(formatKeyHelp(kb.ToggleFold), "fold/unfold"),
),
ToggleFoldAll: key.NewBinding(
key.WithKeys(kb.ToggleFoldAll...),
key.WithHelp(formatKeyHelp(kb.ToggleFoldAll), "fold/unfold all"),
),
EditNotes: key.NewBinding(
key.WithKeys(kb.EditNotes...),
key.WithHelp(formatKeyHelp(kb.EditNotes), "edit notes"),
@ -111,6 +132,10 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap {
key.WithKeys(kb.SetDeadline...),
key.WithHelp(formatKeyHelp(kb.SetDeadline), "set deadline"),
),
SetScheduled: key.NewBinding(
key.WithKeys(kb.SetScheduled...),
key.WithHelp(formatKeyHelp(kb.SetScheduled), "set scheduled"),
),
SetPriority: key.NewBinding(
key.WithKeys(kb.SetPriority...),
key.WithHelp(formatKeyHelp(kb.SetPriority), "set priority"),
@ -168,7 +193,7 @@ func (k keyMap) FullHelp() [][]key.Binding {
// This will be overridden by custom rendering in viewFullHelp
return [][]key.Binding{
{k.Up, k.Down, k.Left, k.Right},
{k.ToggleFold, k.EditNotes, k.ToggleReorder},
{k.ToggleFold, k.ToggleFoldAll, k.EditNotes, k.ToggleReorder},
{k.Capture, k.AddSubTask, k.Delete, k.Save},
{k.ToggleView, k.Help, k.Quit},
}
@ -178,9 +203,9 @@ func (k keyMap) FullHelp() [][]key.Binding {
func (k keyMap) getAllBindings() []key.Binding {
return []key.Binding{
k.Up, k.Down, k.Left, k.Right,
k.ToggleFold, k.EditNotes, k.ToggleReorder,
k.ToggleFold, k.ToggleFoldAll, k.EditNotes, k.ToggleReorder,
k.Capture, k.AddSubTask, k.Delete, k.Save,
k.ClockIn, k.ClockOut, k.SetDeadline, k.SetPriority, k.SetEffort,
k.ClockIn, k.ClockOut, k.SetDeadline, k.SetScheduled, k.SetPriority, k.SetEffort,
k.TagItem, k.Settings, k.ToggleView, k.Help, k.Quit,
}
}

View file

@ -26,6 +26,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateAddSubTask(msg)
case modeSetDeadline:
return m.updateSetDeadline(msg)
case modeSetScheduled:
return m.updateSetScheduled(msg)
case modeSetPriority:
return m.updateSetPriority(msg)
case modeSetEffort:
@ -40,6 +42,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateSettingsAddState(msg)
case modeTagEdit:
return m.updateTagEdit(msg)
case modeRename:
return m.updateRename(msg)
}
switch msg := msg.(type) {
@ -122,6 +126,12 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.ShiftDown):
m.moveItemDown()
case key.Matches(msg, m.keys.ShiftLeft):
m.promoteItem()
case key.Matches(msg, m.keys.ShiftRight):
m.demoteItem()
case key.Matches(msg, m.keys.CycleState):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
@ -145,10 +155,61 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case key.Matches(msg, m.keys.ToggleFoldAll):
if len(m.orgFile.Items) > 0 {
// Check if any top-level item is not folded
anyUnfolded := false
for _, item := range m.orgFile.Items {
if !item.Folded {
anyUnfolded = true
break
}
}
if anyUnfolded {
// Fold all items recursively (collapse all)
var foldAll func([]*model.Item)
foldAll = func(items []*model.Item) {
for _, item := range items {
item.Folded = true
if len(item.Children) > 0 {
foldAll(item.Children)
}
}
}
foldAll(m.orgFile.Items)
m.setStatus("All items folded")
} else {
// Unfold everything recursively
var unfoldAll func([]*model.Item)
unfoldAll = func(items []*model.Item) {
for _, item := range items {
item.Folded = false
if len(item.Children) > 0 {
unfoldAll(item.Children)
}
}
}
unfoldAll(m.orgFile.Items)
m.setStatus("All items unfolded")
}
}
case key.Matches(msg, m.keys.EditNotes):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.editingItem = items[m.cursor]
selectedItem := items[m.cursor]
// Check if we're in multi-file mode
isMultiFile := len(m.orgFile.Items) > 0 && m.orgFile.Items[0].SourceFile != ""
// Prevent editing notes for top-level file items in multi-file mode
if isMultiFile && selectedItem.Level == 1 && selectedItem.SourceFile != "" {
m.setStatus("Cannot add notes to file-level items")
return m, nil
}
m.editingItem = selectedItem
m.mode = modeEdit
m.textarea.SetValue(strings.Join(m.editingItem.Notes, "\n"))
m.textarea.Focus()
@ -171,8 +232,20 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, textinput.Blink
}
case key.Matches(msg, m.keys.Rename):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.editingItem = items[m.cursor]
m.mode = modeRename
m.textinput.SetValue(items[m.cursor].Title)
m.textinput.Placeholder = "Item title"
m.textinput.Focus()
return m, textinput.Blink
}
case key.Matches(msg, m.keys.Capture):
m.mode = modeCapture
m.captureCursor = m.cursor // Store current cursor position
m.textinput.SetValue("")
m.textinput.Placeholder = "What needs doing?"
m.textinput.Focus()
@ -250,6 +323,17 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, textinput.Blink
}
case key.Matches(msg, m.keys.SetScheduled):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.editingItem = items[m.cursor]
m.mode = modeSetScheduled
m.textinput.SetValue("")
m.textinput.Placeholder = "YYYY-MM-DD or +N (days from today)"
m.textinput.Focus()
return m, textinput.Blink
}
case key.Matches(msg, m.keys.SetPriority):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
@ -356,13 +440,36 @@ func (m uiModel) updateCapture(msg tea.Msg) (tea.Model, tea.Cmd) {
Notes: []string{},
Children: []*model.Item{},
}
// Insert at beginning
// Check if we're in multi-file mode
isMultiFile := len(m.orgFile.Items) > 0 && m.orgFile.Items[0].SourceFile != ""
if isMultiFile {
// In multi-file mode, add to the file of the highlighted item (using stored cursor position)
items := m.getVisibleItems()
targetFileItem := m.findTopLevelFileItem(items, m.captureCursor)
if targetFileItem != nil {
// Set the source file for the new item
newItem.SourceFile = targetFileItem.SourceFile
newItem.Level = 2 // Children of file items are level 2
// Insert at the beginning of the file item's children
targetFileItem.Children = append([]*model.Item{newItem}, targetFileItem.Children...)
targetFileItem.Folded = false // Unfold to show the new item
m.setStatus("TODO captured to " + targetFileItem.Title)
} else {
m.setStatus("Error: Could not find file to add to")
}
} else {
// Single file mode: insert at beginning
m.orgFile.Items = append([]*model.Item{newItem}, m.orgFile.Items...)
m.setStatus("TODO captured!")
}
}
m.mode = modeList
m.textinput.Blur()
m.cursor = 0
// Don't reset cursor, keep it where it was
return m, nil
case tea.KeyEsc:
m.mode = modeList
@ -376,6 +483,49 @@ func (m uiModel) updateCapture(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
// findTopLevelFileItem finds the top-level file item that contains the item at the given cursor position
func (m *uiModel) findTopLevelFileItem(items []*model.Item, cursorPos int) *model.Item {
if cursorPos < 0 || cursorPos >= len(items) {
// Fallback to first file if cursor out of bounds
if len(m.orgFile.Items) > 0 {
return m.orgFile.Items[0]
}
return nil
}
selectedItem := items[cursorPos]
// Check if we're in multi-file mode
isMultiFile := len(m.orgFile.Items) > 0 && m.orgFile.Items[0].SourceFile != ""
if !isMultiFile {
// Not in multi-file mode, return nil
return nil
}
// If the selected item itself is a file item (level 1 with SourceFile), return it
if selectedItem.SourceFile != "" && selectedItem.Level == 1 {
return selectedItem
}
// Otherwise, find which top-level file item this item belongs to
// by checking the SourceFile field
if selectedItem.SourceFile != "" {
for _, fileItem := range m.orgFile.Items {
if fileItem.SourceFile == selectedItem.SourceFile {
return fileItem
}
}
}
// Fallback: return the first file item
if len(m.orgFile.Items) > 0 {
return m.orgFile.Items[0]
}
return nil
}
func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
@ -400,6 +550,7 @@ func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) {
Title: title,
Notes: []string{},
Children: []*model.Item{},
SourceFile: m.editingItem.SourceFile, // Inherit source file from parent
}
m.editingItem.Children = append(m.editingItem.Children, newItem)
m.editingItem.Folded = false // Unfold to show new sub-task
@ -423,83 +574,11 @@ func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m uiModel) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.textinput.Width = 50
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
input := strings.TrimSpace(m.textinput.Value())
if m.editingItem != nil {
if input == "" {
// Empty input clears the deadline
m.editingItem.Deadline = nil
// Remove DEADLINE line from notes (only lines starting with DEADLINE:)
var filteredNotes []string
for _, note := range m.editingItem.Notes {
trimmedNote := strings.TrimSpace(note)
if !strings.HasPrefix(trimmedNote, "DEADLINE:") {
filteredNotes = append(filteredNotes, note)
}
}
m.editingItem.Notes = filteredNotes
m.setStatus("Deadline cleared!")
} else {
deadline, err := parseDeadlineInput(input)
if err != nil {
m.setStatus(fmt.Sprintf("Invalid date: %v", err))
} else {
m.editingItem.Deadline = &deadline
// Also update or add DEADLINE line in notes
updatedNotes := false
for i, note := range m.editingItem.Notes {
trimmedNote := strings.TrimSpace(note)
if strings.HasPrefix(trimmedNote, "DEADLINE:") {
m.editingItem.Notes[i] = fmt.Sprintf("DEADLINE: <%s>", parser.FormatOrgDate(deadline))
updatedNotes = true
break
}
}
// If DEADLINE wasn't in notes, it will be added by writeItem
if !updatedNotes {
// Remove old deadline lines just to be safe
var filteredNotes []string
for _, note := range m.editingItem.Notes {
trimmedNote := strings.TrimSpace(note)
if !strings.HasPrefix(trimmedNote, "DEADLINE:") {
filteredNotes = append(filteredNotes, note)
}
}
m.editingItem.Notes = filteredNotes
}
m.setStatus("Deadline set!")
}
}
}
m.mode = modeList
m.textinput.Blur()
m.editingItem = nil
return m, nil
case tea.KeyEsc:
m.mode = modeList
m.textinput.Blur()
m.editingItem = nil
m.setStatus("Cancelled")
return m, nil
}
}
m.textinput, cmd = m.textinput.Update(msg)
return m, cmd
return m.updateSetDate(msg, "DEADLINE")
}
// parseDeadlineInput parses deadline input like "2024-01-15" or "+3" (3 days from now)
func parseDeadlineInput(input string) (time.Time, error) {
// parseDateInput parses date input like "2024-01-15" or "+3" (3 days from now)
func parseDateInput(input string) (time.Time, error) {
// Check if it's a relative date (+N days)
if strings.HasPrefix(input, "+") {
daysStr := strings.TrimPrefix(input, "+")
@ -527,6 +606,110 @@ func parseDeadlineInput(input string) (time.Time, error) {
return time.Time{}, fmt.Errorf("unable to parse date: %s (use YYYY-MM-DD or +N)", input)
}
func (m uiModel) updateSetScheduled(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateSetDate(msg, "SCHEDULED")
}
func (m uiModel) updateSetDate(msg tea.Msg, dateType string) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.textinput.Width = 50
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
input := strings.TrimSpace(m.textinput.Value())
if m.editingItem != nil {
var prefixDate string
var clearedDateMsg string
var setDateMsg string
if dateType == "DEADLINE" {
prefixDate = "DEADLINE:"
clearedDateMsg = "Deadline cleared!"
setDateMsg = "Deadline set!"
} else {
prefixDate = "SCHEDULED:"
clearedDateMsg = "Scheduled date cleared!"
setDateMsg = "Scheduled date set!"
}
if input == "" {
// Empty input clears the date
if dateType == "DEADLINE" {
m.editingItem.Deadline = nil
} else {
m.editingItem.Scheduled = nil
}
// Remove property line from notes
var filteredNotes []string
for _, note := range m.editingItem.Notes {
trimmedNote := strings.TrimSpace(note)
if !strings.HasPrefix(trimmedNote, prefixDate) {
filteredNotes = append(filteredNotes, note)
}
}
m.editingItem.Notes = filteredNotes
m.setStatus(clearedDateMsg)
} else {
dateVal, err := parseDateInput(input)
if err != nil {
m.setStatus(fmt.Sprintf("Invalid date: %v", err))
} else {
if dateType == "DEADLINE" {
m.editingItem.Deadline = &dateVal
} else {
m.editingItem.Scheduled = &dateVal
}
// Also update or add property line in notes
updatedNotes := false
for i, note := range m.editingItem.Notes {
trimmedNote := strings.TrimSpace(note)
if strings.HasPrefix(trimmedNote, prefixDate) {
m.editingItem.Notes[i] = fmt.Sprintf("%s <%s>", prefixDate, parser.FormatOrgDate(dateVal))
updatedNotes = true
break
}
}
// If property wasn't in notes, it will be added by writeItem
if !updatedNotes {
// Remove old property lines just to be safe
var filteredNotes []string
for _, note := range m.editingItem.Notes {
trimmedNote := strings.TrimSpace(note)
if !strings.HasPrefix(trimmedNote, prefixDate) {
filteredNotes = append(filteredNotes, note)
}
}
m.editingItem.Notes = filteredNotes
}
m.setStatus(setDateMsg)
}
}
}
m.mode = modeList
m.textinput.Blur()
m.editingItem = nil
return m, nil
case tea.KeyEsc:
m.mode = modeList
m.textinput.Blur()
m.editingItem = nil
m.setStatus("Cancelled")
return m, nil
}
}
m.textinput, cmd = m.textinput.Update(msg)
return m, cmd
}
func (m uiModel) updateSetPriority(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@ -628,6 +811,7 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
// Find current state index
currentIndex := -1
currentState := string(item.State)
lastStateIndex := len(stateNames) - 1
// Handle empty state
if currentState == "" {
@ -641,15 +825,51 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
}
}
// Store the old state to check if we're transitioning to/from DONE
oldState := currentState
var newState string
// Cycle forward
if currentIndex < 0 || currentIndex >= len(stateNames)-1 {
if currentIndex == len(stateNames)-1 {
item.State = model.TodoState("") // Back to empty
newState = "" // Back to empty
} else {
item.State = model.TodoState(stateNames[0]) // First state
newState = stateNames[0] // First state
}
} else {
item.State = model.TodoState(stateNames[currentIndex+1])
newState = stateNames[currentIndex+1]
}
// Update the item state
item.State = model.TodoState(newState)
// Manage CLOSED timestamp
wasInDoneState := (oldState == stateNames[lastStateIndex])
isInDoneState := (newState == stateNames[lastStateIndex])
if isInDoneState && !wasInDoneState {
// Moving TO done state - add CLOSED timestamp
now := time.Now()
item.Closed = &now
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
} else if wasInDoneState && !isInDoneState {
// Moving FROM done state - remove CLOSED timestamp
item.Closed = nil
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
}
}
@ -662,6 +882,7 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
// Find current state index
currentIndex := -1
currentState := string(item.State)
lastStateIndex := len(stateNames) - 1
// Handle empty state
if currentState == "" {
@ -675,13 +896,49 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
}
}
// Store the old state to check if we're transitioning to/from DONE
oldState := currentState
var newState string
// Cycle backward
if currentIndex <= 0 {
item.State = model.TodoState("") // Empty state
newState = "" // Empty state
} else if currentIndex > len(stateNames) {
item.State = model.TodoState(stateNames[len(stateNames)-1])
newState = stateNames[len(stateNames)-1]
} else {
item.State = model.TodoState(stateNames[currentIndex-1])
newState = stateNames[currentIndex-1]
}
// Update the item state
item.State = model.TodoState(newState)
// Manage CLOSED timestamp
wasInDoneState := (oldState == stateNames[lastStateIndex])
isInDoneState := (newState == stateNames[lastStateIndex])
if isInDoneState && !wasInDoneState {
// Moving TO done state - add CLOSED timestamp
now := time.Now()
item.Closed = &now
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
} else if wasInDoneState && !isInDoneState {
// Moving FROM done state - remove CLOSED timestamp
item.Closed = nil
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
}
}
@ -820,6 +1077,151 @@ func (m *uiModel) swapItems(item1, item2 *model.Item) {
swapInList(m.orgFile.Items)
}
func (m *uiModel) promoteItem() {
items := m.getVisibleItems()
if len(items) == 0 || m.cursor >= len(items) {
return
}
currentItem := items[m.cursor]
// Can't promote a top-level item
if currentItem.Level <= 1 {
m.setStatus("Cannot promote - already at top level")
return
}
// Find the parent of this item
parent := m.findParent(currentItem)
if parent == nil {
m.setStatus("Cannot promote - no parent found")
return
}
// Remove item from parent's children
for i, child := range parent.Children {
if child == currentItem {
parent.Children = append(parent.Children[:i], parent.Children[i+1:]...)
break
}
}
// Find grandparent to insert this item after the parent
grandparent := m.findParent(parent)
if grandparent != nil {
// Insert after parent in grandparent's children
for i, child := range grandparent.Children {
if child == parent {
// Decrease level and update all descendants
m.adjustItemLevels(currentItem, -1)
grandparent.Children = append(grandparent.Children[:i+1], append([]*model.Item{currentItem}, grandparent.Children[i+1:]...)...)
break
}
}
} else {
// Parent is at top level, insert after parent in m.orgFile.Items
for i, item := range m.orgFile.Items {
if item == parent {
// Decrease level and update all descendants
m.adjustItemLevels(currentItem, -1)
m.orgFile.Items = append(m.orgFile.Items[:i+1], append([]*model.Item{currentItem}, m.orgFile.Items[i+1:]...)...)
break
}
}
}
m.setStatus("Item promoted")
// Update cursor to follow the item
items = m.getVisibleItems()
for i, item := range items {
if item == currentItem {
m.cursor = i
break
}
}
}
func (m *uiModel) demoteItem() {
items := m.getVisibleItems()
if len(items) == 0 || m.cursor >= len(items) {
return
}
currentItem := items[m.cursor]
// Find the previous sibling to make this item its child
prevSibling := m.findPreviousSibling(currentItem)
if prevSibling == nil {
m.setStatus("Cannot demote - no previous sibling")
return
}
// Remove item from its current parent's children
parent := m.findParent(currentItem)
if parent != nil {
for i, child := range parent.Children {
if child == currentItem {
parent.Children = append(parent.Children[:i], parent.Children[i+1:]...)
break
}
}
} else {
// Item is at top level
for i, item := range m.orgFile.Items {
if item == currentItem {
m.orgFile.Items = append(m.orgFile.Items[:i], m.orgFile.Items[i+1:]...)
break
}
}
}
// Increase level and update all descendants
m.adjustItemLevels(currentItem, 1)
// Add as child of previous sibling
prevSibling.Children = append(prevSibling.Children, currentItem)
prevSibling.Folded = false // Unfold to show the demoted item
m.setStatus("Item demoted")
// Update cursor to follow the item
items = m.getVisibleItems()
for i, item := range items {
if item == currentItem {
m.cursor = i
break
}
}
}
func (m *uiModel) findParent(target *model.Item) *model.Item {
var findInList func([]*model.Item) *model.Item
findInList = func(items []*model.Item) *model.Item {
for _, item := range items {
// Check if target is a direct child
for _, child := range item.Children {
if child == target {
return item
}
}
// Recursively check children
if result := findInList(item.Children); result != nil {
return result
}
}
return nil
}
return findInList(m.orgFile.Items)
}
func (m *uiModel) adjustItemLevels(item *model.Item, delta int) {
item.Level += delta
for _, child := range item.Children {
m.adjustItemLevels(child, delta)
}
}
func (m uiModel) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@ -900,3 +1302,45 @@ func (m *uiModel) updateTagEdit(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
}
// updateRename handles item rename mode
func (m *uiModel) updateRename(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
m.mode = modeList
m.textinput.Blur()
m.editingItem = nil
return m, nil
case msg.Type == tea.KeyEnter:
if m.editingItem != nil {
newTitle := strings.TrimSpace(m.textinput.Value())
if newTitle != "" {
m.editingItem.Title = newTitle
m.setStatus("Item renamed")
} else {
m.setStatus("Cannot rename to empty title")
}
}
m.mode = modeList
m.textinput.Blur()
m.editingItem = nil
return m, nil
case msg.Type == tea.KeyEsc:
m.mode = modeList
m.textinput.Blur()
m.editingItem = nil
m.setStatus("Cancelled")
return m, nil
default:
var cmd tea.Cmd
m.textinput, cmd = m.textinput.Update(msg)
return m, cmd
}
}
return m, nil
}

View file

@ -13,7 +13,8 @@ import (
type settingsSection int
const (
settingsSectionTags settingsSection = iota
settingsSectionGeneral settingsSection = iota
settingsSectionTags
settingsSectionStates
settingsSectionKeybindings
)
@ -76,7 +77,7 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Left):
// Previous section
if m.settingsSection > settingsSectionTags {
if m.settingsSection > settingsSectionGeneral {
m.settingsSection--
m.settingsCursor = 0
m.settingsScroll = 0
@ -101,6 +102,8 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Capture):
// Add new tag or state
switch m.settingsSection {
case settingsSectionGeneral:
// No capture action in General
case settingsSectionTags:
m.addNewTag()
case settingsSectionStates:
@ -128,6 +131,8 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
// getSettingsItemCount returns the number of items in the current settings view
func (m *uiModel) getSettingsItemCount() int {
switch m.settingsSection {
case settingsSectionGeneral:
return 3 // Org syntax highlighting toggle, show indentation guides toggle, indentation guide color
case settingsSectionTags:
return len(m.config.Tags.Tags) + 1 // +1 for "Add new tag" option
case settingsSectionStates:
@ -172,6 +177,40 @@ func (m *uiModel) updateSettingsScrollOffset() {
// startSettingsEdit starts editing a settings item
func (m *uiModel) startSettingsEdit() {
switch m.settingsSection {
case settingsSectionGeneral:
// Setting 0: Toggle org syntax highlighting
if m.settingsCursor == 0 {
m.config.UI.OrgSyntaxHighlighting = !m.config.UI.OrgSyntaxHighlighting
if m.config.UI.OrgSyntaxHighlighting {
m.setStatus("Org syntax highlighting enabled (saved)")
} else {
m.setStatus("Org syntax highlighting disabled (saved)")
}
// Auto-save
if err := m.config.Save(); err != nil {
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
}
}
// Setting 1: Toggle show indentation guides
if m.settingsCursor == 1 {
m.config.UI.ShowIndentationGuides = !m.config.UI.ShowIndentationGuides
if m.config.UI.ShowIndentationGuides {
m.setStatus("Indentation guides enabled (saved)")
} else {
m.setStatus("Indentation guides disabled (saved)")
}
// Auto-save
if err := m.config.Save(); err != nil {
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
}
}
// Setting 2: Edit indentation guide color
if m.settingsCursor == 2 {
m.textinput.SetValue(m.config.UI.IndentationGuideColor)
m.textinput.Placeholder = "Enter color (e.g., 245, 99)"
m.textinput.Focus()
}
return
case settingsSectionTags:
if m.settingsCursor >= len(m.config.Tags.Tags) {
return
@ -237,6 +276,23 @@ func (m *uiModel) startSettingsEdit() {
// saveSettingsEdit saves the edited value and auto-saves to disk
func (m *uiModel) saveSettingsEdit() {
switch m.settingsSection {
case settingsSectionGeneral:
// Setting 2: Indentation guide color
if m.settingsCursor == 2 {
newColor := strings.TrimSpace(m.textinput.Value())
if newColor != "" {
m.config.UI.IndentationGuideColor = newColor
m.setStatus(fmt.Sprintf("Indentation guide color set to '%s' (saved)", newColor))
// Auto-save
if err := m.config.Save(); err != nil {
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
} else {
// Reload styles
m.styles = newStyleMapFromConfig(m.config)
}
}
}
return
case settingsSectionTags:
if m.settingsCursor >= len(m.config.Tags.Tags) {
return
@ -350,6 +406,9 @@ func (m *uiModel) saveSettingsEdit() {
// deleteSettingsItem deletes the current settings item and auto-saves
func (m *uiModel) deleteSettingsItem() {
switch m.settingsSection {
case settingsSectionGeneral:
// Cannot delete general settings
return
case settingsSectionTags:
if m.settingsCursor >= len(m.config.Tags.Tags) {
return
@ -437,6 +496,12 @@ func (m *uiModel) viewSettings() string {
activeTabStyle := lipgloss.NewStyle().Padding(0, 2).Bold(true).Foreground(lipgloss.Color(m.config.Colors.Title))
tabs := ""
if m.settingsSection == settingsSectionGeneral {
tabs += activeTabStyle.Render("[General]")
} else {
tabs += tabStyle.Render("General")
}
tabs += " "
if m.settingsSection == settingsSectionTags {
tabs += activeTabStyle.Render("[Tags]")
} else {
@ -459,6 +524,8 @@ func (m *uiModel) viewSettings() string {
// Instructions
var instructions string
switch m.settingsSection {
case settingsSectionGeneral:
instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Toggle setting\nctrl+s: Save • q/,: Exit"
case settingsSectionTags:
instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Edit • D: Delete\nc: Add new tag • ctrl+s: Save • q/,: Exit"
case settingsSectionStates:
@ -470,6 +537,8 @@ func (m *uiModel) viewSettings() string {
// Render the appropriate section
switch m.settingsSection {
case settingsSectionGeneral:
content.WriteString(m.viewSettingsGeneral())
case settingsSectionTags:
content.WriteString(m.viewSettingsTags())
case settingsSectionStates:
@ -488,6 +557,68 @@ func (m *uiModel) viewSettings() string {
return content.String()
}
// viewSettingsGeneral renders the general settings section
func (m *uiModel) viewSettingsGeneral() string {
var content strings.Builder
// Calculate visible window
reservedLines := 10
if m.textinput.Focused() {
reservedLines += 3
}
availableHeight := m.height - reservedLines
if availableHeight < 3 {
availableHeight = 3
}
enabledStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Bold(true)
disabledStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
colorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
// Setting 0: Org syntax highlighting toggle
line := ""
if m.settingsCursor == 0 && !m.textinput.Focused() {
line += "▶ "
} else {
line += " "
}
line += "Org syntax highlighting: "
if m.config.UI.OrgSyntaxHighlighting {
line += enabledStyle.Render("Enabled")
} else {
line += disabledStyle.Render("Disabled")
}
content.WriteString(line + "\n")
// Setting 1: Show indentation guides toggle
line = ""
if m.settingsCursor == 1 && !m.textinput.Focused() {
line += "▶ "
} else {
line += " "
}
line += "Show indentation guides: "
if m.config.UI.ShowIndentationGuides {
line += enabledStyle.Render("Enabled")
} else {
line += disabledStyle.Render("Disabled")
}
content.WriteString(line + "\n")
// Setting 2: Indentation guide color
line = ""
if m.settingsCursor == 2 && !m.textinput.Focused() {
line += "▶ "
} else {
line += " "
}
line += "Indentation guide color: "
line += colorStyle.Render(m.config.UI.IndentationGuideColor)
content.WriteString(line + "\n")
return content.String()
}
// viewSettingsTags renders the tags section
func (m *uiModel) viewSettingsTags() string {
var content strings.Builder

View file

@ -78,6 +78,8 @@ func (m uiModel) View() string {
return m.viewAddSubTask()
case modeSetDeadline:
return m.viewSetDeadline()
case modeSetScheduled:
return m.viewSetScheduled()
case modeSetPriority:
return m.viewSetPriority()
case modeSetEffort:
@ -92,6 +94,8 @@ func (m uiModel) View() string {
return m.viewSettingsAddState()
case modeTagEdit:
return m.viewTagEdit()
case modeRename:
return m.viewRename()
}
// Build footer (status + help)
@ -146,8 +150,24 @@ func (m uiModel) View() string {
for i, item := range items {
lineCount := 1 // The item itself
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
// Build indentation for notes
var notePrefix strings.Builder
if m.config.UI.ShowIndentationGuides {
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
for j := 1; j <= item.Level; j++ {
notePrefix.WriteString(guideStyle.Render("· "))
}
} else {
// No visual guides, just use spaces
for j := 1; j <= item.Level; j++ {
notePrefix.WriteString(" ")
}
}
indent := notePrefix.String()
noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes)
highlightedNotes := renderNotesWithHighlighting(filteredNotes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
lineCount += len(highlightedNotes)
}
itemLineCount[i] = lineCount
@ -204,9 +224,24 @@ func (m uiModel) View() string {
// Render remaining notes
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
indent := strings.Repeat(" ", item.Level)
// Build indentation for notes
var notePrefix strings.Builder
if m.config.UI.ShowIndentationGuides {
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
for i := 1; i <= item.Level; i++ {
notePrefix.WriteString(guideStyle.Render("· "))
}
} else {
// No visual guides, just use spaces
for i := 1; i <= item.Level; i++ {
notePrefix.WriteString(" ")
}
}
indent := notePrefix.String()
noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes)
highlightedNotes := renderNotesWithHighlighting(filteredNotes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
for noteIdx := linesToSkip - 1; noteIdx < len(highlightedNotes) && itemLines < availableHeight; noteIdx++ {
content.WriteString(indent)
content.WriteString(" " + highlightedNotes[noteIdx])
@ -226,9 +261,24 @@ func (m uiModel) View() string {
// Show notes if not folded
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
indent := strings.Repeat(" ", item.Level)
// Build indentation for notes
var notePrefix strings.Builder
if m.config.UI.ShowIndentationGuides {
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
for i := 1; i <= item.Level; i++ {
notePrefix.WriteString(guideStyle.Render("· "))
}
} else {
// No visual guides, just use spaces
for i := 1; i <= item.Level; i++ {
notePrefix.WriteString(" ")
}
}
indent := notePrefix.String()
noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes)
highlightedNotes := renderNotesWithHighlighting(filteredNotes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
for _, note := range highlightedNotes {
if itemLines >= availableHeight {
break
@ -331,6 +381,14 @@ func (m uiModel) viewAddSubTask() string {
}
func (m uiModel) viewSetDeadline() string {
return m.viewSetDate("Set Deadline", "Leave empty to clear deadline")
}
func (m uiModel) viewSetScheduled() string {
return m.viewSetDate("Set Scheduled Date", "Leave empty to clear scheduled date")
}
func (m uiModel) viewSetDate(title, helpMsg string) string {
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("141")).
@ -338,7 +396,7 @@ func (m uiModel) viewSetDeadline() string {
Width(60)
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Set Deadline"))
content.WriteString(m.styles.titleStyle.Render(title))
content.WriteString("\n")
if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
@ -348,7 +406,7 @@ func (m uiModel) viewSetDeadline() string {
content.WriteString("\n\n")
content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)"))
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Leave empty to clear deadline"))
content.WriteString(m.styles.statusStyle.Render(helpMsg))
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
@ -440,7 +498,7 @@ func (m uiModel) viewHelp() string {
navigationBindings := []key.Binding{m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right}
itemBindings := []key.Binding{m.keys.ToggleFold, m.keys.EditNotes, m.keys.CycleState}
taskBindings := []key.Binding{m.keys.Capture, m.keys.AddSubTask, m.keys.Delete}
timeBindings := []key.Binding{m.keys.ClockIn, m.keys.ClockOut, m.keys.SetDeadline, m.keys.SetEffort}
timeBindings := []key.Binding{m.keys.ClockIn, m.keys.ClockOut, m.keys.SetDeadline, m.keys.SetScheduled, m.keys.SetEffort}
organizationBindings := []key.Binding{m.keys.SetPriority, m.keys.TagItem, m.keys.ShiftUp, m.keys.ShiftDown, m.keys.ToggleReorder}
viewBindings := []key.Binding{m.keys.ToggleView, m.keys.Settings, m.keys.Save, m.keys.Help, m.keys.Quit}
@ -599,8 +657,8 @@ func filterLogbookDrawer(notes []string) []string {
continue
}
// Skip SCHEDULED and DEADLINE lines
if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") {
// Skip SCHEDULED, DEADLINE, and CLOSED lines
if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") || strings.HasPrefix(trimmed, "CLOSED:") {
continue
}
@ -610,8 +668,31 @@ func filterLogbookDrawer(notes []string) []string {
return filtered
}
// wrapNoteLines wraps note lines to fit within the specified width
func wrapNoteLines(notes []string, width int, indent string) []string {
var wrapped []string
for _, note := range notes {
// Don't wrap code block delimiters or drawer markers
trimmed := strings.TrimSpace(note)
if strings.HasPrefix(trimmed, "#+BEGIN_SRC") ||
strings.HasPrefix(trimmed, "#+END_SRC") ||
strings.HasPrefix(trimmed, "```") ||
trimmed == ":LOGBOOK:" ||
trimmed == ":PROPERTIES:" ||
trimmed == ":END:" {
wrapped = append(wrapped, note)
continue
}
// Wrap the note line
wrappedLines := wrapText(note, width, indent)
wrapped = append(wrapped, wrappedLines...)
}
return wrapped
}
// renderNotesWithHighlighting renders notes with syntax highlighting for code blocks
func renderNotesWithHighlighting(notes []string) []string {
func (m uiModel) renderNotesWithHighlighting(notes []string) []string {
if len(notes) == 0 {
return notes
}
@ -711,10 +792,16 @@ func renderNotesWithHighlighting(notes []string) []string {
// If in code block, accumulate lines
if inCodeBlock {
codeLines = append(codeLines, note)
} else {
// Apply org-mode syntax highlighting to non-code text if enabled
if m.config.UI.OrgSyntaxHighlighting {
highlighted := highlightCode(note, "org")
result = append(result, highlighted)
} else {
result = append(result, note)
}
}
}
// Handle case where code block wasn't closed
if inCodeBlock && len(codeLines) > 0 {
@ -751,12 +838,116 @@ func highlightCode(code, language string) string {
return strings.TrimRight(buf.String(), "\n")
}
// wrapText wraps text to fit within the specified width, accounting for indent
func wrapText(text string, width int, indent string) []string {
if width <= 0 {
return []string{text}
}
// Calculate available width after indent
indentWidth := lipgloss.Width(indent)
availableWidth := width - indentWidth
if availableWidth <= 10 {
// If very little space, just return the original text
return []string{text}
}
var result []string
var currentLine strings.Builder
currentWidth := 0
// Split by whitespace while preserving leading/trailing spaces
words := strings.Fields(text)
if len(words) == 0 {
// Preserve empty lines
return []string{text}
}
for i, word := range words {
wordWidth := lipgloss.Width(word)
// If this is the first word on the line
if currentWidth == 0 {
// Handle words longer than available width
if wordWidth > availableWidth {
// Split the word across multiple lines
for len(word) > 0 {
if availableWidth <= 0 {
availableWidth = 10 // Fallback
}
chunkSize := availableWidth
if chunkSize > len(word) {
chunkSize = len(word)
}
result = append(result, word[:chunkSize])
word = word[chunkSize:]
}
continue
}
currentLine.WriteString(word)
currentWidth = wordWidth
} else {
// Check if adding this word (plus a space) would exceed the width
spaceAndWordWidth := currentWidth + 1 + wordWidth
if spaceAndWordWidth > availableWidth {
// Start a new line
result = append(result, currentLine.String())
currentLine.Reset()
// Handle words longer than available width
if wordWidth > availableWidth {
for len(word) > 0 {
chunkSize := availableWidth
if chunkSize > len(word) {
chunkSize = len(word)
}
result = append(result, word[:chunkSize])
word = word[chunkSize:]
}
currentWidth = 0
continue
}
currentLine.WriteString(word)
currentWidth = wordWidth
} else {
// Add word to current line
currentLine.WriteString(" ")
currentLine.WriteString(word)
currentWidth = spaceAndWordWidth
}
}
// If this is the last word, add the line
if i == len(words)-1 && currentLine.Len() > 0 {
result = append(result, currentLine.String())
}
}
return result
}
func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
var b strings.Builder
// Indentation for level
indent := strings.Repeat(" ", item.Level-1)
b.WriteString(indent)
// Indentation with subtle visual nesting guides
if m.config.UI.ShowIndentationGuides {
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
for i := 1; i < item.Level; i++ {
if i == item.Level-1 {
// Last level before the item - use subtle dot connector
b.WriteString(guideStyle.Render("· "))
} else {
// Parent levels - use subtle dot
b.WriteString(guideStyle.Render("· "))
}
}
} else {
// No visual guides, just use spaces for indentation
for i := 1; i < item.Level; i++ {
b.WriteString(" ")
}
}
// Fold indicator
if len(item.Children) > 0 || len(item.Notes) > 0 {
@ -886,3 +1077,21 @@ func (m uiModel) viewTagEdit() string {
return content.String()
}
// viewRename renders the rename item view
func (m uiModel) viewRename() string {
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Rename Item") + "\n\n")
if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Current: %s", m.editingItem.Title)) + "\n\n")
}
content.WriteString(m.textinput.View() + "\n\n")
content.WriteString(m.styles.statusStyle.Render("Enter new title for the item") + "\n\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel") + "\n")
return content.String()
}