Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,8 +567,12 @@ reloads it afterwards. In Vim mode, `Ctrl+Z` suspends to the shell.
with `:tasks list|kanban|calendar`, `Space x`, `Space k`, or the rows
under Tasks in the sidebar; `v` cycles them. `x` toggles, Enter opens the
note at the line, `p d w c /` set priority, due date, waiting, cancelled
and in progress, `f` filters, `m` opens the menu. The default board uses
Today / Upcoming / In progress / Waiting / Done. `H`/`L` move a card;
and in progress, `f` filters, `m` opens the menu. The list and the default
board use Today / Upcoming / No due date / In progress / Waiting / Done
(moving a card to No due date clears its due date). `O` or `:order
priority|note` orders the list inside each group by priority or by the
notes' line order (`tasks_list_order`, shared with the desktop's Order
menu; `J`/`K` reorder in note order). `H`/`L` move a card;
`g` regroups by status, priority, due date, folder or `field:<key>`.
`:boardoptions` (also F2 → Board options) sets the folder root, column labels,
custom statuses and card order. Folder boards keep cross-folder moves
Expand Down
6 changes: 6 additions & 0 deletions internal/config/prefs.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type Prefs struct {
CalendarWeekStart string
CalendarShowWeekNumbers bool
TasksViewMode string
TasksListOrder string // note | priority: the desktop's Order menu above the Tasks list (#989)
ShowArchivedTasks bool
KanbanGroupBy string
KanbanFolderRoot string
Expand Down Expand Up @@ -114,6 +115,7 @@ func DefaultPrefs() Prefs {
CalendarWeekStart: "monday",
CalendarShowWeekNumbers: true,
TasksViewMode: "list",
TasksListOrder: "note",
KanbanGroupBy: "status",
KanbanStatuses: []string{},
KanbanColumnTitles: map[string]string{},
Expand Down Expand Up @@ -200,6 +202,10 @@ func applyPrefs(p *Prefs, doc map[string]any) {
setString(view, "calendar_week_start", &p.CalendarWeekStart)
setBool(view, "calendar_show_week_numbers", &p.CalendarShowWeekNumbers)
setString(view, "tasks_view_mode", &p.TasksViewMode)
setString(view, "tasks_list_order", &p.TasksListOrder)
if p.TasksListOrder != "priority" {
p.TasksListOrder = "note"
}
setBool(view, "show_archived_tasks", &p.ShowArchivedTasks)
setString(view, "kanban_group_by", &p.KanbanGroupBy)
setString(view, "kanban_folder_root", &p.KanbanFolderRoot)
Expand Down
19 changes: 19 additions & 0 deletions internal/config/prefs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,22 @@ func TestStarterConfigParses(t *testing.T) {
t.Fatalf("starter theme: %+v", prefs)
}
}

func TestPrefsReadTheTasksListOrder(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZENNOTES_CONFIG_DIR", dir)
prefs, _, err := LoadPrefs()
if err != nil || prefs.TasksListOrder != "note" {
t.Fatalf("default order = %q, %v", prefs.TasksListOrder, err)
}
for _, tc := range []struct{ value, want string }{{"priority", "priority"}, {"note", "note"}, {"due", "note"}} {
toml := "[view]\ntasks_list_order = \"" + tc.value + "\" # note | priority\n"
if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(toml), 0o644); err != nil {
t.Fatal(err)
}
prefs, _, err = LoadPrefs()
if err != nil || prefs.TasksListOrder != tc.want {
t.Fatalf("tasks_list_order = %q read as %q, want %q (%v)", tc.value, prefs.TasksListOrder, tc.want, err)
}
}
}
15 changes: 15 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ func (a *App) commandTable() []command {
{names: []string{"comments"}, title: "Note comments", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.openComments(); return nil }},
{names: []string{"filters"}, title: "Saved task filters", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.savedTaskFilters(); return nil }},
{names: []string{"savefilter"}, title: "Save current task filter", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.saveTaskFilter(); return nil }},
{names: []string{"order"}, title: "Order the task list (note | priority)", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error {
switch want := strings.ToLower(strings.TrimSpace(cmd.Args)); want {
case "note", "priority":
a.setTasksListOrder(want)
case "":
next := "priority"
if a.prefs.TasksListOrder == "priority" {
next = "note"
}
a.setTasksListOrder(next)
default:
a.notify("Usage: :order note | priority")
}
return nil
}},
{names: []string{"boardoptions"}, title: "Task board options", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error {
if v := a.activeTasksView(); v != nil {
v.boardMenu(a)
Expand Down
5 changes: 3 additions & 2 deletions internal/tui/helpdata.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,14 @@ func helpLines(a *App, filter string) []string {
"x / Enter\tToggle done; open the task's note at its line",
"p d w c /\tPriority, due date, waiting, cancelled, in progress",
"e s < >\tEdit text, set a field, shift the due date by a day",
"J K\tMove the task up or down inside its note",
"J K\tMove the task up or down inside its note (list in note order)",
"O / :order\tList order: note order or priority (high first in each group); shared with the desktop's Order menu via tasks_list_order",
"n f F A\tNew task in the daily note, filter, clear the filter, include archived",
"m\tTask menu (includes forward to today's daily note)",
"Board: h l j k\tMove between columns and cards; H L move the card; g cycles the grouping",
"Calendar: h j k l [ ] t\tMove days and months, t is today; Enter lists the day's tasks; o opens the daily note",
"Filter syntax\tDesktop substring matching: task/note text, !high, #tag, @field:value. Terminal operators require where:, e.g. where: is:open due:today -meeting",
"Board status\tToday / Upcoming / In progress / Waiting / Done; custom status uses field:status. Folder boards do not move cards between folders.",
"Board status\tToday / Upcoming / No due date / In progress / Waiting / Done; custom status uses field:status. Folder boards do not move cards between folders.",
"F2 / :boardoptions\tView actions, saved filters, grouping, folder root, labels and persistent card order",
"",
"## Tags",
Expand Down
31 changes: 31 additions & 0 deletions internal/tui/task_filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,39 @@ func (a *App) filterMenu(name string) {
}},
})
}

// setTasksListOrder writes tasks_list_order to config.toml, the key the
// desktop's Order menu above the Tasks list uses (#989), so both agree.
func (a *App) setTasksListOrder(order string) {
if err := config.SetValue("view", "tasks_list_order", order); err != nil {
a.notifyError(err.Error())
return
}
_ = a.reloadPreferences()
a.prefs.TasksListOrder = order
if v := a.activeTasksView(); v != nil {
v.refresh(a)
}
if order == "priority" {
a.notify("Tasks ordered by priority")
} else {
a.notify("Tasks in their notes' order")
}
}

func (v *tasksView) boardMenu(a *App) {
listOrder := "List order: priority (O switches to note order)"
if a.prefs.TasksListOrder != "priority" {
listOrder = "List order: note order (O switches to priority)"
}
a.showMenu("Board options", []menuItem{
{key: "o", label: listOrder, run: func(a *App) {
next := "priority"
if a.prefs.TasksListOrder == "priority" {
next = "note"
}
a.setTasksListOrder(next)
}},
{key: "g", label: "Group by", run: func(a *App) {
items := []paletteItem{}
for _, g := range v.groupOptions() {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/task_query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func TestTaskViewsApplyPortableSavedQuery(t *testing.T) {
v := &tasksView{groupBy: "status", filter: "@project:alpha"}
v.refresh(a)
assertTaskQueryIDs(t, v.tasks, []string{"alpha"})
assertKanbanCards(t, v, "today", []string{"alpha"})
assertKanbanCards(t, v, "undated", []string{"alpha"})
}

func assertTaskQueryIDs(t *testing.T, tasks []vault.Task, want []string) {
Expand Down
44 changes: 44 additions & 0 deletions internal/tui/tasks_list_order_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package tui

import (
"testing"
"time"

"github.com/ZenNotes/tui/internal/config"
"github.com/ZenNotes/tui/internal/vault"
)

// The list follows tasks_list_order, the desktop's Order menu (#989).
func TestTasksListFollowsTheListOrder(t *testing.T) {
today := vault.TodayISO(time.Now())
tasks := []vault.Task{
{ID: "bank", Content: "bank", SourcePath: "a.md", TaskIndex: 0, Priority: "med", Due: today},
{ID: "passport", Content: "passport", SourcePath: "a.md", TaskIndex: 1, Priority: "high", Due: today},
{ID: "plants", Content: "plants", SourcePath: "b.md", TaskIndex: 0, Due: today},
}
for _, tc := range []struct {
order string
want []string
}{
{"note", []string{"bank", "passport", "plants"}},
{"priority", []string{"passport", "bank", "plants"}},
} {
a := &App{idx: &index{tasks: tasks}, prefs: prefsView{Prefs: config.Prefs{TasksListOrder: tc.order}}}
v := &tasksView{mode: "list"}
v.refresh(a)
var got []string
for _, row := range v.rows {
if row.task != nil {
got = append(got, row.task.ID)
}
}
if len(got) != len(tc.want) {
t.Fatalf("%s: rows = %v, want %v", tc.order, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("%s: rows = %v, want %v", tc.order, got, tc.want)
}
}
}
}
27 changes: 23 additions & 4 deletions internal/tui/tasks_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,18 @@ func TestKanbanStatusColumnsMatchDesktop(t *testing.T) {
}}
a := &App{prefs: prefsView{Prefs: config.Prefs{KanbanStatuses: []string{"review", "blocked"}}}}
v.buildColumns(a)
assertKanbanColumnIDs(t, v, []string{"today", "upcoming", "in-progress", "waiting", "done"})
wantTitles := []string{"Today", "Upcoming", "In progress", "Waiting", "Done"}
assertKanbanColumnIDs(t, v, []string{"today", "upcoming", "undated", "in-progress", "waiting", "done"})
wantTitles := []string{"Today", "Upcoming", "No due date", "In progress", "Waiting", "Done"}
for i, col := range v.columns {
if col.title != wantTitles[i] {
t.Errorf("column %q title = %q, want %q", col.id, col.title, wantTitles[i])
}
}
want := map[string]string{
"undated": "today", "due-today": "today", "overdue": "today",
"undated": "undated", "due-today": "today", "overdue": "today",
"future": "upcoming", "started-undated": "in-progress", "started-future": "in-progress",
"waiting-started": "waiting", "waiting-overdue": "waiting", "done-waiting": "done",
"custom-status": "today", "custom-started": "today",
"custom-status": "undated", "custom-started": "undated",
}
got := map[string]string{}
for _, col := range v.columns {
Expand Down Expand Up @@ -211,6 +211,7 @@ func TestKanbanStatusMovesMatchDesktopMutations(t *testing.T) {
{name: "progress clears waiting and preserves due", line: "- [ ] Work @waiting due:" + future, target: "in-progress", due: future, progress: true},
{name: "waiting preserves progress and due", line: "- [/] Work due:" + future, target: "waiting", due: future, waiting: true, progress: true},
{name: "done preserves waiting and due", line: "- [/] Work @waiting due:" + future, target: "done", due: future, waiting: true, checked: true},
{name: "no due date clears due and reopens (#990)", line: "- [/] Work @waiting due:" + future, target: "undated", due: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -229,6 +230,24 @@ func TestKanbanStatusMovesMatchDesktopMutations(t *testing.T) {
}
}

func TestKanbanNoDueDateRefusesADailyNoteDate(t *testing.T) {
// The date comes from the note, not a due: on the line, so there is nothing
// to clear and the card would land straight back in its day (#990).
const body = "- [ ] Work @status:review\n"
a, path, task := newKanbanMutationTestApp(t, body)
task.Due = vault.TodayISO(time.Now())
task.DueInferred = true
v := &tasksView{groupBy: "status", columns: []kanbanColumn{{id: "undated"}}}
v.moveCard(a, task, 0)
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("a daily-note task must not be rewritten; body=%q, want %q", got, body)
}
}

func TestKanbanFieldMovesWriteUnprefixedField(t *testing.T) {
tests := []struct{ group, target, key, want string }{
{"field:project", "beta", "project", "beta"},
Expand Down
39 changes: 32 additions & 7 deletions internal/tui/views_tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (v *tasksView) setMode(mode string) {
func (v *tasksView) refresh(a *App) {
all := a.displayTasks()
v.tasks = filterTaskQuery(all, v.filter)
groups := vault.GroupTasks(v.tasks, time.Now())
groups := vault.OrderTaskGroups(vault.GroupTasks(v.tasks, time.Now()), a.prefs.TasksListOrder)
v.rows = nil
add := func(name string, tasks []vault.Task, extra string) {
if len(tasks) == 0 {
Expand All @@ -110,6 +110,7 @@ func (v *tasksView) refresh(a *App) {
}
add("Today", groups.Today, overdue)
add("Upcoming", groups.Upcoming, "")
add("No due date", groups.Undated, "")
add("Waiting", groups.Waiting, "")
add("Done", groups.Done, "")
add("Forwarded", groups.Forwarded, "")
Expand Down Expand Up @@ -290,7 +291,7 @@ func (v *tasksView) buildColumns(a *App) {
}
}
case "status", "":
for _, c := range []struct{ id, title string }{{"today", "Today"}, {"upcoming", "Upcoming"}, {"in-progress", "In progress"}, {"waiting", "Waiting"}, {"done", "Done"}} {
for _, c := range []struct{ id, title string }{{"today", "Today"}, {"upcoming", "Upcoming"}, {"undated", "No due date"}, {"in-progress", "In progress"}, {"waiting", "Waiting"}, {"done", "Done"}} {
cols = append(cols, kanbanColumn{id: c.id, title: v.columnTitle(a, c.id, c.title)})
}
today := vault.TodayISO(time.Now())
Expand All @@ -306,6 +307,8 @@ func (v *tasksView) buildColumns(a *App) {
id = "waiting"
case t.InProgress:
id = "in-progress"
case t.Due == "":
id = "undated"
case t.Due > today:
id = "upcoming"
}
Expand Down Expand Up @@ -772,6 +775,14 @@ func (v *tasksView) handleKey(a *App, k vim.Key) bool {
v.boardMenu(a)
return true
}
if k.IsRune('O') && v.mode == "list" && a.listKeyAllowed(k) {
next := "priority"
if a.prefs.TasksListOrder == "priority" {
next = "note"
}
a.setTasksListOrder(next)
return true
}
if k.IsRune('v') && a.listKeyAllowed(k) {
switch v.mode {
case "list":
Expand Down Expand Up @@ -893,13 +904,18 @@ func (v *tasksView) listKey(a *App, k vim.Key) bool {
if t != nil {
a.openTaskSource(*t)
}
case "tasks.moveTaskUp":
if t != nil {
a.moveTaskWithinNote(*t, -1)
case "tasks.moveTaskUp", "tasks.moveTaskDown":
// J and K move lines in the note, which the list only shows in note order.
if a.prefs.TasksListOrder == "priority" {
a.notify("Reordering works in note order. Press O to switch.")
return true
}
case "tasks.moveTaskDown":
if t != nil {
a.moveTaskWithinNote(*t, 1)
dir := 1
if id == "tasks.moveTaskUp" {
dir = -1
}
a.moveTaskWithinNote(*t, dir)
}
case "nav.localEx":
a.openLocalEx()
Expand Down Expand Up @@ -1043,6 +1059,10 @@ func (v *tasksView) moveCard(a *App, t vault.Task, target int) {
return
}
case "status", "":
if col.id == "undated" && t.DueInferred {
a.notify("This task's date comes from its daily note, so there is no due date to clear")
return
}
a.setTaskStatusColumn(t, col.id)
case "folder":
a.notify("Folder boards are read-only. Move the source note to change its folder.")
Expand Down Expand Up @@ -1305,6 +1325,11 @@ func (a *App) setTaskStatusColumn(t vault.Task, status string) {
checked(false)
waiting(false)
progress(true)
case "undated":
checked(false)
waiting(false)
progress(false)
due("")
case "today", "upcoming":
checked(false)
waiting(false)
Expand Down
Loading
Loading