mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
commit
4891de4163
84 changed files with 6424 additions and 1742 deletions
|
|
@ -32,16 +32,16 @@ tiki exec 'select where status = "ready" order by priority'
|
|||
tiki exec 'update where id = "TIKI-ABC123" set status="done"'
|
||||
```
|
||||
|
||||
### config
|
||||
### workflow
|
||||
|
||||
Manage configuration files.
|
||||
Manage workflow configuration files.
|
||||
|
||||
#### config reset
|
||||
#### workflow reset
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
```bash
|
||||
tiki config reset [target] --scope
|
||||
tiki workflow reset [target] [--scope]
|
||||
```
|
||||
|
||||
**Targets** (omit to reset all three files):
|
||||
|
|
@ -49,7 +49,7 @@ tiki config reset [target] --scope
|
|||
- `workflow` — workflow.yaml
|
||||
- `new` — new.md (task template)
|
||||
|
||||
**Scopes** (required):
|
||||
**Scopes** (default: `--local`):
|
||||
- `--global` — user config directory
|
||||
- `--local` — project config directory (`.doc/`)
|
||||
- `--current` — current working directory
|
||||
|
|
@ -60,13 +60,54 @@ For `--local` and `--current`, files are deleted so the next tier in the [preced
|
|||
|
||||
```bash
|
||||
# restore all global config to defaults
|
||||
tiki config reset --global
|
||||
tiki workflow reset --global
|
||||
|
||||
# remove project workflow overrides (falls back to global)
|
||||
tiki config reset workflow --local
|
||||
tiki workflow reset workflow --local
|
||||
|
||||
# remove cwd config override
|
||||
tiki config reset config --current
|
||||
tiki workflow reset config --current
|
||||
```
|
||||
|
||||
#### workflow install
|
||||
|
||||
Install a named workflow from the tiki repository. Downloads `workflow.yaml` and `new.md` into the scope directory, overwriting any existing files.
|
||||
|
||||
```bash
|
||||
tiki workflow install <name> [--scope]
|
||||
```
|
||||
|
||||
**Scopes** (default: `--local`):
|
||||
- `--global` — user config directory
|
||||
- `--local` — project config directory (`.doc/`)
|
||||
- `--current` — current working directory
|
||||
|
||||
```bash
|
||||
# install the sprint workflow globally
|
||||
tiki workflow install sprint --global
|
||||
|
||||
# install the kanban workflow for the current project
|
||||
tiki workflow install kanban --local
|
||||
```
|
||||
|
||||
#### workflow describe
|
||||
|
||||
Fetch a workflow's description from the tiki repository and print it to stdout.
|
||||
Reads the top-level `description` field of the named workflow's `workflow.yaml`.
|
||||
Prints nothing and exits 0 if the workflow has no description field.
|
||||
|
||||
```bash
|
||||
tiki workflow describe <name>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# preview the todo workflow before installing it
|
||||
tiki workflow describe todo
|
||||
|
||||
# check what bug-tracker is for
|
||||
tiki workflow describe bug-tracker
|
||||
```
|
||||
|
||||
### demo
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@ views:
|
|||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: update where id = id() set assignee=user()
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
plugins:
|
||||
- name: Kanban
|
||||
description: "Move tiki to new status, search, create or delete"
|
||||
|
|
@ -209,12 +213,6 @@ views:
|
|||
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=2
|
||||
view: expanded
|
||||
- name: Help
|
||||
description: "Keyboard shortcuts, navigation, and usage guide"
|
||||
type: doki
|
||||
fetcher: internal
|
||||
text: "Help"
|
||||
key: "?"
|
||||
- name: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ tiki is highly customizable. `workflow.yaml` lets you define your workflow statu
|
|||
how tikis are displayed and organized. Statuses define the lifecycle stages your tasks move through,
|
||||
while plugins control what you see and how you interact with your work. This section covers both.
|
||||
|
||||
## Description
|
||||
|
||||
An optional top-level `description:` field in `workflow.yaml` describes what
|
||||
the workflow is for. It supports multi-line text via YAML's block scalar (`|`)
|
||||
and is used by `tiki workflow describe <name>` to preview a workflow before
|
||||
installing it.
|
||||
|
||||
```yaml
|
||||
description: |
|
||||
Release workflow. Coordinate feature rollout through
|
||||
Planned → Building → Staging → Canary → Released.
|
||||
```
|
||||
|
||||
## Statuses
|
||||
|
||||
Workflow statuses are defined in `workflow.yaml` under the `statuses:` key. Every tiki project must define
|
||||
|
|
@ -220,14 +233,72 @@ actions:
|
|||
|
||||
Each action has:
|
||||
- `key` - a single printable character used as the keyboard shortcut
|
||||
- `label` - description shown in the header
|
||||
- `label` - description shown in the header and action palette
|
||||
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
|
||||
- `hot` - (optional) controls header visibility. `hot: true` shows the action in the header, `hot: false` hides it. When absent, actions default to visible in the header. This does not affect the action palette — all actions are always discoverable via `?` regardless of the `hot` setting
|
||||
- `input` - (optional) declares that the action prompts for user input before executing. The value is the scalar type of the input: `string`, `int`, `bool`, `date`, `timestamp`, or `duration`. The action's `ruki` statement must use `input()` to reference the value
|
||||
|
||||
Example — keeping a verbose action out of the header but still accessible from the palette:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- key: "x"
|
||||
label: "Archive and notify"
|
||||
action: update where id = id() set status="done"
|
||||
hot: false
|
||||
```
|
||||
|
||||
When the shortcut key is pressed, the action is applied to the currently selected tiki.
|
||||
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
|
||||
|
||||
`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki.
|
||||
|
||||
### Input-backed actions
|
||||
|
||||
Actions with `input:` prompt the user for a value before executing. When the action key is pressed, a modal input box opens with the action label as the prompt. The user types a value and presses Enter to execute, or Esc to cancel.
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee = input()
|
||||
input: string
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags = tags + [input()]
|
||||
input: string
|
||||
- key: "T"
|
||||
label: "Remove tag"
|
||||
action: update where id = id() set tags = tags - [input()]
|
||||
input: string
|
||||
- key: "p"
|
||||
label: "Set points"
|
||||
action: update where id = id() set points = input()
|
||||
input: int
|
||||
- key: "D"
|
||||
label: "Set due date"
|
||||
action: update where id = id() set due = input()
|
||||
input: date
|
||||
```
|
||||
|
||||
The input box is modal while editing — other actions are blocked until Enter or Esc. If the entered value is invalid for the declared type (e.g. non-numeric text for `int`), an error appears in the statusline and the prompt stays open for correction.
|
||||
|
||||
Supported `input:` types: `string`, `int`, `bool`, `date` (YYYY-MM-DD), `timestamp` (RFC3339 or YYYY-MM-DD), `duration` (e.g. `2day`, `1week`).
|
||||
|
||||
Validation rules:
|
||||
- An action with `input:` must use `input()` in its `ruki` statement
|
||||
- An action using `input()` must declare `input:` — otherwise the workflow fails to load
|
||||
- `input()` may only appear once per action
|
||||
|
||||
### Search and input box interaction
|
||||
|
||||
The input box serves both search and action-input, with explicit mode tracking:
|
||||
|
||||
- **Search editing**: pressing `/` opens the input box focused for typing. Enter with text applies the search and transitions to **search passive** mode. Enter on empty text is a no-op. Esc clears search and closes the box.
|
||||
- **Search passive**: the search box remains visible as a non-editable indicator showing the active query, while normal task navigation and actions are re-enabled. Pressing `/` again is blocked — dismiss the active search with Esc first, then open a new search. Esc clears the search results and closes the box.
|
||||
- **Action input**: pressing an input-backed action key opens a modal prompt. If search was passive, the prompt temporarily replaces the search indicator. Valid Enter executes the action and restores the passive search indicator (or closes if no prior search). Esc cancels and likewise restores passive search. Invalid Enter keeps the prompt open for correction.
|
||||
- **Modal blocking**: while search editing or action input is active, all other plugin actions and keyboard shortcuts are blocked. The action palette cannot open while the input box is editing.
|
||||
|
||||
### ruki expressions
|
||||
|
||||
Plugin filters, lane actions, and plugin actions all use the [ruki](ruki/index.md) language. Filters use `select` statements. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored).
|
||||
|
|
@ -300,6 +371,7 @@ update where id = id() set assignee=user()
|
|||
- `user()` — current user
|
||||
- `now()` — current timestamp
|
||||
- `id()` — currently selected tiki (in plugin context)
|
||||
- `input()` — user-supplied value (in actions with `input:` declaration)
|
||||
- `count(select where ...)` — count matching tikis
|
||||
|
||||
For the full language reference, see the [ruki documentation](ruki/index.md).
|
||||
|
|
@ -16,7 +16,7 @@ cd /tmp && tiki demo
|
|||
```
|
||||
|
||||
Move your tiki around the board with `Shift ←/Shift →`.
|
||||
Make sure to press `?` for help.
|
||||
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
|
||||
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
|
||||
|
||||
## AI skills
|
||||
|
|
@ -56,7 +56,7 @@ Store your notes in remotes!
|
|||
|
||||
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
|
||||
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
|
||||
Read more by pressing `?` for help
|
||||
Press `?` to open the Action Palette and discover all available actions
|
||||
|
||||
# AI skills
|
||||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ create title="x" dependsOn=dependsOn + tags
|
|||
| `next_date(...)` | `date` | exactly 1 | argument must be `recurrence` |
|
||||
| `blocks(...)` | `list<ref>` | exactly 1 | argument must be `id`, `ref`, or string literal |
|
||||
| `id()` | `id` | 0 | valid only in plugin runtime; resolves to selected tiki ID |
|
||||
| `input()` | declared type | 0 | valid only in plugin actions with `input:` declaration |
|
||||
| `call(...)` | `string` | exactly 1 | argument must be `string` |
|
||||
| `user()` | `string` | 0 | no additional validation |
|
||||
|
||||
|
|
@ -223,6 +224,8 @@ select where blocks(id) is empty
|
|||
select where id() in dependsOn
|
||||
create title=call("echo hi")
|
||||
select where assignee = user()
|
||||
update where id = id() set assignee = input()
|
||||
update where id = id() set tags = tags + [input()]
|
||||
```
|
||||
|
||||
Runtime notes:
|
||||
|
|
@ -231,6 +234,7 @@ Runtime notes:
|
|||
- When a validated statement uses `id()`, plugin execution must provide a non-empty selected task ID.
|
||||
- `id()` is rejected for CLI, event-trigger, and time-trigger semantic runtimes.
|
||||
- `call(...)` is currently rejected by semantic validation.
|
||||
- `input()` returns the value typed by the user at the action prompt. Its return type matches the `input:` declaration on the action (e.g. `input: string` means `input()` returns `string`). Only valid in plugin action statements that declare `input:`. May only appear once per action. Accepted `timestamp` input formats: RFC3339, with YYYY-MM-DD as a convenience fallback.
|
||||
|
||||
`run(...)`
|
||||
|
||||
|
|
|
|||
|
|
@ -317,3 +317,51 @@ select where select = 1
|
|||
create title=select
|
||||
```
|
||||
|
||||
## input() errors
|
||||
|
||||
`input()` without `input:` declaration on the action:
|
||||
|
||||
```sql
|
||||
update where id = id() set assignee = input()
|
||||
```
|
||||
|
||||
Fails at workflow load time with: `input() requires 'input:' declaration on action`.
|
||||
|
||||
`input:` declared but `input()` not used:
|
||||
|
||||
```yaml
|
||||
- key: "a"
|
||||
label: "Ready"
|
||||
action: update where id = id() set status="ready"
|
||||
input: string
|
||||
```
|
||||
|
||||
Fails at workflow load time: `declares 'input: string' but does not use input()`.
|
||||
|
||||
Duplicate `input()` (more than one call per action):
|
||||
|
||||
```sql
|
||||
update where id = id() set assignee=input(), title=input()
|
||||
```
|
||||
|
||||
Fails with: `input() may only be used once per action`.
|
||||
|
||||
Type mismatch (declared type incompatible with target field):
|
||||
|
||||
```yaml
|
||||
- key: "a"
|
||||
label: "Assign to"
|
||||
action: update where id = id() set assignee = input()
|
||||
input: int
|
||||
```
|
||||
|
||||
Fails at workflow load time: `int` is not assignable to string field `assignee`.
|
||||
|
||||
`input()` with arguments:
|
||||
|
||||
```sql
|
||||
update where id = id() set assignee = input("name")
|
||||
```
|
||||
|
||||
Fails with: `input() takes no arguments`.
|
||||
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -1,7 +1,7 @@
|
|||
.PHONY: help build install clean test lint snapshot
|
||||
|
||||
# Build variables
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo "dev")
|
||||
COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||
DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
LDFLAGS := -ldflags "-X github.com/boolean-maybe/tiki/config.Version=$(VERSION) -X github.com/boolean-maybe/tiki/config.GitCommit=$(COMMIT) -X github.com/boolean-maybe/tiki/config.BuildDate=$(DATE)"
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ this will clone and show a demo project. Once done you can try your own:
|
|||
|
||||
`cd` into your **git** repo and run `tiki init` to initialize.
|
||||
Move your tiki around the board with `Shift ←/Shift →`.
|
||||
Make sure to press `?` for help.
|
||||
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
|
||||
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
|
||||
|
||||
### AI skills
|
||||
|
|
@ -142,7 +142,7 @@ Store your notes in remotes!
|
|||
|
||||
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
|
||||
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
|
||||
Read more by pressing `?` for help
|
||||
Press `?` to open the Action Palette and discover all available actions
|
||||
|
||||
## AI skills
|
||||
|
||||
|
|
|
|||
125
cmd_config.go
125
cmd_config.go
|
|
@ -1,125 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// runConfig dispatches config subcommands. Returns an exit code.
|
||||
func runConfig(args []string) int {
|
||||
if len(args) == 0 {
|
||||
printConfigUsage()
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset":
|
||||
return runConfigReset(args[1:])
|
||||
case "--help", "-h":
|
||||
printConfigUsage()
|
||||
return exitOK
|
||||
default:
|
||||
_, _ = fmt.Fprintf(os.Stderr, "unknown config command: %s\n", args[0])
|
||||
printConfigUsage()
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// runConfigReset implements `tiki config reset [target] --scope`.
|
||||
func runConfigReset(args []string) int {
|
||||
target, scope, err := parseConfigResetArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printConfigResetUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printConfigResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
affected, err := config.ResetConfig(scope, target)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
if len(affected) == 0 {
|
||||
fmt.Println("nothing to reset")
|
||||
return exitOK
|
||||
}
|
||||
for _, path := range affected {
|
||||
fmt.Println("reset", path)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// parseConfigResetArgs parses arguments for `tiki config reset`.
|
||||
func parseConfigResetArgs(args []string) (config.ResetTarget, config.ResetScope, error) {
|
||||
var targetStr string
|
||||
var scopeStr string
|
||||
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
return "", "", errHelpRequested
|
||||
case "--global", "--local", "--current":
|
||||
if scopeStr != "" {
|
||||
return "", "", fmt.Errorf("only one scope allowed: already have --%s", scopeStr)
|
||||
}
|
||||
scopeStr = strings.TrimPrefix(arg, "--")
|
||||
default:
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return "", "", fmt.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if targetStr != "" {
|
||||
return "", "", fmt.Errorf("multiple targets specified: %q and %q", targetStr, arg)
|
||||
}
|
||||
targetStr = arg
|
||||
}
|
||||
}
|
||||
|
||||
if scopeStr == "" {
|
||||
return "", "", fmt.Errorf("scope required: --global, --local, or --current")
|
||||
}
|
||||
|
||||
target := config.ResetTarget(targetStr)
|
||||
switch target {
|
||||
case config.TargetAll, config.TargetConfig, config.TargetWorkflow, config.TargetNew:
|
||||
// valid
|
||||
default:
|
||||
return "", "", fmt.Errorf("unknown target: %q (use config, workflow, or new)", targetStr)
|
||||
}
|
||||
|
||||
return target, config.ResetScope(scopeStr), nil
|
||||
}
|
||||
|
||||
func printConfigUsage() {
|
||||
fmt.Print(`Usage: tiki config <command>
|
||||
|
||||
Commands:
|
||||
reset [target] --scope Reset config files to defaults
|
||||
|
||||
Run 'tiki config reset --help' for details.
|
||||
`)
|
||||
}
|
||||
|
||||
func printConfigResetUsage() {
|
||||
fmt.Print(`Usage: tiki config reset [target] --scope
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
Targets (omit to reset all):
|
||||
config Reset config.yaml
|
||||
workflow Reset workflow.yaml
|
||||
new Reset new.md (task template)
|
||||
|
||||
Scopes (required):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
`)
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
func TestParseConfigResetArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
target config.ResetTarget
|
||||
scope config.ResetScope
|
||||
wantErr error // sentinel match (nil = no error)
|
||||
errSubstr string // substring match for non-sentinel errors
|
||||
}{
|
||||
{
|
||||
name: "global all",
|
||||
args: []string{"--global"},
|
||||
target: config.TargetAll,
|
||||
scope: config.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "local workflow",
|
||||
args: []string{"workflow", "--local"},
|
||||
target: config.TargetWorkflow,
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "current config",
|
||||
args: []string{"--current", "config"},
|
||||
target: config.TargetConfig,
|
||||
scope: config.ScopeCurrent,
|
||||
},
|
||||
{
|
||||
name: "global new",
|
||||
args: []string{"new", "--global"},
|
||||
target: config.TargetNew,
|
||||
scope: config.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "help flag",
|
||||
args: []string{"--help"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "short help flag",
|
||||
args: []string{"-h"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "missing scope",
|
||||
args: []string{"config"},
|
||||
errSubstr: "scope required",
|
||||
},
|
||||
{
|
||||
name: "unknown flag",
|
||||
args: []string{"--verbose"},
|
||||
errSubstr: "unknown flag",
|
||||
},
|
||||
{
|
||||
name: "unknown target",
|
||||
args: []string{"themes", "--global"},
|
||||
errSubstr: "unknown target",
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
args: []string{"config", "workflow", "--global"},
|
||||
errSubstr: "multiple targets",
|
||||
},
|
||||
{
|
||||
name: "duplicate scopes",
|
||||
args: []string{"--global", "--local"},
|
||||
errSubstr: "only one scope allowed",
|
||||
},
|
||||
{
|
||||
name: "no args",
|
||||
args: nil,
|
||||
errSubstr: "scope required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target, scope, err := parseConfigResetArgs(tt.args)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.errSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if target != tt.target {
|
||||
t.Errorf("target = %q, want %q", target, tt.target)
|
||||
}
|
||||
if scope != tt.scope {
|
||||
t.Errorf("scope = %q, want %q", scope, tt.scope)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
251
cmd_workflow.go
Normal file
251
cmd_workflow.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// runWorkflow dispatches workflow subcommands. Returns an exit code.
|
||||
func runWorkflow(args []string) int {
|
||||
if len(args) == 0 {
|
||||
printWorkflowUsage()
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset":
|
||||
return runWorkflowReset(args[1:])
|
||||
case "install":
|
||||
return runWorkflowInstall(args[1:])
|
||||
case "describe":
|
||||
return runWorkflowDescribe(args[1:])
|
||||
case "--help", "-h":
|
||||
printWorkflowUsage()
|
||||
return exitOK
|
||||
default:
|
||||
_, _ = fmt.Fprintf(os.Stderr, "unknown workflow command: %s\n", args[0])
|
||||
printWorkflowUsage()
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// runWorkflowReset implements `tiki workflow reset [target] --scope`.
|
||||
func runWorkflowReset(args []string) int {
|
||||
positional, scope, err := parseScopeArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowResetUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
target := config.ResetTarget(positional)
|
||||
if !config.ValidResetTarget(target) {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: unknown target: %q (use config, workflow, or new)\n", positional)
|
||||
printWorkflowResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
affected, err := config.ResetConfig(scope, target)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
if len(affected) == 0 {
|
||||
fmt.Println("nothing to reset")
|
||||
return exitOK
|
||||
}
|
||||
for _, path := range affected {
|
||||
fmt.Println("reset", path)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// runWorkflowInstall implements `tiki workflow install <name> --scope`.
|
||||
func runWorkflowInstall(args []string) int {
|
||||
name, scope, err := parseScopeArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowInstallUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowInstallUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error: workflow name required")
|
||||
printWorkflowInstallUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
results, err := config.InstallWorkflow(name, scope, config.DefaultWorkflowBaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
if r.Changed {
|
||||
fmt.Println("installed", r.Path)
|
||||
} else {
|
||||
fmt.Println("unchanged", r.Path)
|
||||
}
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// runWorkflowDescribe implements `tiki workflow describe <name>`.
|
||||
// describe is a read-only network call, so scope flags are rejected
|
||||
// to keep the CLI surface honest.
|
||||
func runWorkflowDescribe(args []string) int {
|
||||
name, err := parsePositionalOnly(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowDescribeUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowDescribeUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error: workflow name required")
|
||||
printWorkflowDescribeUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
desc, err := config.DescribeWorkflow(name, config.DefaultWorkflowBaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
if desc == "" {
|
||||
return exitOK
|
||||
}
|
||||
if strings.HasSuffix(desc, "\n") {
|
||||
fmt.Print(desc)
|
||||
} else {
|
||||
fmt.Println(desc)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// parseScopeArgs extracts an optional positional argument and a required --scope flag.
|
||||
// Returns errHelpRequested for --help/-h.
|
||||
func parseScopeArgs(args []string) (string, config.Scope, error) {
|
||||
var positional string
|
||||
var scopeStr string
|
||||
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
return "", "", errHelpRequested
|
||||
case "--global", "--local", "--current":
|
||||
if scopeStr != "" {
|
||||
return "", "", fmt.Errorf("only one scope allowed: already have --%s", scopeStr)
|
||||
}
|
||||
scopeStr = strings.TrimPrefix(arg, "--")
|
||||
default:
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return "", "", fmt.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if positional != "" {
|
||||
return "", "", fmt.Errorf("multiple positional arguments: %q and %q", positional, arg)
|
||||
}
|
||||
positional = arg
|
||||
}
|
||||
}
|
||||
|
||||
if scopeStr == "" {
|
||||
scopeStr = "local"
|
||||
}
|
||||
|
||||
return positional, config.Scope(scopeStr), nil
|
||||
}
|
||||
|
||||
// parsePositionalOnly extracts a single positional argument and rejects any
|
||||
// flag other than --help/-h. Used by subcommands that don't take a scope.
|
||||
func parsePositionalOnly(args []string) (string, error) {
|
||||
var positional string
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
return "", errHelpRequested
|
||||
}
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return "", fmt.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if positional != "" {
|
||||
return "", fmt.Errorf("multiple positional arguments: %q and %q", positional, arg)
|
||||
}
|
||||
positional = arg
|
||||
}
|
||||
return positional, nil
|
||||
}
|
||||
|
||||
func printWorkflowUsage() {
|
||||
fmt.Print(`Usage: tiki workflow <command>
|
||||
|
||||
Commands:
|
||||
reset [target] [--scope] Reset config files to defaults
|
||||
install <name> [--scope] Install a workflow from the tiki repository
|
||||
describe <name> Fetch and print a workflow's description
|
||||
|
||||
Run 'tiki workflow <command> --help' for details.
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowResetUsage() {
|
||||
fmt.Print(`Usage: tiki workflow reset [target] [--scope]
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
Targets (omit to reset all):
|
||||
config Reset config.yaml
|
||||
workflow Reset workflow.yaml
|
||||
new Reset new.md (task template)
|
||||
|
||||
Scopes (default: --local):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowInstallUsage() {
|
||||
fmt.Print(`Usage: tiki workflow install <name> [--scope]
|
||||
|
||||
Install a named workflow from the tiki repository.
|
||||
Downloads workflow.yaml and new.md into the scope directory,
|
||||
overwriting any existing files.
|
||||
|
||||
Scopes (default: --local):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
|
||||
Example:
|
||||
tiki workflow install sprint --global
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowDescribeUsage() {
|
||||
fmt.Print(`Usage: tiki workflow describe <name>
|
||||
|
||||
Fetch a workflow's description from the tiki repository and print it.
|
||||
Reads the top-level 'description' field of the named workflow.yaml.
|
||||
|
||||
Example:
|
||||
tiki workflow describe todo
|
||||
`)
|
||||
}
|
||||
414
cmd_workflow_test.go
Normal file
414
cmd_workflow_test.go
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// setupWorkflowTest creates a temp config dir for workflow commands.
|
||||
func setupWorkflowTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
config.ResetPathManager()
|
||||
t.Cleanup(config.ResetPathManager)
|
||||
|
||||
tikiDir := filepath.Join(xdgDir, "tiki")
|
||||
if err := os.MkdirAll(tikiDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tikiDir
|
||||
}
|
||||
|
||||
func overrideBaseURL(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
orig := config.DefaultWorkflowBaseURL
|
||||
config.DefaultWorkflowBaseURL = url
|
||||
t.Cleanup(func() { config.DefaultWorkflowBaseURL = orig })
|
||||
}
|
||||
|
||||
func TestParseScopeArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
positional string
|
||||
scope config.Scope
|
||||
wantErr error
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "global no positional",
|
||||
args: []string{"--global"},
|
||||
positional: "",
|
||||
scope: config.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "local with positional",
|
||||
args: []string{"workflow", "--local"},
|
||||
positional: "workflow",
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "scope before positional",
|
||||
args: []string{"--current", "config"},
|
||||
positional: "config",
|
||||
scope: config.ScopeCurrent,
|
||||
},
|
||||
{
|
||||
name: "help flag",
|
||||
args: []string{"--help"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "short help flag",
|
||||
args: []string{"-h"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "missing scope defaults to local",
|
||||
args: []string{"config"},
|
||||
positional: "config",
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "unknown flag",
|
||||
args: []string{"--verbose"},
|
||||
errSubstr: "unknown flag",
|
||||
},
|
||||
{
|
||||
name: "multiple positional",
|
||||
args: []string{"config", "workflow", "--global"},
|
||||
errSubstr: "multiple positional arguments",
|
||||
},
|
||||
{
|
||||
name: "duplicate scopes",
|
||||
args: []string{"--global", "--local"},
|
||||
errSubstr: "only one scope allowed",
|
||||
},
|
||||
{
|
||||
name: "no args defaults to local",
|
||||
args: nil,
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
positional, scope, err := parseScopeArgs(tt.args)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.errSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if positional != tt.positional {
|
||||
t.Errorf("positional = %q, want %q", positional, tt.positional)
|
||||
}
|
||||
if scope != tt.scope {
|
||||
t.Errorf("scope = %q, want %q", scope, tt.scope)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePositionalOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
positional string
|
||||
wantErr error
|
||||
errSubstr string
|
||||
}{
|
||||
{name: "no args", args: nil},
|
||||
{name: "single positional", args: []string{"sprint"}, positional: "sprint"},
|
||||
{name: "help flag", args: []string{"--help"}, wantErr: errHelpRequested},
|
||||
{name: "short help flag", args: []string{"-h"}, wantErr: errHelpRequested},
|
||||
{name: "rejects scope", args: []string{"sprint", "--global"}, errSubstr: "unknown flag"},
|
||||
{name: "rejects unknown flag", args: []string{"sprint", "--verbose"}, errSubstr: "unknown flag"},
|
||||
{name: "multiple positional", args: []string{"a", "b"}, errSubstr: "multiple positional arguments"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
positional, err := parsePositionalOnly(tt.args)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.errSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if positional != tt.positional {
|
||||
t.Errorf("positional = %q, want %q", positional, tt.positional)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflow dispatch tests ---
|
||||
|
||||
func TestRunWorkflow_NoArgs(t *testing.T) {
|
||||
if code := runWorkflow(nil); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_UnknownSubcommand(t *testing.T) {
|
||||
if code := runWorkflow([]string{"bogus"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_Help(t *testing.T) {
|
||||
if code := runWorkflow([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowReset integration tests ---
|
||||
|
||||
func TestRunWorkflowReset_GlobalAll(t *testing.T) {
|
||||
tikiDir := setupWorkflowTest(t)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tikiDir, "workflow.yaml"), []byte("custom"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if code := runWorkflowReset([]string{"--global"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) == "custom" {
|
||||
t.Error("workflow.yaml was not reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_NothingToReset(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowReset([]string{"config", "--global"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_InvalidTarget(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowReset([]string{"themes", "--global"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_DefaultsToLocal(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
// without an initialized project, --local scope fails with exitInternal (not exitUsage)
|
||||
if code := runWorkflowReset([]string{"config"}); code == exitUsage {
|
||||
t.Error("missing scope should not produce usage error — it should default to --local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_Help(t *testing.T) {
|
||||
if code := runWorkflowReset([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowInstall integration tests ---
|
||||
|
||||
func TestRunWorkflowInstall_Success(t *testing.T) {
|
||||
tikiDir := setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/workflows/sprint/workflow.yaml":
|
||||
_, _ = w.Write([]byte("sprint workflow"))
|
||||
case "/workflows/sprint/new.md":
|
||||
_, _ = w.Write([]byte("sprint template"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowInstall([]string{"sprint", "--global"}); code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if string(got) != "sprint workflow" {
|
||||
t.Errorf("workflow.yaml = %q, want %q", got, "sprint workflow")
|
||||
}
|
||||
got, _ = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if string(got) != "sprint template" {
|
||||
t.Errorf("new.md = %q, want %q", got, "sprint template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_MissingName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowInstall([]string{"--global"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_InvalidName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowInstall([]string{"../../etc", "--global"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_NotFound(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowInstall([]string{"nonexistent", "--global"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_DefaultsToLocal(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
// without an initialized project, --local scope fails with exitInternal (not exitUsage)
|
||||
if code := runWorkflowInstall([]string{"sprint"}); code == exitUsage {
|
||||
t.Error("missing scope should not produce usage error — it should default to --local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_Help(t *testing.T) {
|
||||
if code := runWorkflowInstall([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowDescribe integration tests ---
|
||||
|
||||
func TestRunWorkflowDescribe_Success(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/workflows/sprint/workflow.yaml" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("description: |\n sprint desc\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"sprint"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_MissingName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowDescribe(nil); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_InvalidName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"../../etc"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_NotFound(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"nonexistent"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_UnknownFlag(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"sprint", "--verbose"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_RejectsScopeFlags(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
for _, flag := range []string{"--global", "--local", "--current"} {
|
||||
if code := runWorkflowDescribe([]string{"sprint", flag}); code != exitUsage {
|
||||
t.Errorf("%s: exit code = %d, want %d", flag, code, exitUsage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_Help(t *testing.T) {
|
||||
if code := runWorkflowDescribe([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_DescribeDispatch(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("description: hi\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflow([]string{"describe", "sprint"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import "path"
|
|||
|
||||
// AITool defines a supported AI coding assistant.
|
||||
// To add a new tool, add an entry to the aiTools slice below.
|
||||
// NOTE: also update view/help/tiki.md which lists tool names in prose.
|
||||
// NOTE: the action palette (press Ctrl+A) surfaces available actions; update docs if tool names change.
|
||||
type AITool struct {
|
||||
Key string // config identifier: "claude", "gemini", "codex", "opencode"
|
||||
DisplayName string // human-readable label for UI: "Claude Code"
|
||||
|
|
|
|||
|
|
@ -52,10 +52,10 @@ type ColorConfig struct {
|
|||
ContentBackgroundColor Color
|
||||
ContentTextColor Color
|
||||
|
||||
// Search box colors
|
||||
SearchBoxLabelColor Color
|
||||
SearchBoxBackgroundColor Color
|
||||
SearchBoxTextColor Color
|
||||
// Input box colors
|
||||
InputBoxLabelColor Color
|
||||
InputBoxBackgroundColor Color
|
||||
InputBoxTextColor Color
|
||||
|
||||
// Input field colors (used in task detail edit mode)
|
||||
InputFieldBackgroundColor Color
|
||||
|
|
@ -226,10 +226,10 @@ func ColorsFromPalette(p Palette) *ColorConfig {
|
|||
ContentBackgroundColor: p.ContentBackgroundColor,
|
||||
ContentTextColor: p.TextColor,
|
||||
|
||||
// Search box
|
||||
SearchBoxLabelColor: p.TextColor,
|
||||
SearchBoxBackgroundColor: p.TransparentColor,
|
||||
SearchBoxTextColor: p.TextColor,
|
||||
// Input box
|
||||
InputBoxLabelColor: p.TextColor,
|
||||
InputBoxBackgroundColor: p.TransparentColor,
|
||||
InputBoxTextColor: p.TextColor,
|
||||
|
||||
// Input field
|
||||
InputFieldBackgroundColor: p.TransparentColor,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
version: 0.5.0
|
||||
description: |
|
||||
Default tiki workflow. A lightweight kanban-style flow with
|
||||
Backlog → Ready → In Progress → Review → Done, plus Story / Bug / Spike / Epic task types.
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
|
|
@ -45,9 +49,34 @@ views:
|
|||
- key: "Y"
|
||||
label: "Copy content"
|
||||
action: select title, description where id = id() | clipboard()
|
||||
- key: "+"
|
||||
label: "Priority up"
|
||||
action: update where id = id() set priority = priority - 1
|
||||
- key: "-"
|
||||
label: "Priority down"
|
||||
action: update where id = id() set priority = priority + 1
|
||||
- key: "u"
|
||||
label: "Flag urgent"
|
||||
action: update where id = id() set priority=1 tags=tags+["urgent"]
|
||||
hot: false
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
hot: false
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags=tags+[input()]
|
||||
input: string
|
||||
hot: false
|
||||
- key: "T"
|
||||
label: "Remove tag"
|
||||
action: update where id = id() set tags=tags-[input()]
|
||||
input: string
|
||||
hot: false
|
||||
plugins:
|
||||
- name: Kanban
|
||||
description: "Move tiki to new status, search, create or delete"
|
||||
description: "Move tiki to change status, search, create or delete\nShift Left/Right to move"
|
||||
default: true
|
||||
key: "F1"
|
||||
lanes:
|
||||
|
|
@ -101,12 +130,6 @@ views:
|
|||
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=2
|
||||
view: expanded
|
||||
- name: Help
|
||||
description: "Keyboard shortcuts, navigation, and usage guide"
|
||||
type: doki
|
||||
fetcher: internal
|
||||
text: "Help"
|
||||
key: "?"
|
||||
- name: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ const (
|
|||
TaskBoxPaddingExpanded = 4 // Width padding in expanded mode
|
||||
TaskBoxMinWidth = 10 // Minimum width fallback
|
||||
|
||||
// Search box dimensions
|
||||
SearchBoxHeight = 3
|
||||
// Input box dimensions
|
||||
InputBoxHeight = 3
|
||||
|
||||
// TaskList default visible rows
|
||||
TaskListDefaultMaxRows = 10
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ Press Esc to cancel project initialization.`
|
|||
Value(&opts.AITools),
|
||||
huh.NewConfirm().
|
||||
Title("Create sample tasks").
|
||||
Description("Seed project with example tikis (incompatible samples are skipped automatically)").
|
||||
Description("Create example tikis in project").
|
||||
Value(&opts.SampleTasks),
|
||||
),
|
||||
).WithTheme(theme).
|
||||
|
|
|
|||
121
config/install.go
Normal file
121
config/install.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
httpTimeout = 15 * time.Second
|
||||
maxResponseSize = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
var DefaultWorkflowBaseURL = "https://raw.githubusercontent.com/boolean-maybe/tiki/main"
|
||||
|
||||
var validWorkflowName = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
// InstallResult describes the outcome for a single installed file.
|
||||
type InstallResult struct {
|
||||
Path string
|
||||
Changed bool
|
||||
}
|
||||
|
||||
var installFiles = []string{
|
||||
defaultWorkflowFilename,
|
||||
templateFilename,
|
||||
}
|
||||
|
||||
// InstallWorkflow fetches a named workflow from baseURL and writes its files
|
||||
// to the directory for the given scope, overwriting existing files.
|
||||
// baseURL is the root URL before "/workflows" (e.g. "https://raw.githubusercontent.com/boolean-maybe/tiki/main").
|
||||
func InstallWorkflow(name string, scope Scope, baseURL string) ([]InstallResult, error) {
|
||||
dir, err := resolveDir(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fetched := make(map[string]string, len(installFiles))
|
||||
for _, filename := range installFiles {
|
||||
content, err := fetchWorkflowFile(baseURL, name, filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s/%s: %w", name, filename, err)
|
||||
}
|
||||
fetched[filename] = string(content)
|
||||
}
|
||||
|
||||
var results []InstallResult
|
||||
for _, filename := range installFiles {
|
||||
path := filepath.Join(dir, filename)
|
||||
changed, err := writeFileIfChanged(path, fetched[filename])
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("write %s: %w", filename, err)
|
||||
}
|
||||
results = append(results, InstallResult{Path: path, Changed: changed})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// DescribeWorkflow fetches the workflow.yaml for name from baseURL and
|
||||
// returns the value of its top-level `description:` field. Returns empty
|
||||
// string if the field is absent.
|
||||
func DescribeWorkflow(name, baseURL string) (string, error) {
|
||||
body, err := fetchWorkflowFile(baseURL, name, defaultWorkflowFilename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var wf struct {
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
if err := yaml.Unmarshal(body, &wf); err != nil {
|
||||
return "", fmt.Errorf("parse %s/workflow.yaml: %w", name, err)
|
||||
}
|
||||
return wf.Description, nil
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: httpTimeout}
|
||||
|
||||
// fetchWorkflowFile validates the workflow name and downloads a single file
|
||||
// from baseURL. Returns the raw body bytes.
|
||||
func fetchWorkflowFile(baseURL, name, filename string) ([]byte, error) {
|
||||
if !validWorkflowName.MatchString(name) {
|
||||
return nil, fmt.Errorf("invalid workflow name %q: use letters, digits, hyphens, dots, or underscores", name)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/workflows/%s/%s", baseURL, name, filename)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("workflow %q not found (%s)", name, filename)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected HTTP %d for %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
213
config/install_test.go
Normal file
213
config/install_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallWorkflow_Success(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/workflows/sprint/workflow.yaml":
|
||||
_, _ = w.Write([]byte("statuses:\n - key: todo\n"))
|
||||
case "/workflows/sprint/new.md":
|
||||
_, _ = w.Write([]byte("---\ntitle:\n---\n"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallWorkflow() error = %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
for _, r := range results {
|
||||
if !r.Changed {
|
||||
t.Errorf("expected %s to be changed on fresh install", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read workflow.yaml: %v", err)
|
||||
}
|
||||
if string(got) != "statuses:\n - key: todo\n" {
|
||||
t.Errorf("workflow.yaml content = %q", string(got))
|
||||
}
|
||||
|
||||
got, err = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read new.md: %v", err)
|
||||
}
|
||||
if string(got) != "---\ntitle:\n---\n" {
|
||||
t.Errorf("new.md content = %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_Overwrites(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tikiDir, "workflow.yaml"), []byte("old content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("new content"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallWorkflow() error = %v", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if !r.Changed {
|
||||
t.Errorf("expected %s to be changed on overwrite", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if string(got) != "new content" {
|
||||
t.Errorf("workflow.yaml not overwritten: %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_NotFound(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
_, err := InstallWorkflow("nonexistent", ScopeGlobal, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent workflow, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_AlreadyUpToDate(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("same content"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := InstallWorkflow("sprint", ScopeGlobal, server.URL); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("second install: %v", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Changed {
|
||||
t.Errorf("expected %s to be unchanged on repeat install", r.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_InvalidName(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
for _, name := range []string{"../../etc", "a b", "", "foo/bar", "-dash", "dot."} {
|
||||
_, err := InstallWorkflow(name, ScopeGlobal, "http://unused")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for name %q, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_Success(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/workflows/sprint/workflow.yaml" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("description: |\n Sprint workflow.\n Two-week cycles.\nstatuses:\n - key: todo\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
desc, err := DescribeWorkflow("sprint", server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeWorkflow() error = %v", err)
|
||||
}
|
||||
want := "Sprint workflow.\nTwo-week cycles.\n"
|
||||
if desc != want {
|
||||
t.Errorf("description = %q, want %q", desc, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_NoDescriptionField(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("statuses:\n - key: todo\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
desc, err := DescribeWorkflow("sprint", server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeWorkflow() error = %v", err)
|
||||
}
|
||||
if desc != "" {
|
||||
t.Errorf("description = %q, want empty", desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_NotFound(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
_, err := DescribeWorkflow("nonexistent", server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent workflow, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_InvalidName(t *testing.T) {
|
||||
for _, name := range []string{"../../etc", "a b", "", "foo/bar", "-dash", "dot."} {
|
||||
if _, err := DescribeWorkflow(name, "http://unused"); err == nil {
|
||||
t.Errorf("expected error for name %q, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_AtomicFetch(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := InstallWorkflow("partial", ScopeGlobal, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for partial failure, got nil")
|
||||
}
|
||||
|
||||
for _, filename := range []string{"workflow.yaml", "new.md"} {
|
||||
if _, statErr := os.Stat(filepath.Join(tikiDir, filename)); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s should not exist after fetch failure", filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ var lastConfigFile string
|
|||
|
||||
// Config holds all application configuration loaded from config.yaml
|
||||
type Config struct {
|
||||
Version string `mapstructure:"version"`
|
||||
|
||||
// Logging configuration
|
||||
Logging struct {
|
||||
Level string `mapstructure:"level"` // "debug", "info", "warn", "error"
|
||||
|
|
@ -200,11 +202,13 @@ type viewsFileData struct {
|
|||
// kept in config package to avoid import cycle with plugin package.
|
||||
// all top-level sections must be listed here to survive round-trip serialization.
|
||||
type workflowFileData struct {
|
||||
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
|
||||
Types []map[string]interface{} `yaml:"types,omitempty"`
|
||||
Views viewsFileData `yaml:"views,omitempty"`
|
||||
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
|
||||
Fields []map[string]interface{} `yaml:"fields,omitempty"`
|
||||
Version string `yaml:"version,omitempty"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
|
||||
Types []map[string]interface{} `yaml:"types,omitempty"`
|
||||
Views viewsFileData `yaml:"views,omitempty"`
|
||||
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
|
||||
Fields []map[string]interface{} `yaml:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// readWorkflowFile reads and unmarshals workflow.yaml from the given path.
|
||||
|
|
|
|||
|
|
@ -494,6 +494,59 @@ triggers:
|
|||
}
|
||||
}
|
||||
|
||||
func TestSavePluginViewMode_PreservesDescription(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
workflowContent := `description: |
|
||||
Release workflow. Coordinate feature rollout through
|
||||
Planned → Building → Staging → Canary → Released.
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
default: true
|
||||
- key: done
|
||||
label: Done
|
||||
done: true
|
||||
views:
|
||||
- name: Kanban
|
||||
default: true
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Done
|
||||
filter: status = 'done'
|
||||
action: status = 'done'
|
||||
sort: Priority, CreatedAt
|
||||
`
|
||||
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
|
||||
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wf, err := readWorkflowFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readWorkflowFile failed: %v", err)
|
||||
}
|
||||
wantDesc := "Release workflow. Coordinate feature rollout through\nPlanned → Building → Staging → Canary → Released.\n"
|
||||
if wf.Description != wantDesc {
|
||||
t.Errorf("description after read = %q, want %q", wf.Description, wantDesc)
|
||||
}
|
||||
|
||||
if len(wf.Views.Plugins) > 0 {
|
||||
wf.Views.Plugins[0]["view"] = "compact"
|
||||
}
|
||||
if err := writeWorkflowFile(workflowPath, wf); err != nil {
|
||||
t.Fatalf("writeWorkflowFile failed: %v", err)
|
||||
}
|
||||
|
||||
wf2, err := readWorkflowFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readWorkflowFile after write failed: %v", err)
|
||||
}
|
||||
if wf2.Description != wantDesc {
|
||||
t.Errorf("description after round-trip = %q, want %q", wf2.Description, wantDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig(t *testing.T) {
|
||||
// Reset appConfig
|
||||
appConfig = nil
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@ import (
|
|||
"path/filepath"
|
||||
)
|
||||
|
||||
// ResetScope identifies which config tier to reset.
|
||||
type ResetScope string
|
||||
// Scope identifies which config tier to operate on.
|
||||
type Scope string
|
||||
|
||||
// ResetTarget identifies which config file to reset.
|
||||
type ResetTarget string
|
||||
|
||||
const (
|
||||
ScopeGlobal ResetScope = "global"
|
||||
ScopeLocal ResetScope = "local"
|
||||
ScopeCurrent ResetScope = "current"
|
||||
ScopeGlobal Scope = "global"
|
||||
ScopeLocal Scope = "local"
|
||||
ScopeCurrent Scope = "current"
|
||||
|
||||
TargetAll ResetTarget = ""
|
||||
TargetConfig ResetTarget = "config"
|
||||
|
|
@ -40,7 +40,7 @@ var resetEntries = []resetEntry{
|
|||
|
||||
// ResetConfig resets configuration files for the given scope and target.
|
||||
// Returns the list of file paths that were actually modified or deleted.
|
||||
func ResetConfig(scope ResetScope, target ResetTarget) ([]string, error) {
|
||||
func ResetConfig(scope Scope, target ResetTarget) ([]string, error) {
|
||||
dir, err := resolveDir(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -67,7 +67,7 @@ func ResetConfig(scope ResetScope, target ResetTarget) ([]string, error) {
|
|||
}
|
||||
|
||||
// resolveDir returns the directory path for the given scope.
|
||||
func resolveDir(scope ResetScope) (string, error) {
|
||||
func resolveDir(scope Scope) (string, error) {
|
||||
switch scope {
|
||||
case ScopeGlobal:
|
||||
return GetConfigDir(), nil
|
||||
|
|
@ -87,6 +87,16 @@ func resolveDir(scope ResetScope) (string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// ValidResetTarget reports whether target is a recognized reset target.
|
||||
func ValidResetTarget(target ResetTarget) bool {
|
||||
switch target {
|
||||
case TargetAll, TargetConfig, TargetWorkflow, TargetNew:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// filterEntries returns the reset entries matching the target.
|
||||
func filterEntries(target ResetTarget) ([]resetEntry, error) {
|
||||
if target == TargetAll {
|
||||
|
|
@ -101,7 +111,7 @@ func filterEntries(target ResetTarget) ([]resetEntry, error) {
|
|||
case TargetNew:
|
||||
filename = templateFilename
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown reset target: %q", target)
|
||||
return nil, fmt.Errorf("unknown target: %q (use config, workflow, or new)", target)
|
||||
}
|
||||
for _, e := range resetEntries {
|
||||
if e.filename == filename {
|
||||
|
|
@ -114,21 +124,25 @@ func filterEntries(target ResetTarget) ([]resetEntry, error) {
|
|||
// resetFile either overwrites or deletes a file depending on scope and available defaults.
|
||||
// For global scope with non-empty default content, the file is overwritten.
|
||||
// Otherwise the file is deleted. Returns true if the file was changed.
|
||||
func resetFile(path string, scope ResetScope, defaultContent string) (bool, error) {
|
||||
func resetFile(path string, scope Scope, defaultContent string) (bool, error) {
|
||||
if scope == ScopeGlobal && defaultContent != "" {
|
||||
return writeDefault(path, defaultContent)
|
||||
return writeFileIfChanged(path, defaultContent)
|
||||
}
|
||||
return deleteIfExists(path)
|
||||
}
|
||||
|
||||
// writeDefault writes defaultContent to path, creating parent dirs if needed.
|
||||
// Returns true if the file was actually changed (skips write when content already matches).
|
||||
func writeDefault(path string, content string) (bool, error) {
|
||||
// writeFileIfChanged writes content to path, skipping if the file already has identical content.
|
||||
// Returns true if the file was actually changed.
|
||||
func writeFileIfChanged(path string, content string) (bool, error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
if err == nil && string(existing) == content {
|
||||
return false, nil
|
||||
}
|
||||
return writeFile(path, content)
|
||||
}
|
||||
|
||||
// writeFile writes content to path unconditionally, creating parent dirs if needed.
|
||||
func writeFile(path string, content string) (bool, error) {
|
||||
dir := filepath.Dir(path)
|
||||
//nolint:gosec // G301: 0755 is appropriate for config directory
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ func setupResetTest(t *testing.T) string {
|
|||
return tikiDir
|
||||
}
|
||||
|
||||
// writeFile is a test helper that writes content to path.
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
// writeTestFile is a test helper that writes content to path.
|
||||
func writeTestFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -36,9 +36,9 @@ func TestResetConfig_GlobalAll(t *testing.T) {
|
|||
tikiDir := setupResetTest(t)
|
||||
|
||||
// seed all three files with custom content
|
||||
writeFile(t, filepath.Join(tikiDir, "config.yaml"), "logging:\n level: debug\n")
|
||||
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), "custom: true\n")
|
||||
writeFile(t, filepath.Join(tikiDir, "new.md"), "custom template\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "config.yaml"), "logging:\n level: debug\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "custom: true\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "new.md"), "custom template\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetAll)
|
||||
if err != nil {
|
||||
|
|
@ -87,7 +87,7 @@ func TestResetConfig_GlobalSingleTarget(t *testing.T) {
|
|||
t.Run(string(tt.target), func(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
writeFile(t, filepath.Join(tikiDir, tt.filename), "custom\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, tt.filename), "custom\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, tt.target)
|
||||
if err != nil {
|
||||
|
|
@ -125,12 +125,12 @@ func TestResetConfig_LocalDeletesFiles(t *testing.T) {
|
|||
pm.projectRoot = projectDir
|
||||
|
||||
// seed project config files
|
||||
writeFile(t, filepath.Join(docDir, "config.yaml"), "custom\n")
|
||||
writeFile(t, filepath.Join(docDir, "workflow.yaml"), "custom\n")
|
||||
writeFile(t, filepath.Join(docDir, "new.md"), "custom\n")
|
||||
writeTestFile(t, filepath.Join(docDir, "config.yaml"), "custom\n")
|
||||
writeTestFile(t, filepath.Join(docDir, "workflow.yaml"), "custom\n")
|
||||
writeTestFile(t, filepath.Join(docDir, "new.md"), "custom\n")
|
||||
|
||||
// also write global defaults so we can verify local doesn't overwrite
|
||||
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeLocal, TargetAll)
|
||||
if err != nil {
|
||||
|
|
@ -165,7 +165,7 @@ func TestResetConfig_CurrentDeletesFiles(t *testing.T) {
|
|||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
writeFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n")
|
||||
writeTestFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeCurrent, TargetWorkflow)
|
||||
if err != nil {
|
||||
|
|
@ -222,7 +222,7 @@ func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) {
|
|||
tikiDir := setupResetTest(t)
|
||||
|
||||
// write the embedded default content — reset should detect no change
|
||||
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML())
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML())
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
|
||||
if err != nil {
|
||||
|
|
@ -233,6 +233,21 @@ func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidResetTarget(t *testing.T) {
|
||||
valid := []ResetTarget{TargetAll, TargetConfig, TargetWorkflow, TargetNew}
|
||||
for _, target := range valid {
|
||||
if !ValidResetTarget(target) {
|
||||
t.Errorf("ValidResetTarget(%q) = false, want true", target)
|
||||
}
|
||||
}
|
||||
invalid := []ResetTarget{"themes", "invalid", "reset"}
|
||||
for _, target := range invalid {
|
||||
if ValidResetTarget(target) {
|
||||
t.Errorf("ValidResetTarget(%q) = true, want false", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_LocalRejectsUninitializedProject(t *testing.T) {
|
||||
// point projectRoot at a temp dir that has no .doc/tiki
|
||||
xdgDir := t.TempDir()
|
||||
|
|
|
|||
47
config/shipped_workflows_test.go
Normal file
47
config/shipped_workflows_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TestShippedWorkflows_HaveDescription ensures every workflow.yaml shipped in
|
||||
// the repo's top-level workflows/ directory has a non-empty top-level
|
||||
// description field and parses cleanly as a full workflowFileData.
|
||||
// Guards against a maintainer dropping the description when adding a new
|
||||
// shipped workflow.
|
||||
func TestShippedWorkflows_HaveDescription(t *testing.T) {
|
||||
matches, err := filepath.Glob("../workflows/*/workflow.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("glob shipped workflows: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("no shipped workflows found at ../workflows/*/workflow.yaml")
|
||||
}
|
||||
|
||||
for _, path := range matches {
|
||||
t.Run(filepath.Base(filepath.Dir(path)), func(t *testing.T) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
var desc struct {
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &desc); err != nil {
|
||||
t.Fatalf("unmarshal description from %s: %v", path, err)
|
||||
}
|
||||
if desc.Description == "" {
|
||||
t.Errorf("%s: missing or empty top-level description", path)
|
||||
}
|
||||
|
||||
if _, err := readWorkflowFile(path); err != nil {
|
||||
t.Errorf("readWorkflowFile(%s) failed: %v", path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ const (
|
|||
ActionRefresh ActionID = "refresh"
|
||||
ActionToggleViewMode ActionID = "toggle_view_mode"
|
||||
ActionToggleHeader ActionID = "toggle_header"
|
||||
ActionOpenPalette ActionID = "open_palette"
|
||||
)
|
||||
|
||||
// ActionID values for task navigation and manipulation (used by plugins).
|
||||
|
|
@ -92,6 +93,7 @@ func InitPluginActions(plugins []PluginInfo) {
|
|||
if p.Key == 0 && p.Rune == 0 {
|
||||
continue // skip plugins without key binding
|
||||
}
|
||||
pluginViewID := model.MakePluginViewID(p.Name)
|
||||
pluginActionRegistry.Register(Action{
|
||||
ID: ActionID("plugin:" + p.Name),
|
||||
Key: p.Key,
|
||||
|
|
@ -99,6 +101,12 @@ func InitPluginActions(plugins []PluginInfo) {
|
|||
Modifier: p.Modifier,
|
||||
Label: p.Name,
|
||||
ShowInHeader: true,
|
||||
IsEnabled: func(view *ViewEntry, _ View) bool {
|
||||
if view == nil {
|
||||
return true
|
||||
}
|
||||
return view.ViewID != pluginViewID
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -124,12 +132,14 @@ func GetPluginNameFromAction(id ActionID) string {
|
|||
|
||||
// Action represents a keyboard shortcut binding
|
||||
type Action struct {
|
||||
ID ActionID
|
||||
Key tcell.Key
|
||||
Rune rune // for letter keys (when Key == tcell.KeyRune)
|
||||
Label string
|
||||
Modifier tcell.ModMask
|
||||
ShowInHeader bool // whether to display in header bar
|
||||
ID ActionID
|
||||
Key tcell.Key
|
||||
Rune rune // for letter keys (when Key == tcell.KeyRune)
|
||||
Label string
|
||||
Modifier tcell.ModMask
|
||||
ShowInHeader bool // whether to display in header bar
|
||||
HideFromPalette bool // when true, action is excluded from the action palette (zero value = visible)
|
||||
IsEnabled func(view *ViewEntry, activeView View) bool
|
||||
}
|
||||
|
||||
// keyWithMod is a composite map key for special-key lookups, disambiguating
|
||||
|
|
@ -246,13 +256,23 @@ func (r *ActionRegistry) Match(event *tcell.EventKey) *Action {
|
|||
return nil
|
||||
}
|
||||
|
||||
// selectionRequired is an IsEnabled predicate that returns true only when
|
||||
// the active view has a non-empty selection (for use with plugin/deps actions).
|
||||
func selectionRequired(_ *ViewEntry, activeView View) bool {
|
||||
if sv, ok := activeView.(SelectableView); ok {
|
||||
return sv.GetSelectedID() != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultGlobalActions returns common actions available in all views
|
||||
func DefaultGlobalActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Hide Header", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Toggle Header", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionOpenPalette, Key: tcell.KeyCtrlA, Modifier: tcell.ModCtrl, Label: "All", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -267,6 +287,39 @@ func (r *ActionRegistry) GetHeaderActions() []Action {
|
|||
return result
|
||||
}
|
||||
|
||||
// GetPaletteActions returns palette-visible actions, deduped by ActionID (first registration wins).
|
||||
func (r *ActionRegistry) GetPaletteActions() []Action {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[ActionID]bool)
|
||||
var result []Action
|
||||
for _, a := range r.actions {
|
||||
if a.HideFromPalette {
|
||||
continue
|
||||
}
|
||||
if seen[a.ID] {
|
||||
continue
|
||||
}
|
||||
seen[a.ID] = true
|
||||
result = append(result, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ContainsID returns true if the registry has an action with the given ID.
|
||||
func (r *ActionRegistry) ContainsID(id ActionID) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range r.actions {
|
||||
if a.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ToHeaderActions converts the registry's header actions to model.HeaderAction slice.
|
||||
// This bridges the controller→model boundary without requiring callers to do the mapping.
|
||||
func (r *ActionRegistry) ToHeaderActions() []model.HeaderAction {
|
||||
|
|
@ -293,15 +346,22 @@ func (r *ActionRegistry) ToHeaderActions() []model.HeaderAction {
|
|||
func TaskDetailViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
|
||||
r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditDesc, Key: tcell.KeyRune, Rune: 'D', Label: "Edit desc", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true})
|
||||
taskDetailEnabled := func(view *ViewEntry, _ View) bool {
|
||||
if view == nil || view.ViewID != model.TaskDetailViewID {
|
||||
return false
|
||||
}
|
||||
return model.DecodeTaskDetailParams(view.Params).TaskID != ""
|
||||
}
|
||||
|
||||
r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionEditDesc, Key: tcell.KeyRune, Rune: 'D', Label: "Edit desc", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionFullscreen, Key: tcell.KeyRune, Rune: 'f', Label: "Full screen", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
|
||||
if config.GetAIAgent() != "" {
|
||||
r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
}
|
||||
|
||||
return r
|
||||
|
|
@ -321,8 +381,8 @@ func TaskEditViewActions() *ActionRegistry {
|
|||
r := NewActionRegistry()
|
||||
|
||||
r.Register(Action{ID: ActionSaveTask, Key: tcell.KeyCtrlS, Label: "Save", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev", ShowInHeader: true, HideFromPalette: true})
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
@ -330,15 +390,15 @@ func TaskEditViewActions() *ActionRegistry {
|
|||
// CommonFieldNavigationActions returns actions available in all field editors (Tab/Shift-Tab navigation)
|
||||
func CommonFieldNavigationActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next field", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev field", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next field", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev field", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
// TaskEditTitleActions returns actions available when editing the title field
|
||||
func TaskEditTitleActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionQuickSave, Key: tcell.KeyEnter, Label: "Quick Save", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionQuickSave, Key: tcell.KeyEnter, Label: "Quick Save", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionSaveTask, Key: tcell.KeyCtrlS, Label: "Save", ShowInHeader: true})
|
||||
r.Merge(CommonFieldNavigationActions())
|
||||
return r
|
||||
|
|
@ -347,16 +407,16 @@ func TaskEditTitleActions() *ActionRegistry {
|
|||
// TaskEditStatusActions returns actions available when editing the status field
|
||||
func TaskEditStatusActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
// TaskEditTypeActions returns actions available when editing the type field
|
||||
func TaskEditTypeActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -370,8 +430,8 @@ func TaskEditPriorityActions() *ActionRegistry {
|
|||
// TaskEditAssigneeActions returns actions available when editing the assignee field
|
||||
func TaskEditAssigneeActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -385,19 +445,19 @@ func TaskEditPointsActions() *ActionRegistry {
|
|||
// TaskEditDueActions returns actions available when editing the due date field
|
||||
func TaskEditDueActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionClearField, Key: tcell.KeyCtrlU, Label: "Clear", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionClearField, Key: tcell.KeyCtrlU, Label: "Clear", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
// TaskEditRecurrenceActions returns actions available when editing the recurrence field
|
||||
func TaskEditRecurrenceActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -455,22 +515,22 @@ func GetActionsForField(field model.EditField) *ActionRegistry {
|
|||
func PluginViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
|
||||
// navigation (not shown in header)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"})
|
||||
// navigation (not shown in header, hidden from palette)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→", HideFromPalette: true})
|
||||
|
||||
// plugin actions (shown in header)
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionNewTask, Key: tcell.KeyRune, Rune: 'n', Label: "New", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionToggleViewMode, Key: tcell.KeyRune, Rune: 'v', Label: "View mode", ShowInHeader: true})
|
||||
|
||||
|
|
@ -484,24 +544,24 @@ func PluginViewActions() *ActionRegistry {
|
|||
func DepsViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
|
||||
// navigation (not shown in header)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"})
|
||||
// navigation (not shown in header, hidden from palette)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→", HideFromPalette: true})
|
||||
|
||||
// move task between lanes (shown in header)
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
|
||||
// task actions
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionNewTask, Key: tcell.KeyRune, Rune: 'n', Label: "New", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
|
||||
// view mode and search
|
||||
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})
|
||||
|
|
|
|||
|
|
@ -404,11 +404,11 @@ func TestDefaultGlobalActions(t *testing.T) {
|
|||
registry := DefaultGlobalActions()
|
||||
actions := registry.GetActions()
|
||||
|
||||
if len(actions) != 4 {
|
||||
t.Errorf("expected 4 global actions, got %d", len(actions))
|
||||
if len(actions) != 5 {
|
||||
t.Errorf("expected 5 global actions, got %d", len(actions))
|
||||
}
|
||||
|
||||
expectedActions := []ActionID{ActionBack, ActionQuit, ActionRefresh, ActionToggleHeader}
|
||||
expectedActions := []ActionID{ActionBack, ActionQuit, ActionRefresh, ActionToggleHeader, ActionOpenPalette}
|
||||
for i, expected := range expectedActions {
|
||||
if i >= len(actions) {
|
||||
t.Errorf("missing action at index %d: want %v", i, expected)
|
||||
|
|
@ -417,8 +417,30 @@ func TestDefaultGlobalActions(t *testing.T) {
|
|||
if actions[i].ID != expected {
|
||||
t.Errorf("action at index %d: want %v, got %v", i, expected, actions[i].ID)
|
||||
}
|
||||
if !actions[i].ShowInHeader {
|
||||
t.Errorf("global action %v should have ShowInHeader=true", expected)
|
||||
}
|
||||
|
||||
// ActionOpenPalette should show in header with label "All" and use Ctrl+A binding
|
||||
for _, a := range actions {
|
||||
if a.ID == ActionOpenPalette {
|
||||
if !a.ShowInHeader {
|
||||
t.Error("ActionOpenPalette should have ShowInHeader=true")
|
||||
}
|
||||
if a.Label != "All" {
|
||||
t.Errorf("ActionOpenPalette label = %q, want %q", a.Label, "All")
|
||||
}
|
||||
if a.Key != tcell.KeyCtrlA {
|
||||
t.Errorf("ActionOpenPalette Key = %v, want KeyCtrlA", a.Key)
|
||||
}
|
||||
if a.Modifier != tcell.ModCtrl {
|
||||
t.Errorf("ActionOpenPalette Modifier = %v, want ModCtrl", a.Modifier)
|
||||
}
|
||||
if a.Rune != 0 {
|
||||
t.Errorf("ActionOpenPalette Rune = %v, want 0", a.Rune)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !a.ShowInHeader {
|
||||
t.Errorf("global action %v should have ShowInHeader=true", a.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -630,3 +652,163 @@ func TestMatchWithModifiers(t *testing.T) {
|
|||
t.Error("M (no modifier) should not match action with Alt-M binding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaletteActions_HidesMarkedActions(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit"})
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh"})
|
||||
|
||||
actions := r.GetPaletteActions()
|
||||
if len(actions) != 2 {
|
||||
t.Fatalf("expected 2 palette actions, got %d", len(actions))
|
||||
}
|
||||
if actions[0].ID != ActionQuit {
|
||||
t.Errorf("expected first action to be Quit, got %v", actions[0].ID)
|
||||
}
|
||||
if actions[1].ID != ActionRefresh {
|
||||
t.Errorf("expected second action to be Refresh, got %v", actions[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaletteActions_DedupsByActionID(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑ (vim)"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
|
||||
|
||||
actions := r.GetPaletteActions()
|
||||
if len(actions) != 2 {
|
||||
t.Fatalf("expected 2 deduped actions, got %d", len(actions))
|
||||
}
|
||||
if actions[0].ID != ActionNavUp {
|
||||
t.Errorf("expected first action to be NavUp, got %v", actions[0].ID)
|
||||
}
|
||||
if actions[0].Key != tcell.KeyUp {
|
||||
t.Error("dedup should keep first registered binding (arrow key), not vim key")
|
||||
}
|
||||
if actions[1].ID != ActionNavDown {
|
||||
t.Errorf("expected second action to be NavDown, got %v", actions[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaletteActions_NilRegistry(t *testing.T) {
|
||||
var r *ActionRegistry
|
||||
if actions := r.GetPaletteActions(); actions != nil {
|
||||
t.Errorf("expected nil from nil registry, got %v", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsID(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit"})
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back"})
|
||||
|
||||
if !r.ContainsID(ActionQuit) {
|
||||
t.Error("expected ContainsID to find ActionQuit")
|
||||
}
|
||||
if !r.ContainsID(ActionBack) {
|
||||
t.Error("expected ContainsID to find ActionBack")
|
||||
}
|
||||
if r.ContainsID(ActionRefresh) {
|
||||
t.Error("expected ContainsID to not find ActionRefresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsID_NilRegistry(t *testing.T) {
|
||||
var r *ActionRegistry
|
||||
if r.ContainsID(ActionQuit) {
|
||||
t.Error("expected false from nil registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultGlobalActions_BackHiddenFromPalette(t *testing.T) {
|
||||
registry := DefaultGlobalActions()
|
||||
paletteActions := registry.GetPaletteActions()
|
||||
|
||||
for _, a := range paletteActions {
|
||||
if a.ID == ActionBack {
|
||||
t.Error("ActionBack should be hidden from palette")
|
||||
}
|
||||
}
|
||||
|
||||
if !registry.ContainsID(ActionBack) {
|
||||
t.Error("ActionBack should still be registered in global actions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginViewActions_NavHiddenFromPalette(t *testing.T) {
|
||||
registry := PluginViewActions()
|
||||
paletteActions := registry.GetPaletteActions()
|
||||
|
||||
navIDs := map[ActionID]bool{
|
||||
ActionNavUp: true, ActionNavDown: true,
|
||||
ActionNavLeft: true, ActionNavRight: true,
|
||||
}
|
||||
for _, a := range paletteActions {
|
||||
if navIDs[a.ID] {
|
||||
t.Errorf("navigation action %v should be hidden from palette", a.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// semantic actions should remain visible
|
||||
found := map[ActionID]bool{}
|
||||
for _, a := range paletteActions {
|
||||
found[a.ID] = true
|
||||
}
|
||||
for _, want := range []ActionID{ActionOpenFromPlugin, ActionNewTask, ActionDeleteTask, ActionSearch} {
|
||||
if !found[want] {
|
||||
t.Errorf("expected palette-visible action %v", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEditActions_FieldLocalHidden_SaveVisible(t *testing.T) {
|
||||
registry := TaskEditTitleActions()
|
||||
paletteActions := registry.GetPaletteActions()
|
||||
|
||||
found := map[ActionID]bool{}
|
||||
for _, a := range paletteActions {
|
||||
found[a.ID] = true
|
||||
}
|
||||
|
||||
if !found[ActionSaveTask] {
|
||||
t.Error("Save should be palette-visible in task edit")
|
||||
}
|
||||
if found[ActionQuickSave] {
|
||||
t.Error("Quick Save should be hidden from palette")
|
||||
}
|
||||
if found[ActionNextField] {
|
||||
t.Error("Next field should be hidden from palette")
|
||||
}
|
||||
if found[ActionPrevField] {
|
||||
t.Error("Prev field should be hidden from palette")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitPluginActions_ActivePluginDisabled(t *testing.T) {
|
||||
InitPluginActions([]PluginInfo{
|
||||
{Name: "Kanban", Key: tcell.KeyRune, Rune: '1'},
|
||||
{Name: "Backlog", Key: tcell.KeyRune, Rune: '2'},
|
||||
})
|
||||
|
||||
registry := GetPluginActions()
|
||||
actions := registry.GetPaletteActions()
|
||||
|
||||
kanbanViewEntry := &ViewEntry{ViewID: model.MakePluginViewID("Kanban")}
|
||||
backlogViewEntry := &ViewEntry{ViewID: model.MakePluginViewID("Backlog")}
|
||||
|
||||
for _, a := range actions {
|
||||
if a.ID == "plugin:Kanban" {
|
||||
if a.IsEnabled == nil {
|
||||
t.Fatal("expected IsEnabled on plugin:Kanban")
|
||||
}
|
||||
if a.IsEnabled(kanbanViewEntry, nil) {
|
||||
t.Error("Kanban activation should be disabled when Kanban view is active")
|
||||
}
|
||||
if !a.IsEnabled(backlogViewEntry, nil) {
|
||||
t.Error("Kanban activation should be enabled when Backlog view is active")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package controller
|
|||
import (
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
// DokiController handles doki plugin view actions (documentation/markdown navigation).
|
||||
|
|
@ -59,6 +60,14 @@ func (dc *DokiController) HandleAction(actionID ActionID) bool {
|
|||
}
|
||||
|
||||
// HandleSearch is not applicable for DokiPlugins (documentation views don't have search)
|
||||
func (dc *DokiController) HandleSearch(query string) {
|
||||
// No-op: Doki plugins don't support search
|
||||
func (dc *DokiController) HandleSearch(query string) {}
|
||||
|
||||
func (dc *DokiController) GetActionInputSpec(ActionID) (string, ruki.ValueType, bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
func (dc *DokiController) CanStartActionInput(ActionID) (string, ruki.ValueType, bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
func (dc *DokiController) HandleActionInput(ActionID, string) InputSubmitResult {
|
||||
return InputKeepEditing
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ type PluginControllerInterface interface {
|
|||
HandleAction(ActionID) bool
|
||||
HandleSearch(string)
|
||||
ShowNavigation() bool
|
||||
GetActionInputSpec(ActionID) (prompt string, typ ruki.ValueType, hasInput bool)
|
||||
CanStartActionInput(ActionID) (prompt string, typ ruki.ValueType, ok bool)
|
||||
HandleActionInput(ActionID, string) InputSubmitResult
|
||||
}
|
||||
|
||||
// TikiViewProvider is implemented by controllers that back a TikiPlugin view.
|
||||
|
|
@ -54,6 +57,8 @@ type InputRouter struct {
|
|||
statusline *model.StatuslineConfig
|
||||
schema ruki.Schema
|
||||
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
|
||||
headerConfig *model.HeaderConfig
|
||||
paletteConfig *model.ActionPaletteConfig
|
||||
}
|
||||
|
||||
// NewInputRouter creates an input router
|
||||
|
|
@ -79,6 +84,16 @@ func NewInputRouter(
|
|||
}
|
||||
}
|
||||
|
||||
// SetHeaderConfig wires the header config for fullscreen-aware header toggling.
|
||||
func (ir *InputRouter) SetHeaderConfig(hc *model.HeaderConfig) {
|
||||
ir.headerConfig = hc
|
||||
}
|
||||
|
||||
// SetPaletteConfig wires the palette config for ActionOpenPalette dispatch.
|
||||
func (ir *InputRouter) SetPaletteConfig(pc *model.ActionPaletteConfig) {
|
||||
ir.paletteConfig = pc
|
||||
}
|
||||
|
||||
// HandleInput processes a key event for the current view and routes it to the appropriate handler.
|
||||
// It processes events through multiple handlers in order:
|
||||
// 1. Search input (if search is active)
|
||||
|
|
@ -91,6 +106,28 @@ func NewInputRouter(
|
|||
func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry) bool {
|
||||
slog.Debug("input received", "name", event.Name(), "key", int(event.Key()), "rune", string(event.Rune()), "modifiers", int(event.Modifiers()))
|
||||
|
||||
// palette fires regardless of focus context (Ctrl+A can't conflict with typing)
|
||||
if action := ir.globalActions.Match(event); action != nil {
|
||||
if action.ID == ActionOpenPalette {
|
||||
return ir.handleGlobalAction(action.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// if the input box is focused, let it handle all remaining input (including F10)
|
||||
if activeView := ir.navController.GetActiveView(); activeView != nil {
|
||||
if iv, ok := activeView.(InputableView); ok && iv.IsInputBoxFocused() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// pre-gate: global actions that must fire before task-edit Prepare() and before
|
||||
// search/fullscreen/editor gates
|
||||
if action := ir.globalActions.Match(event); action != nil {
|
||||
if action.ID == ActionToggleHeader {
|
||||
return ir.handleGlobalAction(action.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if currentView == nil {
|
||||
return false
|
||||
}
|
||||
|
|
@ -104,7 +141,7 @@ func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry
|
|||
ir.taskEditCoord.Prepare(activeView, model.DecodeTaskEditParams(currentView.Params))
|
||||
}
|
||||
|
||||
if stop, handled := ir.maybeHandleSearchInput(activeView, event); stop {
|
||||
if stop, handled := ir.maybeHandleInputBox(activeView, event); stop {
|
||||
return handled
|
||||
}
|
||||
if stop, handled := ir.maybeHandleFullscreenEscape(activeView, event); stop {
|
||||
|
|
@ -137,20 +174,20 @@ func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry
|
|||
}
|
||||
}
|
||||
|
||||
// maybeHandleSearchInput handles search box focus/visibility semantics.
|
||||
// maybeHandleInputBox handles input box focus/visibility semantics.
|
||||
// stop=true means input routing should stop and return handled.
|
||||
func (ir *InputRouter) maybeHandleSearchInput(activeView View, event *tcell.EventKey) (stop bool, handled bool) {
|
||||
searchableView, ok := activeView.(SearchableView)
|
||||
func (ir *InputRouter) maybeHandleInputBox(activeView View, event *tcell.EventKey) (stop bool, handled bool) {
|
||||
inputableView, ok := activeView.(InputableView)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
if searchableView.IsSearchBoxFocused() {
|
||||
// Search box has focus and handles input through tview.
|
||||
if inputableView.IsInputBoxFocused() {
|
||||
return true, false
|
||||
}
|
||||
// Search is visible but grid has focus - handle Esc to close search.
|
||||
if searchableView.IsSearchVisible() && event.Key() == tcell.KeyEscape {
|
||||
searchableView.HideSearch()
|
||||
// visible but not focused (passive mode): Esc dismisses via cancel path
|
||||
// so search-specific teardown fires (clearing results)
|
||||
if inputableView.IsInputBoxVisible() && event.Key() == tcell.KeyEscape {
|
||||
inputableView.CancelInputBox()
|
||||
return true, true
|
||||
}
|
||||
return false, false
|
||||
|
|
@ -262,28 +299,29 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
|
|||
// handlePluginInput routes input to the appropriate plugin controller
|
||||
func (ir *InputRouter) handlePluginInput(event *tcell.EventKey, viewID model.ViewID) bool {
|
||||
pluginName := model.GetPluginName(viewID)
|
||||
controller, ok := ir.pluginControllers[pluginName]
|
||||
ctrl, ok := ir.pluginControllers[pluginName]
|
||||
if !ok {
|
||||
slog.Warn("plugin controller not found", "plugin", pluginName)
|
||||
return false
|
||||
}
|
||||
|
||||
registry := controller.GetActionRegistry()
|
||||
registry := ctrl.GetActionRegistry()
|
||||
if action := registry.Match(event); action != nil {
|
||||
// Handle search action specially - show search box
|
||||
if action.ID == ActionSearch {
|
||||
return ir.handleSearchAction(controller)
|
||||
return ir.handleSearchInput(ctrl)
|
||||
}
|
||||
// Handle plugin activation keys - switch to different plugin
|
||||
if targetPluginName := GetPluginNameFromAction(action.ID); targetPluginName != "" {
|
||||
targetViewID := model.MakePluginViewID(targetPluginName)
|
||||
if viewID != targetViewID {
|
||||
ir.navController.ReplaceView(targetViewID, nil)
|
||||
return true
|
||||
}
|
||||
return true // already on this plugin, consume the event
|
||||
return true
|
||||
}
|
||||
return controller.HandleAction(action.ID)
|
||||
if _, _, hasInput := ctrl.GetActionInputSpec(action.ID); hasInput {
|
||||
return ir.startActionInput(ctrl, action.ID)
|
||||
}
|
||||
return ctrl.HandleAction(action.ID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -293,8 +331,6 @@ func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool {
|
|||
switch actionID {
|
||||
case ActionBack:
|
||||
if v := ir.navController.GetActiveView(); v != nil && v.GetViewID() == model.TaskEditViewID {
|
||||
// Cancel edit session (discards changes) and close.
|
||||
// This keeps the ActionBack behavior consistent across input paths.
|
||||
return ir.taskEditCoord.CancelAndClose()
|
||||
}
|
||||
return ir.navController.HandleBack()
|
||||
|
|
@ -304,32 +340,262 @@ func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool {
|
|||
case ActionRefresh:
|
||||
_ = ir.taskStore.Reload()
|
||||
return true
|
||||
case ActionOpenPalette:
|
||||
if ir.paletteConfig != nil {
|
||||
ir.paletteConfig.SetVisible(true)
|
||||
}
|
||||
return true
|
||||
case ActionToggleHeader:
|
||||
ir.toggleHeader()
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// handleSearchAction is a generic handler for ActionSearch across all searchable views
|
||||
func (ir *InputRouter) handleSearchAction(controller interface{ HandleSearch(string) }) bool {
|
||||
// toggleHeader toggles the stored user preference and recomputes effective visibility
|
||||
// against the live active view so fullscreen/header-hidden views stay force-hidden.
|
||||
func (ir *InputRouter) toggleHeader() {
|
||||
if ir.headerConfig == nil {
|
||||
return
|
||||
}
|
||||
newPref := !ir.headerConfig.GetUserPreference()
|
||||
ir.headerConfig.SetUserPreference(newPref)
|
||||
|
||||
visible := newPref
|
||||
if v := ir.navController.GetActiveView(); v != nil {
|
||||
if hv, ok := v.(interface{ RequiresHeaderHidden() bool }); ok && hv.RequiresHeaderHidden() {
|
||||
visible = false
|
||||
}
|
||||
if fv, ok := v.(FullscreenView); ok && fv.IsFullscreen() {
|
||||
visible = false
|
||||
}
|
||||
}
|
||||
ir.headerConfig.SetVisible(visible)
|
||||
}
|
||||
|
||||
// HandleAction dispatches a palette-selected action by ID against the given view entry.
|
||||
// This is the controller-side fallback for palette execution — the palette tries
|
||||
// view.HandlePaletteAction first, then falls back here.
|
||||
func (ir *InputRouter) HandleAction(id ActionID, currentView *ViewEntry) bool {
|
||||
if currentView == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// block palette-dispatched actions while an input box is in editing mode
|
||||
if activeView := ir.navController.GetActiveView(); activeView != nil {
|
||||
if iv, ok := activeView.(InputableView); ok && iv.IsInputBoxFocused() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// global actions
|
||||
if ir.globalActions.ContainsID(id) {
|
||||
return ir.handleGlobalAction(id)
|
||||
}
|
||||
|
||||
activeView := ir.navController.GetActiveView()
|
||||
searchableView, ok := activeView.(SearchableView)
|
||||
|
||||
switch currentView.ViewID {
|
||||
case model.TaskDetailViewID:
|
||||
taskID := model.DecodeTaskDetailParams(currentView.Params).TaskID
|
||||
if taskID != "" {
|
||||
ir.taskController.SetCurrentTask(taskID)
|
||||
}
|
||||
return ir.dispatchTaskAction(id, currentView.Params)
|
||||
|
||||
case model.TaskEditViewID:
|
||||
if activeView != nil {
|
||||
ir.taskEditCoord.Prepare(activeView, model.DecodeTaskEditParams(currentView.Params))
|
||||
}
|
||||
return ir.dispatchTaskEditAction(id, activeView)
|
||||
|
||||
default:
|
||||
if model.IsPluginViewID(currentView.ViewID) {
|
||||
return ir.dispatchPluginAction(id, currentView.ViewID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchTaskAction handles palette-dispatched task detail actions by ActionID.
|
||||
func (ir *InputRouter) dispatchTaskAction(id ActionID, _ map[string]interface{}) bool {
|
||||
switch id {
|
||||
case ActionEditTitle:
|
||||
taskID := ir.taskController.GetCurrentTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
|
||||
TaskID: taskID,
|
||||
Focus: model.EditFieldTitle,
|
||||
}))
|
||||
return true
|
||||
case ActionFullscreen:
|
||||
activeView := ir.navController.GetActiveView()
|
||||
if fullscreenView, ok := activeView.(FullscreenView); ok {
|
||||
if fullscreenView.IsFullscreen() {
|
||||
fullscreenView.ExitFullscreen()
|
||||
} else {
|
||||
fullscreenView.EnterFullscreen()
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case ActionEditDesc:
|
||||
taskID := ir.taskController.GetCurrentTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
|
||||
TaskID: taskID,
|
||||
Focus: model.EditFieldDescription,
|
||||
DescOnly: true,
|
||||
}))
|
||||
return true
|
||||
case ActionEditTags:
|
||||
taskID := ir.taskController.GetCurrentTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
|
||||
TaskID: taskID,
|
||||
TagsOnly: true,
|
||||
}))
|
||||
return true
|
||||
case ActionEditDeps:
|
||||
taskID := ir.taskController.GetCurrentTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
return ir.openDepsEditor(taskID)
|
||||
case ActionChat:
|
||||
agent := config.GetAIAgent()
|
||||
if agent == "" {
|
||||
return false
|
||||
}
|
||||
taskID := ir.taskController.GetCurrentTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
filename := strings.ToLower(taskID) + ".md"
|
||||
taskFilePath := filepath.Join(config.GetTaskDir(), filename)
|
||||
name, args := resolveAgentCommand(agent, taskFilePath)
|
||||
ir.navController.SuspendAndRun(name, args...)
|
||||
_ = ir.taskStore.ReloadTask(taskID)
|
||||
return true
|
||||
case ActionCloneTask:
|
||||
return ir.taskController.HandleAction(id)
|
||||
default:
|
||||
return ir.taskController.HandleAction(id)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchTaskEditAction handles palette-dispatched task edit actions by ActionID.
|
||||
func (ir *InputRouter) dispatchTaskEditAction(id ActionID, activeView View) bool {
|
||||
switch id {
|
||||
case ActionSaveTask:
|
||||
if activeView != nil {
|
||||
return ir.taskEditCoord.CommitAndClose(activeView)
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchPluginAction handles palette-dispatched plugin actions by ActionID.
|
||||
func (ir *InputRouter) dispatchPluginAction(id ActionID, viewID model.ViewID) bool {
|
||||
if targetPluginName := GetPluginNameFromAction(id); targetPluginName != "" {
|
||||
targetViewID := model.MakePluginViewID(targetPluginName)
|
||||
if viewID != targetViewID {
|
||||
ir.navController.ReplaceView(targetViewID, nil)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
pluginName := model.GetPluginName(viewID)
|
||||
ctrl, ok := ir.pluginControllers[pluginName]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if id == ActionSearch {
|
||||
return ir.handleSearchInput(ctrl)
|
||||
}
|
||||
|
||||
if _, _, hasInput := ctrl.GetActionInputSpec(id); hasInput {
|
||||
return ir.startActionInput(ctrl, id)
|
||||
}
|
||||
|
||||
return ctrl.HandleAction(id)
|
||||
}
|
||||
|
||||
// startActionInput opens the input box for an action that requires user input.
|
||||
func (ir *InputRouter) startActionInput(ctrl PluginControllerInterface, actionID ActionID) bool {
|
||||
_, _, ok := ctrl.CanStartActionInput(actionID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
activeView := ir.navController.GetActiveView()
|
||||
inputableView, ok := activeView.(InputableView)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Set up focus callback
|
||||
app := ir.navController.GetApp()
|
||||
searchableView.SetFocusSetter(func(p tview.Primitive) {
|
||||
inputableView.SetFocusSetter(func(p tview.Primitive) {
|
||||
app.SetFocus(p)
|
||||
})
|
||||
|
||||
// Wire up search submit handler to controller
|
||||
searchableView.SetSearchSubmitHandler(controller.HandleSearch)
|
||||
inputableView.SetInputSubmitHandler(func(text string) InputSubmitResult {
|
||||
return ctrl.HandleActionInput(actionID, text)
|
||||
})
|
||||
|
||||
// Show search box and focus it
|
||||
searchBox := searchableView.ShowSearch()
|
||||
if searchBox != nil {
|
||||
app.SetFocus(searchBox)
|
||||
inputableView.SetInputCancelHandler(func() {
|
||||
inputableView.CancelInputBox()
|
||||
})
|
||||
|
||||
inputBox := inputableView.ShowInputBox("> ", "")
|
||||
if inputBox != nil {
|
||||
app.SetFocus(inputBox)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// handleSearchInput opens the input box in search mode for the active view.
|
||||
// Blocked when search is already passive — user must Esc first.
|
||||
func (ir *InputRouter) handleSearchInput(ctrl interface{ HandleSearch(string) }) bool {
|
||||
activeView := ir.navController.GetActiveView()
|
||||
inputableView, ok := activeView.(InputableView)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if inputableView.IsSearchPassive() {
|
||||
return true
|
||||
}
|
||||
|
||||
app := ir.navController.GetApp()
|
||||
inputableView.SetFocusSetter(func(p tview.Primitive) {
|
||||
app.SetFocus(p)
|
||||
})
|
||||
|
||||
inputableView.SetInputSubmitHandler(func(text string) InputSubmitResult {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return InputKeepEditing
|
||||
}
|
||||
ctrl.HandleSearch(trimmed)
|
||||
return InputShowPassive
|
||||
})
|
||||
|
||||
inputBox := inputableView.ShowSearchBox()
|
||||
if inputBox != nil {
|
||||
app.SetFocus(inputBox)
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -51,24 +51,46 @@ type SelectableView interface {
|
|||
SetSelectedID(id string)
|
||||
}
|
||||
|
||||
// SearchableView is a view that supports search functionality
|
||||
type SearchableView interface {
|
||||
// InputSubmitResult controls what happens to the input box after a submit callback.
|
||||
type InputSubmitResult int
|
||||
|
||||
const (
|
||||
InputKeepEditing InputSubmitResult = iota // keep the box open and focused for correction
|
||||
InputShowPassive // keep the box visible but unfocused/non-editable
|
||||
InputClose // close/hide the box
|
||||
)
|
||||
|
||||
// InputableView is a view that supports an input box (for search, action input, etc.)
|
||||
type InputableView interface {
|
||||
View
|
||||
|
||||
// ShowSearch displays the search box and returns the primitive to focus
|
||||
ShowSearch() tview.Primitive
|
||||
// ShowInputBox displays the input box for action-input mode.
|
||||
// If search is passive, it temporarily replaces the search indicator.
|
||||
ShowInputBox(prompt, initial string) tview.Primitive
|
||||
|
||||
// HideSearch hides the search box
|
||||
HideSearch()
|
||||
// ShowSearchBox opens the input box in search-editing mode.
|
||||
ShowSearchBox() tview.Primitive
|
||||
|
||||
// IsSearchVisible returns whether the search box is currently visible
|
||||
IsSearchVisible() bool
|
||||
// HideInputBox hides the input box (generic widget teardown only, no search state)
|
||||
HideInputBox()
|
||||
|
||||
// IsSearchBoxFocused returns whether the search box currently has focus
|
||||
IsSearchBoxFocused() bool
|
||||
// CancelInputBox triggers mode-aware cancel (search clears results, action-input restores passive search)
|
||||
CancelInputBox()
|
||||
|
||||
// SetSearchSubmitHandler sets the callback for when search is submitted
|
||||
SetSearchSubmitHandler(handler func(text string))
|
||||
// IsInputBoxVisible returns whether the input box is currently visible (any mode)
|
||||
IsInputBoxVisible() bool
|
||||
|
||||
// IsInputBoxFocused returns whether the input box currently has focus
|
||||
IsInputBoxFocused() bool
|
||||
|
||||
// IsSearchPassive returns true if search is applied and the box is in passive/unfocused mode
|
||||
IsSearchPassive() bool
|
||||
|
||||
// SetInputSubmitHandler sets the callback for when input is submitted
|
||||
SetInputSubmitHandler(handler func(text string) InputSubmitResult)
|
||||
|
||||
// SetInputCancelHandler sets the callback for when input is cancelled
|
||||
SetInputCancelHandler(handler func())
|
||||
|
||||
// SetFocusSetter sets the callback for requesting focus changes
|
||||
SetFocusSetter(setter func(p tview.Primitive))
|
||||
|
|
@ -272,6 +294,27 @@ type RecurrencePartNavigable interface {
|
|||
IsRecurrenceValueFocused() bool
|
||||
}
|
||||
|
||||
// PaletteActionHandler is implemented by views that handle palette-dispatched actions
|
||||
// directly (e.g., DokiView replays navigation as synthetic key events).
|
||||
// The palette tries this before falling back to InputRouter.HandleAction.
|
||||
type PaletteActionHandler interface {
|
||||
HandlePaletteAction(id ActionID) bool
|
||||
}
|
||||
|
||||
// FocusRestorer is implemented by views that can recover focus after the palette closes
|
||||
// when the originally saved focused primitive is no longer valid (e.g., TaskDetailView
|
||||
// rebuilds its description primitive during store-driven refresh).
|
||||
type FocusRestorer interface {
|
||||
RestoreFocus() bool
|
||||
}
|
||||
|
||||
// ActionChangeNotifier is implemented by views that mutate their action registry
|
||||
// or live enablement/presentation state while the same view instance stays active.
|
||||
// RootLayout wires the handler and reruns syncViewContextFromView when fired.
|
||||
type ActionChangeNotifier interface {
|
||||
SetActionChangeHandler(handler func())
|
||||
}
|
||||
|
||||
// ViewInfoProvider is a view that provides its name and description for the header info section
|
||||
type ViewInfoProvider interface {
|
||||
GetViewName() string
|
||||
|
|
|
|||
|
|
@ -55,13 +55,17 @@ func NewPluginController(
|
|||
"plugin", pluginDef.Name, "key", string(a.Rune),
|
||||
"plugin_action", a.Label, "built_in_action", existing.Label)
|
||||
}
|
||||
pc.registry.Register(Action{
|
||||
action := Action{
|
||||
ID: pluginActionID(a.Rune),
|
||||
Key: tcell.KeyRune,
|
||||
Rune: a.Rune,
|
||||
Label: a.Label,
|
||||
ShowInHeader: true,
|
||||
})
|
||||
ShowInHeader: a.ShowInHeader,
|
||||
}
|
||||
if a.Action != nil && (a.Action.IsUpdate() || a.Action.IsDelete() || a.Action.IsPipe()) {
|
||||
action.IsEnabled = selectionRequired
|
||||
}
|
||||
pc.registry.Register(action)
|
||||
}
|
||||
|
||||
return pc
|
||||
|
|
@ -139,34 +143,33 @@ func (pc *PluginController) HandleSearch(query string) {
|
|||
})
|
||||
}
|
||||
|
||||
// handlePluginAction applies a plugin shortcut action to the currently selected task.
|
||||
func (pc *PluginController) handlePluginAction(r rune) bool {
|
||||
// find the matching action definition
|
||||
var pa *plugin.PluginAction
|
||||
// getPluginAction looks up a plugin action by ActionID.
|
||||
func (pc *PluginController) getPluginAction(actionID ActionID) (*plugin.PluginAction, rune, bool) {
|
||||
r := getPluginActionRune(actionID)
|
||||
if r == 0 {
|
||||
return nil, 0, false
|
||||
}
|
||||
for i := range pc.pluginDef.Actions {
|
||||
if pc.pluginDef.Actions[i].Rune == r {
|
||||
pa = &pc.pluginDef.Actions[i]
|
||||
break
|
||||
return &pc.pluginDef.Actions[i], r, true
|
||||
}
|
||||
}
|
||||
if pa == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
executor := pc.newExecutor()
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
// buildExecutionInput builds the base ExecutionInput for an action, performing
|
||||
// selection/create-template preflight. Returns ok=false if the action can't run.
|
||||
func (pc *PluginController) buildExecutionInput(pa *plugin.PluginAction) (ruki.ExecutionInput, bool) {
|
||||
input := ruki.ExecutionInput{}
|
||||
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
|
||||
|
||||
if pa.Action.IsSelect() && !pa.Action.IsPipe() {
|
||||
// plain SELECT actions are side-effect only — pass task ID if available but don't require it
|
||||
if taskID != "" {
|
||||
input.SelectedTaskID = taskID
|
||||
}
|
||||
} else if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() {
|
||||
if taskID == "" {
|
||||
return false
|
||||
return input, false
|
||||
}
|
||||
input.SelectedTaskID = taskID
|
||||
}
|
||||
|
|
@ -174,22 +177,33 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
if pa.Action.IsCreate() {
|
||||
template, err := pc.taskStore.NewTaskTemplate()
|
||||
if err != nil {
|
||||
slog.Error("failed to create task template for plugin action", "key", string(r), "error", err)
|
||||
return false
|
||||
slog.Error("failed to create task template for plugin action", "error", err)
|
||||
return input, false
|
||||
}
|
||||
input.CreateTemplate = template
|
||||
}
|
||||
|
||||
return input, true
|
||||
}
|
||||
|
||||
// executeAndApply runs the executor and applies the result (store mutations, pipe, clipboard).
|
||||
func (pc *PluginController) executeAndApply(pa *plugin.PluginAction, input ruki.ExecutionInput, r rune) bool {
|
||||
executor := pc.newExecutor()
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
|
||||
result, err := executor.Execute(pa.Action, allTasks, input)
|
||||
if err != nil {
|
||||
slog.Error("failed to execute plugin action", "task_id", taskID, "key", string(r), "error", err)
|
||||
slog.Error("failed to execute plugin action", "task_id", input.SelectedTaskID, "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
switch {
|
||||
case result.Select != nil:
|
||||
slog.Info("select plugin action executed", "task_id", taskID, "key", string(r),
|
||||
slog.Info("select plugin action executed", "task_id", input.SelectedTaskID, "key", string(r),
|
||||
"label", pa.Label, "matched", len(result.Select.Tasks))
|
||||
return true
|
||||
case result.Update != nil:
|
||||
|
|
@ -228,7 +242,6 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
// non-fatal: continue remaining rows
|
||||
}
|
||||
}
|
||||
case result.Clipboard != nil:
|
||||
|
|
@ -244,10 +257,71 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
}
|
||||
}
|
||||
|
||||
slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
|
||||
slog.Info("plugin action applied", "task_id", input.SelectedTaskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
// handlePluginAction applies a plugin shortcut action to the currently selected task.
|
||||
func (pc *PluginController) handlePluginAction(r rune) bool {
|
||||
pa, _, ok := pc.getPluginAction(pluginActionID(r))
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
input, ok := pc.buildExecutionInput(pa)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return pc.executeAndApply(pa, input, r)
|
||||
}
|
||||
|
||||
// GetActionInputSpec returns the prompt and input type for an action, if it has input.
|
||||
func (pc *PluginController) GetActionInputSpec(actionID ActionID) (string, ruki.ValueType, bool) {
|
||||
pa, _, ok := pc.getPluginAction(actionID)
|
||||
if !ok || !pa.HasInput {
|
||||
return "", 0, false
|
||||
}
|
||||
return pa.Label + ": ", pa.InputType, true
|
||||
}
|
||||
|
||||
// CanStartActionInput checks whether an input-backed action can currently run
|
||||
// (selection/create-template preflight passes).
|
||||
func (pc *PluginController) CanStartActionInput(actionID ActionID) (string, ruki.ValueType, bool) {
|
||||
pa, _, ok := pc.getPluginAction(actionID)
|
||||
if !ok || !pa.HasInput {
|
||||
return "", 0, false
|
||||
}
|
||||
if _, ok := pc.buildExecutionInput(pa); !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
return pa.Label + ": ", pa.InputType, true
|
||||
}
|
||||
|
||||
// HandleActionInput handles submitted text for an input-backed action.
|
||||
func (pc *PluginController) HandleActionInput(actionID ActionID, text string) InputSubmitResult {
|
||||
pa, r, ok := pc.getPluginAction(actionID)
|
||||
if !ok || !pa.HasInput {
|
||||
return InputKeepEditing
|
||||
}
|
||||
|
||||
val, err := ruki.ParseScalarValue(pa.InputType, text)
|
||||
if err != nil {
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return InputKeepEditing
|
||||
}
|
||||
|
||||
input, ok := pc.buildExecutionInput(pa)
|
||||
if !ok {
|
||||
return InputClose
|
||||
}
|
||||
input.InputValue = val
|
||||
input.HasInput = true
|
||||
|
||||
pc.executeAndApply(pa, input, r)
|
||||
return InputClose
|
||||
}
|
||||
|
||||
func (pc *PluginController) handleMoveTask(offset int) bool {
|
||||
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
|
||||
if taskID == "" {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ func (pb *pluginBase) newExecutor() *ruki.Executor {
|
|||
func (pb *pluginBase) GetActionRegistry() *ActionRegistry { return pb.registry }
|
||||
func (pb *pluginBase) GetPluginName() string { return pb.pluginDef.Name }
|
||||
|
||||
// default no-op implementations for input-backed action methods
|
||||
func (pb *pluginBase) GetActionInputSpec(ActionID) (string, ruki.ValueType, bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
func (pb *pluginBase) CanStartActionInput(ActionID) (string, ruki.ValueType, bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
func (pb *pluginBase) HandleActionInput(ActionID, string) InputSubmitResult { return InputKeepEditing }
|
||||
|
||||
func (pb *pluginBase) handleNav(direction string, filteredTasks func(int) []*task.Task) bool {
|
||||
lane := pb.pluginConfig.GetSelectedLane()
|
||||
tasks := filteredTasks(lane)
|
||||
|
|
|
|||
|
|
@ -1288,6 +1288,166 @@ func TestPluginController_HandlePluginAction_SelectNoSelectedTask(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func mustParseStmtWithInput(t *testing.T, input string, inputType ruki.ValueType) *ruki.ValidatedStatement {
|
||||
t.Helper()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
parser := ruki.NewParser(schema)
|
||||
stmt, err := parser.ParseAndValidateStatementWithInput(input, ruki.ExecutorRuntimePlugin, inputType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse ruki statement %q: %v", input, err)
|
||||
}
|
||||
return stmt
|
||||
}
|
||||
|
||||
func TestPluginController_HandleActionInput_ValidInput(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
assignAction := mustParseStmtWithInput(t, `update where id = id() set assignee = input()`, ruki.ValueString)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{Rune: 'a', Label: "Assign to...", Action: assignAction, InputType: ruki.ValueString, HasInput: true},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
gate := service.BuildGate()
|
||||
gate.SetStore(taskStore)
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
|
||||
|
||||
result := pc.HandleActionInput(pluginActionID('a'), "alice")
|
||||
if result != InputClose {
|
||||
t.Fatalf("expected InputClose for valid input, got %d", result)
|
||||
}
|
||||
|
||||
updated := taskStore.GetTask("T-1")
|
||||
if updated.Assignee != "alice" {
|
||||
t.Fatalf("expected assignee=alice, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleActionInput_InvalidInput(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pointsAction := mustParseStmtWithInput(t, `update where id = id() set points = input()`, ruki.ValueInt)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{Rune: 'p', Label: "Set points", Action: pointsAction, InputType: ruki.ValueInt, HasInput: true},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
gate := service.BuildGate()
|
||||
gate.SetStore(taskStore)
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
|
||||
|
||||
result := pc.HandleActionInput(pluginActionID('p'), "abc")
|
||||
if result != InputKeepEditing {
|
||||
t.Fatalf("expected InputKeepEditing for invalid int input, got %d", result)
|
||||
}
|
||||
|
||||
msg, level, _ := statusline.GetMessage()
|
||||
if level != model.MessageLevelError {
|
||||
t.Fatalf("expected error message in statusline, got level %v msg %q", level, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleActionInput_ExecutionFailure_StillCloses(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
// no tasks in store — executor will find no match for id(), which means
|
||||
// the update produces no results (not an error), but executeAndApply still returns true.
|
||||
// Instead, test with a task that exists but use input on a field
|
||||
// where the assignment succeeds at parse/execution level.
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
assignAction := mustParseStmtWithInput(t, `update where id = id() set assignee = input()`, ruki.ValueString)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{Rune: 'a', Label: "Assign to...", Action: assignAction, InputType: ruki.ValueString, HasInput: true},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
gate := service.BuildGate()
|
||||
gate.SetStore(taskStore)
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
|
||||
|
||||
// valid parse, successful execution — still returns InputClose
|
||||
result := pc.HandleActionInput(pluginActionID('a'), "bob")
|
||||
if result != InputClose {
|
||||
t.Fatalf("expected InputClose after valid parse (regardless of execution outcome), got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_GetActionInputSpec(t *testing.T) {
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
assignAction := mustParseStmtWithInput(t, `update where id = id() set assignee = input()`, ruki.ValueString)
|
||||
markDoneAction := mustParseStmt(t, `update where id = id() set status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{Rune: 'a', Label: "Assign to...", Action: assignAction, InputType: ruki.ValueString, HasInput: true},
|
||||
{Rune: 'd', Label: "Done", Action: markDoneAction},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.BuildGate()
|
||||
gate.SetStore(store.NewInMemoryStore())
|
||||
pc := NewPluginController(store.NewInMemoryStore(), gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
|
||||
prompt, typ, hasInput := pc.GetActionInputSpec(pluginActionID('a'))
|
||||
if !hasInput {
|
||||
t.Fatal("expected hasInput=true for 'a' action")
|
||||
}
|
||||
if typ != ruki.ValueString {
|
||||
t.Fatalf("expected ValueString, got %d", typ)
|
||||
}
|
||||
if prompt != "Assign to...: " {
|
||||
t.Fatalf("expected prompt 'Assign to...: ', got %q", prompt)
|
||||
}
|
||||
|
||||
_, _, hasInput = pc.GetActionInputSpec(pluginActionID('d'))
|
||||
if hasInput {
|
||||
t.Fatal("expected hasInput=false for non-input 'd' action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginActionRune(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
164
integration/action_palette_test.go
Normal file
164
integration/action_palette_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
taskpkg "github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/testutil"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func TestActionPalette_OpenAndClose(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
ta.Draw()
|
||||
|
||||
// Ctrl+A opens the palette
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should be visible after pressing Ctrl+A")
|
||||
}
|
||||
|
||||
// Esc closes it
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
if ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should be hidden after pressing Esc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPalette_F10TogglesHeader(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
ta.Draw()
|
||||
|
||||
hc := ta.GetHeaderConfig()
|
||||
initialVisible := hc.IsVisible()
|
||||
|
||||
// F10 should toggle header via the router
|
||||
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
|
||||
if hc.IsVisible() == initialVisible {
|
||||
t.Fatal("F10 should toggle header visibility")
|
||||
}
|
||||
|
||||
// toggle back
|
||||
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
|
||||
if hc.IsVisible() != initialVisible {
|
||||
t.Fatal("second F10 should restore header visibility")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPalette_ModalBlocksGlobals(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
ta.Draw()
|
||||
|
||||
hc := ta.GetHeaderConfig()
|
||||
startVisible := hc.IsVisible()
|
||||
|
||||
// open palette
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should be open")
|
||||
}
|
||||
|
||||
// F10 while palette is open should NOT toggle header
|
||||
// (app capture returns event unchanged, palette input handler swallows F10)
|
||||
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
|
||||
if hc.IsVisible() != startVisible {
|
||||
t.Fatal("F10 should be blocked while palette is modal")
|
||||
}
|
||||
|
||||
// close palette
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestActionPalette_AsteriskIsFilterTextInPalette(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
ta.Draw()
|
||||
|
||||
// open palette
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should be open")
|
||||
}
|
||||
|
||||
// typing '*' while palette is open should be treated as filter text, not open another palette
|
||||
ta.SendKeyToFocused(tcell.KeyRune, '*', tcell.ModNone)
|
||||
|
||||
// palette should still be open
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should remain open when '*' is typed as filter")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestActionPalette_AsteriskDoesNotOpenPalette(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
ta.Draw()
|
||||
|
||||
// send '*' as a rune on the plugin view
|
||||
ta.SendKey(tcell.KeyRune, '*', tcell.ModNone)
|
||||
|
||||
if ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should NOT open when '*' is pressed — only Ctrl+A should open it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPalette_OpensInTaskEdit(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", taskpkg.StatusReady, taskpkg.TypeStory); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
|
||||
ta.NavController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
|
||||
TaskID: "TIKI-1",
|
||||
Focus: model.EditFieldTitle,
|
||||
}))
|
||||
ta.Draw()
|
||||
|
||||
// Ctrl+A should open the palette even in task edit
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should open when Ctrl+A is pressed in task edit view")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestActionPalette_OpensWithInputBoxFocused(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
|
||||
ta.Draw()
|
||||
|
||||
// open search to focus input box
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
|
||||
v := ta.NavController.GetActiveView()
|
||||
iv, ok := v.(controller.InputableView)
|
||||
if !ok || !iv.IsInputBoxFocused() {
|
||||
t.Fatal("input box should be focused after '/'")
|
||||
}
|
||||
|
||||
// Ctrl+A should open the palette even with input box focused
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should open when Ctrl+A is pressed with input box focused")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
574
integration/input_action_test.go
Normal file
574
integration/input_action_test.go
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
package integration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/testutil"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
const inputActionWorkflow = `views:
|
||||
plugins:
|
||||
- name: InputTest
|
||||
key: "F4"
|
||||
lanes:
|
||||
- name: All
|
||||
columns: 1
|
||||
filter: select where status = "backlog" order by id
|
||||
actions:
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags=tags+[input()]
|
||||
input: string
|
||||
- key: "p"
|
||||
label: "Set points"
|
||||
action: update where id = id() set points=input()
|
||||
input: int
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: update where id = id() set status="ready"
|
||||
`
|
||||
|
||||
func setupInputActionTest(t *testing.T) *testutil.TestApp {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(inputActionWorkflow), 0644); err != nil {
|
||||
t.Fatalf("failed to write workflow.yaml: %v", err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("failed to chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
||||
|
||||
ta := testutil.NewTestApp(t)
|
||||
if err := ta.LoadPlugins(); err != nil {
|
||||
t.Fatalf("failed to load plugins: %v", err)
|
||||
}
|
||||
|
||||
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test Task", task.StatusBacklog, task.TypeStory); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
|
||||
ta.NavController.PushView(model.MakePluginViewID("InputTest"), nil)
|
||||
ta.Draw()
|
||||
|
||||
return ta
|
||||
}
|
||||
|
||||
func getActiveInputableView(ta *testutil.TestApp) controller.InputableView {
|
||||
v := ta.NavController.GetActiveView()
|
||||
iv, _ := v.(controller.InputableView)
|
||||
return iv
|
||||
}
|
||||
|
||||
func TestInputAction_KeyOpensPrompt(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
if iv == nil {
|
||||
t.Fatal("active view does not implement InputableView")
|
||||
}
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should not be visible initially")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should be visible after pressing 'A'")
|
||||
}
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("input box should be focused after pressing 'A'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_EnterAppliesMutation(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
ta.SendText("alice")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should be hidden after valid submit")
|
||||
}
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if updated.Assignee != "alice" {
|
||||
t.Fatalf("expected assignee=alice, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_EscCancelsWithoutMutation(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
ta.SendText("bob")
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should be hidden after Esc")
|
||||
}
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if updated.Assignee != "" {
|
||||
t.Fatalf("expected empty assignee after cancel, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_NonInputActionStillWorks(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
// 'b' is a non-input action — should execute immediately without prompt
|
||||
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("non-input action should not open input box")
|
||||
}
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if updated.Status != task.StatusReady {
|
||||
t.Fatalf("expected status ready, got %v", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_ModalBlocksOtherActions(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
// open action-input prompt
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
iv := getActiveInputableView(ta)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("input box should be focused")
|
||||
}
|
||||
|
||||
// while modal, 'b' should NOT execute the non-input action
|
||||
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated.Status != task.StatusBacklog {
|
||||
t.Fatalf("expected status backlog (action should be blocked while modal), got %v", updated.Status)
|
||||
}
|
||||
|
||||
// cancel and verify box closes
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should be hidden after Esc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_SearchPassiveBlocksNewSearch(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// open search
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("search box should be focused after '/'")
|
||||
}
|
||||
|
||||
// type and submit search
|
||||
ta.SendText("Test")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
// should be in passive mode: visible but not focused
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("search box should remain visible in passive mode")
|
||||
}
|
||||
if iv.IsInputBoxFocused() {
|
||||
t.Fatal("search box should not be focused in passive mode")
|
||||
}
|
||||
if !iv.IsSearchPassive() {
|
||||
t.Fatal("expected search-passive state")
|
||||
}
|
||||
|
||||
// pressing '/' again should NOT re-enter search editing
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
if iv.IsInputBoxFocused() {
|
||||
t.Fatal("'/' should be blocked while search is passive — user must Esc first")
|
||||
}
|
||||
|
||||
// Esc clears search and closes
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should be hidden after Esc from passive mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_PassiveSearchReplacedByActionInput(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// set up passive search
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
ta.SendText("Test")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
if !iv.IsSearchPassive() {
|
||||
t.Fatal("expected search-passive state")
|
||||
}
|
||||
|
||||
// action-input should temporarily replace the passive search
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("action-input should be focused, replacing passive search")
|
||||
}
|
||||
if iv.IsSearchPassive() {
|
||||
t.Fatal("should no longer be in search-passive while action-input is active")
|
||||
}
|
||||
|
||||
// submit action input
|
||||
ta.SendText("carol")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
// should restore passive search
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("passive search should be restored after action-input closes")
|
||||
}
|
||||
if !iv.IsSearchPassive() {
|
||||
t.Fatal("should be back in search-passive mode")
|
||||
}
|
||||
|
||||
// verify the mutation happened
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if updated.Assignee != "carol" {
|
||||
t.Fatalf("expected assignee=carol, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_ActionInputEscRestoresPassiveSearch(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// set up passive search
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
ta.SendText("Test")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
// open action-input
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
ta.SendText("dave")
|
||||
|
||||
// Esc should restore passive search, not clear it
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
|
||||
if !iv.IsSearchPassive() {
|
||||
t.Fatal("Esc from action-input should restore passive search")
|
||||
}
|
||||
|
||||
// verify no mutation
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated.Assignee != "" {
|
||||
t.Fatalf("expected empty assignee after cancel, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_SearchEditingBlocksPluginActions(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// open search
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("search box should be focused")
|
||||
}
|
||||
|
||||
// while search editing is active, 'b' (non-input action) should be blocked
|
||||
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated.Status != task.StatusBacklog {
|
||||
t.Fatalf("expected status backlog (action blocked during search editing), got %v", updated.Status)
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestInputAction_EmptySearchEnterIsNoOp(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("search box should be focused")
|
||||
}
|
||||
|
||||
// Enter on empty text should keep editing open
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("empty search Enter should keep box focused (no-op)")
|
||||
}
|
||||
if iv.IsSearchPassive() {
|
||||
t.Fatal("empty search Enter should not transition to passive")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestInputAction_PaletteOpensDuringModal(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
// open action-input
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
iv := getActiveInputableView(ta)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("input box should be focused")
|
||||
}
|
||||
|
||||
// Ctrl+A should open the palette even while input box is focused
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should open when Ctrl+A is pressed with input box focused")
|
||||
}
|
||||
|
||||
// clean up
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestInputAction_PaletteDispatchOpensPrompt(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// simulate palette dispatch: call HandleAction directly with the input-backed action ID
|
||||
actionID := controller.ActionID("plugin_action:A")
|
||||
ta.InputRouter.HandleAction(actionID, ta.NavController.CurrentView())
|
||||
ta.Draw()
|
||||
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("palette-dispatched input action should open the prompt")
|
||||
}
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("prompt should be focused after palette dispatch")
|
||||
}
|
||||
|
||||
// type and submit
|
||||
ta.SendText("eve")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("prompt should close after valid submit")
|
||||
}
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated.Assignee != "eve" {
|
||||
t.Fatalf("expected assignee=eve via palette dispatch, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_InvalidInputKeepsPromptOpen(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
originalTask := ta.TaskStore.GetTask("TIKI-1")
|
||||
originalPoints := originalTask.Points
|
||||
|
||||
// open int input (points)
|
||||
ta.SendKey(tcell.KeyRune, 'p', tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("prompt should be focused")
|
||||
}
|
||||
|
||||
// type non-numeric text and submit
|
||||
ta.SendText("abc")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
// prompt should stay open — invalid input
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("prompt should remain focused after invalid input")
|
||||
}
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("prompt should remain visible after invalid input")
|
||||
}
|
||||
|
||||
// verify no mutation
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated.Points != originalPoints {
|
||||
t.Fatalf("expected points=%d (unchanged), got %d", originalPoints, updated.Points)
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestInputAction_PreflightNoTaskSelected_NoPrompt(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// workflow with an empty lane (no tasks will match)
|
||||
workflow := `views:
|
||||
plugins:
|
||||
- name: EmptyTest
|
||||
key: "F4"
|
||||
lanes:
|
||||
- name: Empty
|
||||
columns: 1
|
||||
filter: select where status = "nonexistent" order by id
|
||||
actions:
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(workflow), 0644); err != nil {
|
||||
t.Fatalf("failed to write workflow.yaml: %v", err)
|
||||
}
|
||||
origDir, _ := os.Getwd()
|
||||
_ = os.Chdir(tmpDir)
|
||||
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
||||
|
||||
ta := testutil.NewTestApp(t)
|
||||
if err := ta.LoadPlugins(); err != nil {
|
||||
t.Fatalf("failed to load plugins: %v", err)
|
||||
}
|
||||
defer ta.Cleanup()
|
||||
|
||||
// create a task, but it won't match the filter
|
||||
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", task.StatusBacklog, task.TypeStory); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
|
||||
ta.NavController.PushView(model.MakePluginViewID("EmptyTest"), nil)
|
||||
ta.Draw()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// press 'A' — no task selected, preflight should fail, no prompt
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
if iv != nil && iv.IsInputBoxVisible() {
|
||||
t.Fatal("input prompt should not open when no task is selected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_DraftSearchSurvivesRefresh(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// open search and type (but don't submit)
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
ta.SendText("draft")
|
||||
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("search box should be focused")
|
||||
}
|
||||
|
||||
// simulate a store refresh (which triggers view rebuild)
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
ta.Draw()
|
||||
|
||||
// search box should still be visible after refresh
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("draft search should survive store refresh/rebuild")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestInputAction_AddTagMutation(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
ta.SendKey(tcell.KeyRune, 't', tcell.ModNone)
|
||||
ta.SendText("urgent")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
found := false
|
||||
for _, tag := range updated.Tags {
|
||||
if tag == "urgent" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected 'urgent' tag, got %v", updated.Tags)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,19 +4,22 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"github.com/boolean-maybe/tiki/view"
|
||||
)
|
||||
|
||||
// NewApp creates a tview application.
|
||||
func NewApp() *tview.Application {
|
||||
tview.Borders.HorizontalFocus = tview.Borders.Horizontal
|
||||
tview.Borders.VerticalFocus = tview.Borders.Vertical
|
||||
tview.Borders.TopLeftFocus = tview.Borders.TopLeft
|
||||
tview.Borders.TopRightFocus = tview.Borders.TopRight
|
||||
tview.Borders.BottomLeftFocus = tview.Borders.BottomLeft
|
||||
tview.Borders.BottomRightFocus = tview.Borders.BottomRight
|
||||
return tview.NewApplication()
|
||||
}
|
||||
|
||||
// Run runs the tview application.
|
||||
// Returns an error if the application fails to run.
|
||||
func Run(app *tview.Application, rootLayout *view.RootLayout) error {
|
||||
app.SetRoot(rootLayout.GetPrimitive(), true).EnableMouse(false)
|
||||
// Run runs the tview application with the given root primitive (typically a tview.Pages).
|
||||
func Run(app *tview.Application, root tview.Primitive) error {
|
||||
app.SetRoot(root, true).EnableMouse(false)
|
||||
if err := app.Run(); err != nil {
|
||||
return fmt.Errorf("run application: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,22 +9,27 @@ import (
|
|||
)
|
||||
|
||||
// InstallGlobalInputCapture installs the global keyboard handler
|
||||
// (header toggle, statusline auto-hide dismiss, router dispatch).
|
||||
// (palette modal short-circuit, statusline auto-hide dismiss, router dispatch).
|
||||
// F10 (toggle header) and * (open palette) are both routed through InputRouter
|
||||
// rather than handled here, so keyboard and palette-entered globals behave identically.
|
||||
func InstallGlobalInputCapture(
|
||||
app *tview.Application,
|
||||
headerConfig *model.HeaderConfig,
|
||||
paletteConfig *model.ActionPaletteConfig,
|
||||
statuslineConfig *model.StatuslineConfig,
|
||||
inputRouter *controller.InputRouter,
|
||||
navController *controller.NavigationController,
|
||||
) {
|
||||
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
// while the palette is visible, pass the event through unchanged so the
|
||||
// focused palette input field receives it. Do not dismiss statusline or
|
||||
// dispatch through InputRouter — the palette is modal.
|
||||
if paletteConfig != nil && paletteConfig.IsVisible() {
|
||||
return event
|
||||
}
|
||||
|
||||
// dismiss auto-hide statusline messages on any keypress
|
||||
statuslineConfig.DismissAutoHide()
|
||||
|
||||
if event.Key() == tcell.KeyF10 {
|
||||
headerConfig.ToggleUserPreference()
|
||||
return nil
|
||||
}
|
||||
if inputRouter.HandleInput(event, navController.CurrentView()) {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
|
|
@ -20,6 +21,7 @@ import (
|
|||
"github.com/boolean-maybe/tiki/util/sysinfo"
|
||||
"github.com/boolean-maybe/tiki/view"
|
||||
"github.com/boolean-maybe/tiki/view/header"
|
||||
"github.com/boolean-maybe/tiki/view/palette"
|
||||
"github.com/boolean-maybe/tiki/view/statusline"
|
||||
)
|
||||
|
||||
|
|
@ -47,6 +49,10 @@ type Result struct {
|
|||
StatuslineConfig *model.StatuslineConfig
|
||||
StatuslineWidget *statusline.StatuslineWidget
|
||||
RootLayout *view.RootLayout
|
||||
PaletteConfig *model.ActionPaletteConfig
|
||||
ActionPalette *palette.ActionPalette
|
||||
ViewContext *model.ViewContext
|
||||
AppRoot tview.Primitive // Pages root for app.SetRoot
|
||||
Context context.Context
|
||||
CancelFunc context.CancelFunc
|
||||
TikiSkillContent string
|
||||
|
|
@ -116,7 +122,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
return nil, err
|
||||
}
|
||||
InitPluginActionRegistry(plugins)
|
||||
syncHeaderPluginActions(headerConfig)
|
||||
viewContext := model.NewViewContext()
|
||||
pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins)
|
||||
|
||||
// Phase 6.5: Trigger system
|
||||
|
|
@ -163,11 +169,12 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
viewFactory.RegisterPlugin(name, cfg, def, ctrl)
|
||||
})
|
||||
|
||||
headerWidget := header.NewHeaderWidget(headerConfig)
|
||||
headerWidget := header.NewHeaderWidget(headerConfig, viewContext)
|
||||
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
|
||||
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
|
||||
Header: headerWidget,
|
||||
HeaderConfig: headerConfig,
|
||||
ViewContext: viewContext,
|
||||
LayoutModel: layoutModel,
|
||||
ViewFactory: viewFactory,
|
||||
TaskStore: taskStore,
|
||||
|
|
@ -184,9 +191,38 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
background.StartBurndownHistoryBuilder(ctx, tikiStore, headerConfig, application)
|
||||
triggerEngine.StartScheduler(ctx)
|
||||
|
||||
// Phase 11.5: Action palette
|
||||
paletteConfig := model.NewActionPaletteConfig()
|
||||
inputRouter.SetHeaderConfig(headerConfig)
|
||||
inputRouter.SetPaletteConfig(paletteConfig)
|
||||
|
||||
actionPalette := palette.NewActionPalette(viewContext, paletteConfig, inputRouter, controllers.Nav)
|
||||
actionPalette.SetChangedFunc()
|
||||
|
||||
// Build Pages root: base = rootLayout, overlay = palette
|
||||
pages := tview.NewPages()
|
||||
pages.AddPage("base", rootLayout.GetPrimitive(), true, true)
|
||||
paletteOverlay := buildPaletteOverlay(actionPalette)
|
||||
pages.AddPage("palette", paletteOverlay, true, false)
|
||||
|
||||
// Wire palette visibility to Pages show/hide and focus management
|
||||
var previousFocus tview.Primitive
|
||||
paletteConfig.AddListener(func() {
|
||||
if paletteConfig.IsVisible() {
|
||||
previousFocus = application.GetFocus()
|
||||
actionPalette.OnShow()
|
||||
pages.ShowPage("palette")
|
||||
application.SetFocus(actionPalette.GetFilterInput())
|
||||
} else {
|
||||
pages.HidePage("palette")
|
||||
restoreFocusAfterPalette(application, previousFocus, rootLayout)
|
||||
previousFocus = nil
|
||||
}
|
||||
})
|
||||
|
||||
// Phase 12: Navigation and input wiring
|
||||
wireNavigation(controllers.Nav, layoutModel, rootLayout)
|
||||
app.InstallGlobalInputCapture(application, headerConfig, statuslineConfig, inputRouter, controllers.Nav)
|
||||
app.InstallGlobalInputCapture(application, paletteConfig, statuslineConfig, inputRouter, controllers.Nav)
|
||||
|
||||
// Phase 13: Initial view — use the first plugin marked default: true,
|
||||
// or fall back to the first plugin in the list.
|
||||
|
|
@ -212,6 +248,10 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
StatuslineConfig: statuslineConfig,
|
||||
StatuslineWidget: statuslineWidget,
|
||||
RootLayout: rootLayout,
|
||||
PaletteConfig: paletteConfig,
|
||||
ActionPalette: actionPalette,
|
||||
ViewContext: viewContext,
|
||||
AppRoot: pages,
|
||||
Context: ctx,
|
||||
CancelFunc: cancel,
|
||||
TikiSkillContent: tikiSkillContent,
|
||||
|
|
@ -219,12 +259,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// syncHeaderPluginActions syncs plugin action shortcuts from the controller registry
|
||||
// into the header model.
|
||||
func syncHeaderPluginActions(headerConfig *model.HeaderConfig) {
|
||||
headerConfig.SetPluginActions(controller.GetPluginActions().ToHeaderActions())
|
||||
}
|
||||
|
||||
// wireOnViewActivated wires focus setters into views as they become active.
|
||||
func wireOnViewActivated(rootLayout *view.RootLayout, app *tview.Application) {
|
||||
rootLayout.SetOnViewActivated(func(v controller.View) {
|
||||
|
|
@ -246,6 +280,59 @@ func wireNavigation(navController *controller.NavigationController, layoutModel
|
|||
navController.SetActiveViewGetter(rootLayout.GetContentView)
|
||||
}
|
||||
|
||||
// paletteOverlayFlex is a Flex that recomputes the palette width on every draw
|
||||
// to maintain 1/3 terminal width with a minimum floor.
|
||||
type paletteOverlayFlex struct {
|
||||
*tview.Flex
|
||||
palette tview.Primitive
|
||||
spacer *tview.Flex
|
||||
lastPaletteSize int
|
||||
}
|
||||
|
||||
func buildPaletteOverlay(ap *palette.ActionPalette) *paletteOverlayFlex {
|
||||
overlay := &paletteOverlayFlex{
|
||||
Flex: tview.NewFlex(),
|
||||
palette: ap.GetPrimitive(),
|
||||
}
|
||||
overlay.spacer = tview.NewFlex()
|
||||
overlay.Flex.AddItem(overlay.spacer, 0, 1, false)
|
||||
overlay.Flex.AddItem(overlay.palette, palette.PaletteMinWidth, 0, true)
|
||||
overlay.lastPaletteSize = palette.PaletteMinWidth
|
||||
return overlay
|
||||
}
|
||||
|
||||
func (o *paletteOverlayFlex) Draw(screen tcell.Screen) {
|
||||
_, _, w, _ := o.GetRect()
|
||||
pw := w / 3
|
||||
if pw < palette.PaletteMinWidth {
|
||||
pw = palette.PaletteMinWidth
|
||||
}
|
||||
if pw != o.lastPaletteSize {
|
||||
o.Flex.Clear()
|
||||
o.Flex.AddItem(o.spacer, 0, 1, false)
|
||||
o.Flex.AddItem(o.palette, pw, 0, true)
|
||||
o.lastPaletteSize = pw
|
||||
}
|
||||
o.Flex.Draw(screen)
|
||||
}
|
||||
|
||||
// restoreFocusAfterPalette restores focus to the previously focused primitive,
|
||||
// falling back to FocusRestorer on the active view, then to the content view root.
|
||||
func restoreFocusAfterPalette(application *tview.Application, previousFocus tview.Primitive, rootLayout *view.RootLayout) {
|
||||
if previousFocus != nil {
|
||||
application.SetFocus(previousFocus)
|
||||
return
|
||||
}
|
||||
if contentView := rootLayout.GetContentView(); contentView != nil {
|
||||
if restorer, ok := contentView.(controller.FocusRestorer); ok {
|
||||
if restorer.RestoreFocus() {
|
||||
return
|
||||
}
|
||||
}
|
||||
application.SetFocus(contentView.GetPrimitive())
|
||||
}
|
||||
}
|
||||
|
||||
// InitColorAndGradientSupport collects system information, auto-corrects TERM if needed,
|
||||
// and initializes gradient support flags based on terminal color capabilities.
|
||||
// Returns the collected SystemInfo for use in bootstrap result.
|
||||
|
|
|
|||
14
main.go
14
main.go
|
|
@ -62,9 +62,9 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Handle config command
|
||||
if len(os.Args) > 1 && os.Args[1] == "config" {
|
||||
os.Exit(runConfig(os.Args[2:]))
|
||||
// Handle workflow command
|
||||
if len(os.Args) > 1 && os.Args[1] == "workflow" {
|
||||
os.Exit(runWorkflow(os.Args[2:]))
|
||||
}
|
||||
|
||||
// Handle exec command: execute ruki statement and exit
|
||||
|
|
@ -88,7 +88,7 @@ func main() {
|
|||
|
||||
// Handle viewer mode (standalone markdown viewer)
|
||||
// "init" is reserved to prevent treating it as a markdown file
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}, "config": {}})
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}, "workflow": {}})
|
||||
if err != nil {
|
||||
if errors.Is(err, viewer.ErrMultipleInputs) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
|
|
@ -126,10 +126,11 @@ func main() {
|
|||
defer result.App.Stop()
|
||||
defer result.HeaderWidget.Cleanup()
|
||||
defer result.RootLayout.Cleanup()
|
||||
defer result.ActionPalette.Cleanup()
|
||||
defer result.CancelFunc()
|
||||
|
||||
// Run application
|
||||
if err := app.Run(result.App, result.RootLayout); err != nil {
|
||||
if err := app.Run(result.App, result.AppRoot); err != nil {
|
||||
slog.Error("application error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -263,7 +264,8 @@ Usage:
|
|||
tiki Launch TUI in initialized repo
|
||||
tiki init Initialize project in current git repo
|
||||
tiki exec '<statement>' Execute a ruki query and exit
|
||||
tiki config reset [target] Reset config files (--global, --local, --current)
|
||||
tiki workflow reset [target] Reset config files (--global, --local, --current)
|
||||
tiki workflow install <name> Install a workflow (--global, --local, --current)
|
||||
tiki demo Clone demo project and launch TUI
|
||||
tiki file.md/URL View markdown file or image
|
||||
echo "Title" | tiki Create task from piped input
|
||||
|
|
|
|||
80
model/action_palette_config.go
Normal file
80
model/action_palette_config.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package model
|
||||
|
||||
import "sync"
|
||||
|
||||
// ActionPaletteConfig manages the visibility state of the action palette overlay.
|
||||
// The palette reads view metadata from ViewContext and action rows from live
|
||||
// controller registries — this config only tracks open/close state.
|
||||
type ActionPaletteConfig struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
visible bool
|
||||
|
||||
listeners map[int]func()
|
||||
nextListener int
|
||||
}
|
||||
|
||||
// NewActionPaletteConfig creates a new palette config (hidden by default).
|
||||
func NewActionPaletteConfig() *ActionPaletteConfig {
|
||||
return &ActionPaletteConfig{
|
||||
listeners: make(map[int]func()),
|
||||
nextListener: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// IsVisible returns whether the palette is currently visible.
|
||||
func (pc *ActionPaletteConfig) IsVisible() bool {
|
||||
pc.mu.RLock()
|
||||
defer pc.mu.RUnlock()
|
||||
return pc.visible
|
||||
}
|
||||
|
||||
// SetVisible sets the palette visibility and notifies listeners on change.
|
||||
func (pc *ActionPaletteConfig) SetVisible(visible bool) {
|
||||
pc.mu.Lock()
|
||||
changed := pc.visible != visible
|
||||
pc.visible = visible
|
||||
pc.mu.Unlock()
|
||||
if changed {
|
||||
pc.notifyListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// ToggleVisible toggles the palette visibility.
|
||||
func (pc *ActionPaletteConfig) ToggleVisible() {
|
||||
pc.mu.Lock()
|
||||
pc.visible = !pc.visible
|
||||
pc.mu.Unlock()
|
||||
pc.notifyListeners()
|
||||
}
|
||||
|
||||
// AddListener registers a callback for palette config changes.
|
||||
// Returns a listener ID for removal.
|
||||
func (pc *ActionPaletteConfig) AddListener(listener func()) int {
|
||||
pc.mu.Lock()
|
||||
defer pc.mu.Unlock()
|
||||
id := pc.nextListener
|
||||
pc.nextListener++
|
||||
pc.listeners[id] = listener
|
||||
return id
|
||||
}
|
||||
|
||||
// RemoveListener removes a previously registered listener by ID.
|
||||
func (pc *ActionPaletteConfig) RemoveListener(id int) {
|
||||
pc.mu.Lock()
|
||||
defer pc.mu.Unlock()
|
||||
delete(pc.listeners, id)
|
||||
}
|
||||
|
||||
func (pc *ActionPaletteConfig) notifyListeners() {
|
||||
pc.mu.RLock()
|
||||
listeners := make([]func(), 0, len(pc.listeners))
|
||||
for _, listener := range pc.listeners {
|
||||
listeners = append(listeners, listener)
|
||||
}
|
||||
pc.mu.RUnlock()
|
||||
|
||||
for _, listener := range listeners {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
87
model/action_palette_config_test.go
Normal file
87
model/action_palette_config_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActionPaletteConfig_DefaultHidden(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
if pc.IsVisible() {
|
||||
t.Error("palette should be hidden by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPaletteConfig_SetVisible(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
pc.SetVisible(true)
|
||||
if !pc.IsVisible() {
|
||||
t.Error("palette should be visible after SetVisible(true)")
|
||||
}
|
||||
pc.SetVisible(false)
|
||||
if pc.IsVisible() {
|
||||
t.Error("palette should be hidden after SetVisible(false)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPaletteConfig_ToggleVisible(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
pc.ToggleVisible()
|
||||
if !pc.IsVisible() {
|
||||
t.Error("palette should be visible after first toggle")
|
||||
}
|
||||
pc.ToggleVisible()
|
||||
if pc.IsVisible() {
|
||||
t.Error("palette should be hidden after second toggle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPaletteConfig_ListenerNotifiedOnChange(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
called := 0
|
||||
pc.AddListener(func() { called++ })
|
||||
|
||||
pc.SetVisible(true)
|
||||
if called != 1 {
|
||||
t.Errorf("listener should be called once on change, got %d", called)
|
||||
}
|
||||
|
||||
// no-op (already visible)
|
||||
pc.SetVisible(true)
|
||||
if called != 1 {
|
||||
t.Errorf("listener should not be called on no-op SetVisible, got %d", called)
|
||||
}
|
||||
|
||||
pc.SetVisible(false)
|
||||
if called != 2 {
|
||||
t.Errorf("listener should be called on hide, got %d", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPaletteConfig_ToggleAlwaysNotifies(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
called := 0
|
||||
pc.AddListener(func() { called++ })
|
||||
|
||||
pc.ToggleVisible()
|
||||
pc.ToggleVisible()
|
||||
if called != 2 {
|
||||
t.Errorf("expected 2 notifications from toggle, got %d", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPaletteConfig_RemoveListener(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
called := 0
|
||||
id := pc.AddListener(func() { called++ })
|
||||
|
||||
pc.SetVisible(true)
|
||||
if called != 1 {
|
||||
t.Errorf("expected 1 call, got %d", called)
|
||||
}
|
||||
|
||||
pc.RemoveListener(id)
|
||||
pc.SetVisible(false)
|
||||
if called != 1 {
|
||||
t.Errorf("expected no more calls after removal, got %d", called)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,17 +25,13 @@ type StatValue struct {
|
|||
Priority int
|
||||
}
|
||||
|
||||
// HeaderConfig manages ALL header state - both content AND visibility.
|
||||
// HeaderConfig manages header visibility and burndown state.
|
||||
// View identity and actions are now in ViewContext.
|
||||
// Thread-safe model that notifies listeners when state changes.
|
||||
type HeaderConfig struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// Content state
|
||||
viewActions []HeaderAction
|
||||
pluginActions []HeaderAction
|
||||
viewName string // current view name for info section
|
||||
viewDescription string // current view description for info section
|
||||
burndown []store.BurndownPoint
|
||||
burndown []store.BurndownPoint
|
||||
|
||||
// Visibility state
|
||||
visible bool // current header visibility (may be overridden by fullscreen view)
|
||||
|
|
@ -56,59 +52,6 @@ func NewHeaderConfig() *HeaderConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// SetViewActions updates the view-specific header actions
|
||||
func (hc *HeaderConfig) SetViewActions(actions []HeaderAction) {
|
||||
hc.mu.Lock()
|
||||
hc.viewActions = actions
|
||||
hc.mu.Unlock()
|
||||
hc.notifyListeners()
|
||||
}
|
||||
|
||||
// GetViewActions returns the current view's header actions
|
||||
func (hc *HeaderConfig) GetViewActions() []HeaderAction {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.viewActions
|
||||
}
|
||||
|
||||
// SetPluginActions updates the plugin navigation header actions
|
||||
func (hc *HeaderConfig) SetPluginActions(actions []HeaderAction) {
|
||||
hc.mu.Lock()
|
||||
hc.pluginActions = actions
|
||||
hc.mu.Unlock()
|
||||
hc.notifyListeners()
|
||||
}
|
||||
|
||||
// GetPluginActions returns the plugin navigation header actions
|
||||
func (hc *HeaderConfig) GetPluginActions() []HeaderAction {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.pluginActions
|
||||
}
|
||||
|
||||
// SetViewInfo sets the current view name and description for the header info section
|
||||
func (hc *HeaderConfig) SetViewInfo(name, description string) {
|
||||
hc.mu.Lock()
|
||||
hc.viewName = name
|
||||
hc.viewDescription = description
|
||||
hc.mu.Unlock()
|
||||
hc.notifyListeners()
|
||||
}
|
||||
|
||||
// GetViewName returns the current view name
|
||||
func (hc *HeaderConfig) GetViewName() string {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.viewName
|
||||
}
|
||||
|
||||
// GetViewDescription returns the current view description
|
||||
func (hc *HeaderConfig) GetViewDescription() string {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.viewDescription
|
||||
}
|
||||
|
||||
// SetBurndown updates the burndown chart data
|
||||
func (hc *HeaderConfig) SetBurndown(points []store.BurndownPoint) {
|
||||
hc.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func TestNewHeaderConfig(t *testing.T) {
|
||||
|
|
@ -17,7 +15,6 @@ func TestNewHeaderConfig(t *testing.T) {
|
|||
t.Fatal("NewHeaderConfig() returned nil")
|
||||
}
|
||||
|
||||
// Initial visibility should be true
|
||||
if !hc.IsVisible() {
|
||||
t.Error("initial IsVisible() = false, want true")
|
||||
}
|
||||
|
|
@ -26,125 +23,11 @@ func TestNewHeaderConfig(t *testing.T) {
|
|||
t.Error("initial GetUserPreference() = false, want true")
|
||||
}
|
||||
|
||||
// Initial collections should be empty
|
||||
if len(hc.GetViewActions()) != 0 {
|
||||
t.Error("initial GetViewActions() should be empty")
|
||||
}
|
||||
|
||||
if len(hc.GetPluginActions()) != 0 {
|
||||
t.Error("initial GetPluginActions() should be empty")
|
||||
}
|
||||
|
||||
if hc.GetViewName() != "" {
|
||||
t.Error("initial GetViewName() should be empty")
|
||||
}
|
||||
|
||||
if hc.GetViewDescription() != "" {
|
||||
t.Error("initial GetViewDescription() should be empty")
|
||||
}
|
||||
|
||||
if len(hc.GetBurndown()) != 0 {
|
||||
t.Error("initial GetBurndown() should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_ViewActions(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
actions := []HeaderAction{
|
||||
{
|
||||
ID: "action1",
|
||||
Key: tcell.KeyEnter,
|
||||
Label: "Enter",
|
||||
ShowInHeader: true,
|
||||
},
|
||||
{
|
||||
ID: "action2",
|
||||
Key: tcell.KeyEscape,
|
||||
Label: "Esc",
|
||||
ShowInHeader: true,
|
||||
},
|
||||
}
|
||||
|
||||
hc.SetViewActions(actions)
|
||||
|
||||
got := hc.GetViewActions()
|
||||
if len(got) != 2 {
|
||||
t.Errorf("len(GetViewActions()) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if got[0].ID != "action1" {
|
||||
t.Errorf("ViewActions[0].ID = %q, want %q", got[0].ID, "action1")
|
||||
}
|
||||
|
||||
if got[1].ID != "action2" {
|
||||
t.Errorf("ViewActions[1].ID = %q, want %q", got[1].ID, "action2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_PluginActions(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
actions := []HeaderAction{
|
||||
{
|
||||
ID: "plugin1",
|
||||
Rune: '1',
|
||||
Label: "Plugin 1",
|
||||
ShowInHeader: true,
|
||||
},
|
||||
}
|
||||
|
||||
hc.SetPluginActions(actions)
|
||||
|
||||
got := hc.GetPluginActions()
|
||||
if len(got) != 1 {
|
||||
t.Errorf("len(GetPluginActions()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].ID != "plugin1" {
|
||||
t.Errorf("PluginActions[0].ID = %q, want %q", got[0].ID, "plugin1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_ViewInfo(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
hc.SetViewInfo("Kanban", "Tasks moving through stages")
|
||||
|
||||
if got := hc.GetViewName(); got != "Kanban" {
|
||||
t.Errorf("GetViewName() = %q, want %q", got, "Kanban")
|
||||
}
|
||||
|
||||
if got := hc.GetViewDescription(); got != "Tasks moving through stages" {
|
||||
t.Errorf("GetViewDescription() = %q, want %q", got, "Tasks moving through stages")
|
||||
}
|
||||
|
||||
// update overwrites
|
||||
hc.SetViewInfo("Backlog", "Upcoming tasks")
|
||||
|
||||
if got := hc.GetViewName(); got != "Backlog" {
|
||||
t.Errorf("GetViewName() after update = %q, want %q", got, "Backlog")
|
||||
}
|
||||
|
||||
if got := hc.GetViewDescription(); got != "Upcoming tasks" {
|
||||
t.Errorf("GetViewDescription() after update = %q, want %q", got, "Upcoming tasks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_ViewInfoEmptyDescription(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
hc.SetViewInfo("Task Detail", "")
|
||||
|
||||
if got := hc.GetViewName(); got != "Task Detail" {
|
||||
t.Errorf("GetViewName() = %q, want %q", got, "Task Detail")
|
||||
}
|
||||
|
||||
if got := hc.GetViewDescription(); got != "" {
|
||||
t.Errorf("GetViewDescription() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_Burndown(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
|
|
@ -177,18 +60,15 @@ func TestHeaderConfig_Burndown(t *testing.T) {
|
|||
func TestHeaderConfig_Visibility(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Default should be visible
|
||||
if !hc.IsVisible() {
|
||||
t.Error("default IsVisible() = false, want true")
|
||||
}
|
||||
|
||||
// Set invisible
|
||||
hc.SetVisible(false)
|
||||
if hc.IsVisible() {
|
||||
t.Error("IsVisible() after SetVisible(false) = true, want false")
|
||||
}
|
||||
|
||||
// Set visible again
|
||||
hc.SetVisible(true)
|
||||
if !hc.IsVisible() {
|
||||
t.Error("IsVisible() after SetVisible(true) = false, want true")
|
||||
|
|
@ -198,12 +78,10 @@ func TestHeaderConfig_Visibility(t *testing.T) {
|
|||
func TestHeaderConfig_UserPreference(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Default preference should be true
|
||||
if !hc.GetUserPreference() {
|
||||
t.Error("default GetUserPreference() = false, want true")
|
||||
}
|
||||
|
||||
// Set preference
|
||||
hc.SetUserPreference(false)
|
||||
if hc.GetUserPreference() {
|
||||
t.Error("GetUserPreference() after SetUserPreference(false) = true, want false")
|
||||
|
|
@ -218,27 +96,21 @@ func TestHeaderConfig_UserPreference(t *testing.T) {
|
|||
func TestHeaderConfig_ToggleUserPreference(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Initial state
|
||||
initialPref := hc.GetUserPreference()
|
||||
initialVisible := hc.IsVisible()
|
||||
|
||||
// Toggle
|
||||
hc.ToggleUserPreference()
|
||||
|
||||
// Preference should be toggled
|
||||
if hc.GetUserPreference() == initialPref {
|
||||
t.Error("ToggleUserPreference() did not toggle preference")
|
||||
}
|
||||
|
||||
// Visible should match new preference
|
||||
if hc.IsVisible() != hc.GetUserPreference() {
|
||||
t.Error("visible state should match preference after toggle")
|
||||
}
|
||||
|
||||
// Toggle back
|
||||
hc.ToggleUserPreference()
|
||||
|
||||
// Should return to initial state
|
||||
if hc.GetUserPreference() != initialPref {
|
||||
t.Error("ToggleUserPreference() twice did not return to initial state")
|
||||
}
|
||||
|
|
@ -258,14 +130,10 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) {
|
|||
|
||||
listenerID := hc.AddListener(listener)
|
||||
|
||||
// Test various operations trigger notification
|
||||
tests := []struct {
|
||||
name string
|
||||
action func()
|
||||
}{
|
||||
{"SetViewActions", func() { hc.SetViewActions([]HeaderAction{{ID: "test"}}) }},
|
||||
{"SetPluginActions", func() { hc.SetPluginActions([]HeaderAction{{ID: "test"}}) }},
|
||||
{"SetViewInfo", func() { hc.SetViewInfo("Test", "desc") }},
|
||||
{"SetBurndown", func() { hc.SetBurndown([]store.BurndownPoint{{Date: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}}) }},
|
||||
{"SetVisible", func() { hc.SetVisible(false); hc.SetVisible(true) }},
|
||||
{"ToggleUserPreference", func() { hc.ToggleUserPreference() }},
|
||||
|
|
@ -284,11 +152,10 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// Remove listener
|
||||
hc.RemoveListener(listenerID)
|
||||
|
||||
called = false
|
||||
hc.SetViewActions([]HeaderAction{{ID: "test2"}})
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -305,8 +172,7 @@ func TestHeaderConfig_SetVisibleNoChangeNoNotify(t *testing.T) {
|
|||
callCount++
|
||||
})
|
||||
|
||||
// Set to current value (no change)
|
||||
hc.SetVisible(true) // Already true by default
|
||||
hc.SetVisible(true) // already true by default
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -314,7 +180,6 @@ func TestHeaderConfig_SetVisibleNoChangeNoNotify(t *testing.T) {
|
|||
t.Error("listener called when visibility didn't change")
|
||||
}
|
||||
|
||||
// Now change it
|
||||
hc.SetVisible(false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
|
@ -344,8 +209,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
|
|||
id1 := hc.AddListener(listener1)
|
||||
id2 := hc.AddListener(listener2)
|
||||
|
||||
// Both should be notified
|
||||
hc.SetViewInfo("Test", "desc")
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -355,10 +219,9 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
|
|||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Remove one
|
||||
hc.RemoveListener(id1)
|
||||
|
||||
hc.SetViewInfo("Test2", "desc2")
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -368,10 +231,9 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
|
|||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Remove second
|
||||
hc.RemoveListener(id2)
|
||||
|
||||
hc.SetViewInfo("Test3", "desc3")
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -387,24 +249,6 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
|
|||
|
||||
done := make(chan bool)
|
||||
|
||||
// Writer goroutine - actions
|
||||
go func() {
|
||||
for i := range 50 {
|
||||
hc.SetViewActions([]HeaderAction{{ID: string(rune('a' + i%26))}})
|
||||
hc.SetPluginActions([]HeaderAction{{ID: string(rune('A' + i%26))}})
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Writer goroutine - view info
|
||||
go func() {
|
||||
for i := range 50 {
|
||||
hc.SetViewInfo("View"+string(rune('a'+i%26)), "desc")
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Writer goroutine - visibility
|
||||
go func() {
|
||||
for i := range 50 {
|
||||
hc.SetVisible(i%2 == 0)
|
||||
|
|
@ -415,13 +259,15 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
|
|||
done <- true
|
||||
}()
|
||||
|
||||
// Reader goroutine
|
||||
go func() {
|
||||
for range 50 {
|
||||
hc.SetBurndown(nil)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for range 100 {
|
||||
_ = hc.GetViewActions()
|
||||
_ = hc.GetPluginActions()
|
||||
_ = hc.GetViewName()
|
||||
_ = hc.GetViewDescription()
|
||||
_ = hc.GetBurndown()
|
||||
_ = hc.IsVisible()
|
||||
_ = hc.GetUserPreference()
|
||||
|
|
@ -429,30 +275,14 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
|
|||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for all
|
||||
for range 4 {
|
||||
for range 3 {
|
||||
<-done
|
||||
}
|
||||
|
||||
// If we get here without panic, test passes
|
||||
}
|
||||
|
||||
func TestHeaderConfig_EmptyCollections(t *testing.T) {
|
||||
func TestHeaderConfig_EmptyBurndown(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Set empty actions
|
||||
hc.SetViewActions([]HeaderAction{})
|
||||
if len(hc.GetViewActions()) != 0 {
|
||||
t.Error("GetViewActions() should return empty slice")
|
||||
}
|
||||
|
||||
// Set nil actions
|
||||
hc.SetPluginActions(nil)
|
||||
if len(hc.GetPluginActions()) != 0 {
|
||||
t.Error("GetPluginActions() with nil input should return empty slice")
|
||||
}
|
||||
|
||||
// Set empty burndown
|
||||
hc.SetBurndown([]store.BurndownPoint{})
|
||||
if len(hc.GetBurndown()) != 0 {
|
||||
t.Error("GetBurndown() should return empty slice")
|
||||
|
|
|
|||
96
model/view_context.go
Normal file
96
model/view_context.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package model
|
||||
|
||||
import "sync"
|
||||
|
||||
// ViewContext holds the active view's identity and action DTOs.
|
||||
// Subscribed to by HeaderWidget and ActionPalette. Written by RootLayout
|
||||
// via syncViewContextFromView when the active view or its actions change.
|
||||
type ViewContext struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
viewID ViewID
|
||||
viewName string
|
||||
viewDescription string
|
||||
viewActions []HeaderAction
|
||||
pluginActions []HeaderAction
|
||||
|
||||
listeners map[int]func()
|
||||
nextListener int
|
||||
}
|
||||
|
||||
func NewViewContext() *ViewContext {
|
||||
return &ViewContext{
|
||||
listeners: make(map[int]func()),
|
||||
nextListener: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// SetFromView atomically updates all view-context fields and fires exactly one notification.
|
||||
func (vc *ViewContext) SetFromView(id ViewID, name, description string, viewActions, pluginActions []HeaderAction) {
|
||||
vc.mu.Lock()
|
||||
vc.viewID = id
|
||||
vc.viewName = name
|
||||
vc.viewDescription = description
|
||||
vc.viewActions = viewActions
|
||||
vc.pluginActions = pluginActions
|
||||
vc.mu.Unlock()
|
||||
vc.notifyListeners()
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewID() ViewID {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewID
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewName() string {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewName
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewDescription() string {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewDescription
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewActions() []HeaderAction {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewActions
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetPluginActions() []HeaderAction {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.pluginActions
|
||||
}
|
||||
|
||||
func (vc *ViewContext) AddListener(listener func()) int {
|
||||
vc.mu.Lock()
|
||||
defer vc.mu.Unlock()
|
||||
id := vc.nextListener
|
||||
vc.nextListener++
|
||||
vc.listeners[id] = listener
|
||||
return id
|
||||
}
|
||||
|
||||
func (vc *ViewContext) RemoveListener(id int) {
|
||||
vc.mu.Lock()
|
||||
defer vc.mu.Unlock()
|
||||
delete(vc.listeners, id)
|
||||
}
|
||||
|
||||
func (vc *ViewContext) notifyListeners() {
|
||||
vc.mu.RLock()
|
||||
listeners := make([]func(), 0, len(vc.listeners))
|
||||
for _, listener := range vc.listeners {
|
||||
listeners = append(listeners, listener)
|
||||
}
|
||||
vc.mu.RUnlock()
|
||||
|
||||
for _, listener := range listeners {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
93
model/view_context_test.go
Normal file
93
model/view_context_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestViewContext_SetFromView_SingleNotification(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
var count int32
|
||||
vc.AddListener(func() { atomic.AddInt32(&count, 1) })
|
||||
|
||||
vc.SetFromView("plugin:Kanban", "Kanban", "desc", nil, nil)
|
||||
|
||||
if got := atomic.LoadInt32(&count); got != 1 {
|
||||
t.Errorf("expected exactly 1 notification, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_Getters(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
|
||||
viewActions := []HeaderAction{{ID: "edit", Label: "Edit"}}
|
||||
pluginActions := []HeaderAction{{ID: "plugin:Kanban", Label: "Kanban"}}
|
||||
|
||||
vc.SetFromView(TaskDetailViewID, "Tiki Detail", "desc", viewActions, pluginActions)
|
||||
|
||||
if vc.GetViewID() != TaskDetailViewID {
|
||||
t.Errorf("expected %v, got %v", TaskDetailViewID, vc.GetViewID())
|
||||
}
|
||||
if vc.GetViewName() != "Tiki Detail" {
|
||||
t.Errorf("expected 'Tiki Detail', got %q", vc.GetViewName())
|
||||
}
|
||||
if vc.GetViewDescription() != "desc" {
|
||||
t.Errorf("expected 'desc', got %q", vc.GetViewDescription())
|
||||
}
|
||||
if len(vc.GetViewActions()) != 1 || vc.GetViewActions()[0].ID != "edit" {
|
||||
t.Errorf("unexpected view actions: %v", vc.GetViewActions())
|
||||
}
|
||||
if len(vc.GetPluginActions()) != 1 || vc.GetPluginActions()[0].ID != "plugin:Kanban" {
|
||||
t.Errorf("unexpected plugin actions: %v", vc.GetPluginActions())
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_RemoveListener(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
var count int32
|
||||
id := vc.AddListener(func() { atomic.AddInt32(&count, 1) })
|
||||
|
||||
vc.SetFromView("v1", "n", "d", nil, nil)
|
||||
if atomic.LoadInt32(&count) != 1 {
|
||||
t.Fatal("listener should have fired once")
|
||||
}
|
||||
|
||||
vc.RemoveListener(id)
|
||||
vc.SetFromView("v2", "n", "d", nil, nil)
|
||||
if atomic.LoadInt32(&count) != 1 {
|
||||
t.Error("listener should not fire after removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_MultipleListeners(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
var a, b int32
|
||||
vc.AddListener(func() { atomic.AddInt32(&a, 1) })
|
||||
vc.AddListener(func() { atomic.AddInt32(&b, 1) })
|
||||
|
||||
vc.SetFromView("v1", "n", "d", nil, nil)
|
||||
|
||||
if atomic.LoadInt32(&a) != 1 {
|
||||
t.Errorf("listener A: expected 1, got %d", atomic.LoadInt32(&a))
|
||||
}
|
||||
if atomic.LoadInt32(&b) != 1 {
|
||||
t.Errorf("listener B: expected 1, got %d", atomic.LoadInt32(&b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_ZeroValueIsEmpty(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
|
||||
if vc.GetViewID() != "" {
|
||||
t.Errorf("expected empty view ID, got %v", vc.GetViewID())
|
||||
}
|
||||
if vc.GetViewName() != "" {
|
||||
t.Errorf("expected empty view name, got %q", vc.GetViewName())
|
||||
}
|
||||
if vc.GetViewActions() != nil {
|
||||
t.Errorf("expected nil view actions, got %v", vc.GetViewActions())
|
||||
}
|
||||
if vc.GetPluginActions() != nil {
|
||||
t.Errorf("expected nil plugin actions, got %v", vc.GetPluginActions())
|
||||
}
|
||||
}
|
||||
|
|
@ -83,13 +83,18 @@ type PluginActionConfig struct {
|
|||
Key string `yaml:"key" mapstructure:"key"`
|
||||
Label string `yaml:"label" mapstructure:"label"`
|
||||
Action string `yaml:"action" mapstructure:"action"`
|
||||
Hot *bool `yaml:"hot,omitempty" mapstructure:"hot"`
|
||||
Input string `yaml:"input,omitempty" mapstructure:"input"`
|
||||
}
|
||||
|
||||
// PluginAction represents a parsed shortcut action bound to a key.
|
||||
type PluginAction struct {
|
||||
Rune rune
|
||||
Label string
|
||||
Action *ruki.ValidatedStatement
|
||||
Rune rune
|
||||
Label string
|
||||
Action *ruki.ValidatedStatement
|
||||
ShowInHeader bool
|
||||
InputType ruki.ValueType
|
||||
HasInput bool
|
||||
}
|
||||
|
||||
// PluginLaneConfig represents a lane in YAML or config definitions.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import (
|
|||
|
||||
// WorkflowFile represents the YAML structure of a workflow.yaml file
|
||||
type WorkflowFile struct {
|
||||
Views viewsSectionConfig `yaml:"views"`
|
||||
Version string `yaml:"version,omitempty"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Views viewsSectionConfig `yaml:"views"`
|
||||
}
|
||||
|
||||
// loadPluginsFromFile loads plugins from a single workflow.yaml file.
|
||||
|
|
@ -45,7 +47,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAct
|
|||
return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)}
|
||||
}
|
||||
|
||||
if len(wf.Views.Plugins) == 0 {
|
||||
if len(wf.Views.Plugins) == 0 && len(wf.Views.Actions) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +56,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAct
|
|||
for i := range wf.Views.Plugins {
|
||||
totalConverted += transformer.ConvertPluginConfig(&wf.Views.Plugins[i])
|
||||
}
|
||||
totalConverted += convertLegacyGlobalActions(transformer, wf.Views.Actions)
|
||||
if totalConverted > 0 {
|
||||
slog.Info("converted legacy workflow expressions to ruki", "count", totalConverted, "path", path)
|
||||
}
|
||||
|
|
@ -256,6 +259,27 @@ func mergeGlobalActionsIntoPlugins(plugins []Plugin, globalActions []PluginActio
|
|||
}
|
||||
}
|
||||
|
||||
// convertLegacyGlobalActions converts legacy action expressions in global views.actions
|
||||
// to ruki format, matching the same conversion applied to per-plugin actions.
|
||||
func convertLegacyGlobalActions(transformer *LegacyConfigTransformer, actions []PluginActionConfig) int {
|
||||
count := 0
|
||||
for i := range actions {
|
||||
action := &actions[i]
|
||||
if action.Action != "" && !isRukiAction(action.Action) {
|
||||
newAction, err := transformer.ConvertAction(action.Action)
|
||||
if err != nil {
|
||||
slog.Warn("failed to convert legacy global action, passing through",
|
||||
"error", err, "action", action.Action, "key", action.Key)
|
||||
continue
|
||||
}
|
||||
slog.Debug("converted legacy global action", "old", action.Action, "new", newAction, "key", action.Key)
|
||||
action.Action = newAction
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// DefaultPlugin returns the first plugin marked as default, or the first plugin
|
||||
// in the list if none are marked. The caller must ensure plugins is non-empty.
|
||||
func DefaultPlugin(plugins []Plugin) Plugin {
|
||||
|
|
|
|||
|
|
@ -206,14 +206,45 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl
|
|||
return nil, fmt.Errorf("action %d (key %q) missing 'action'", i, cfg.Key)
|
||||
}
|
||||
|
||||
actionStmt, err := parser.ParseAndValidateStatement(cfg.Action, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
|
||||
var (
|
||||
actionStmt *ruki.ValidatedStatement
|
||||
inputType ruki.ValueType
|
||||
hasInput bool
|
||||
)
|
||||
|
||||
if cfg.Input != "" {
|
||||
typ, err := ruki.ParseScalarTypeName(cfg.Input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("action %d (key %q) input: %w", i, cfg.Key, err)
|
||||
}
|
||||
actionStmt, err = parser.ParseAndValidateStatementWithInput(cfg.Action, ruki.ExecutorRuntimePlugin, typ)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
|
||||
}
|
||||
if !actionStmt.UsesInputBuiltin() {
|
||||
return nil, fmt.Errorf("action %d (key %q) declares 'input: %s' but does not use input()", i, cfg.Key, cfg.Input)
|
||||
}
|
||||
inputType = typ
|
||||
hasInput = true
|
||||
} else {
|
||||
var err error
|
||||
actionStmt, err = parser.ParseAndValidateStatement(cfg.Action, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
|
||||
}
|
||||
}
|
||||
|
||||
showInHeader := true
|
||||
if cfg.Hot != nil {
|
||||
showInHeader = *cfg.Hot
|
||||
}
|
||||
actions = append(actions, PluginAction{
|
||||
Rune: r,
|
||||
Label: cfg.Label,
|
||||
Action: actionStmt,
|
||||
Rune: r,
|
||||
Label: cfg.Label,
|
||||
Action: actionStmt,
|
||||
ShowInHeader: showInHeader,
|
||||
InputType: inputType,
|
||||
HasInput: hasInput,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -761,3 +761,168 @@ foreground: "#00ff00"
|
|||
t.Errorf("Expected URL, got %q", dokiPlugin.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_HotDefault(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !actions[0].ShowInHeader {
|
||||
t.Error("absent hot should default to ShowInHeader=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_HotExplicitFalse(t *testing.T) {
|
||||
parser := testParser()
|
||||
hotFalse := false
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotFalse},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if actions[0].ShowInHeader {
|
||||
t.Error("hot: false should set ShowInHeader=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_HotExplicitTrue(t *testing.T) {
|
||||
parser := testParser()
|
||||
hotTrue := true
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotTrue},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !actions[0].ShowInHeader {
|
||||
t.Error("hot: true should set ShowInHeader=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginYAML_HotFlagFromYAML(t *testing.T) {
|
||||
yamlData := []byte(`
|
||||
name: Test
|
||||
key: T
|
||||
lanes:
|
||||
- name: Backlog
|
||||
filter: select where status = "backlog"
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Board"
|
||||
action: update where id = id() set status = "ready"
|
||||
hot: false
|
||||
- key: "a"
|
||||
label: "Assign"
|
||||
action: update where id = id() set assignee = user()
|
||||
`)
|
||||
|
||||
p, err := parsePluginYAML(yamlData, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
tiki, ok := p.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("expected TikiPlugin, got %T", p)
|
||||
}
|
||||
|
||||
if tiki.Actions[0].ShowInHeader {
|
||||
t.Error("action with hot: false should have ShowInHeader=false")
|
||||
}
|
||||
if !tiki.Actions[1].ShowInHeader {
|
||||
t.Error("action without hot should default to ShowInHeader=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_InputValid(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "string"},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(actions) != 1 {
|
||||
t.Fatalf("expected 1 action, got %d", len(actions))
|
||||
}
|
||||
if !actions[0].HasInput {
|
||||
t.Error("expected HasInput=true")
|
||||
}
|
||||
if actions[0].InputType != ruki.ValueString {
|
||||
t.Errorf("expected InputType=ValueString, got %d", actions[0].InputType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_InputIntValid(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "p", Label: "Set points", Action: `update where id = id() set points=input()`, Input: "int"},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !actions[0].HasInput {
|
||||
t.Error("expected HasInput=true")
|
||||
}
|
||||
if actions[0].InputType != ruki.ValueInt {
|
||||
t.Errorf("expected InputType=ValueInt, got %d", actions[0].InputType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_InputTypeMismatch(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "int"},
|
||||
}
|
||||
_, err := parsePluginActions(configs, parser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for input type mismatch (int into string field)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_InputWithoutInputFunc(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "a", Label: "Ready", Action: `update where id = id() set status="ready"`, Input: "string"},
|
||||
}
|
||||
_, err := parsePluginActions(configs, parser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error: input: declared but input() not used")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not use input()") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_InputUnsupportedType(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "enum"},
|
||||
}
|
||||
_, err := parsePluginActions(configs, parser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported input type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_NoInputField_NoHasInput(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "a", Label: "Ready", Action: `update where id = id() set status="ready"`},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if actions[0].HasInput {
|
||||
t.Error("expected HasInput=false for action without input: field")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -780,6 +780,8 @@ func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*
|
|||
return e.evalNextDate(fc, t, allTasks)
|
||||
case "blocks":
|
||||
return e.evalBlocks(fc, t, allTasks)
|
||||
case "input":
|
||||
return e.evalInput()
|
||||
case "call":
|
||||
return nil, fmt.Errorf("call() is not supported yet")
|
||||
default:
|
||||
|
|
@ -787,6 +789,13 @@ func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*
|
|||
}
|
||||
}
|
||||
|
||||
func (e *Executor) evalInput() (interface{}, error) {
|
||||
if !e.currentInput.HasInput {
|
||||
return nil, &MissingInputValueError{}
|
||||
}
|
||||
return e.currentInput.InputValue, nil
|
||||
}
|
||||
|
||||
func (e *Executor) evalID() (interface{}, error) {
|
||||
if e.runtime.Mode != ExecutorRuntimePlugin {
|
||||
return nil, fmt.Errorf("id() is only available in plugin runtime")
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ func (r ExecutorRuntime) normalize() ExecutorRuntime {
|
|||
type ExecutionInput struct {
|
||||
SelectedTaskID string
|
||||
CreateTemplate *task.Task
|
||||
InputValue interface{} // value returned by input() builtin
|
||||
HasInput bool // distinguishes nil from unset
|
||||
}
|
||||
|
||||
// RuntimeMismatchError reports execution with a wrapper validated for a
|
||||
|
|
@ -68,6 +70,13 @@ func (e *MissingCreateTemplateError) Error() string {
|
|||
return "create template is required for create execution"
|
||||
}
|
||||
|
||||
// MissingInputValueError reports execution of input() without a provided value.
|
||||
type MissingInputValueError struct{}
|
||||
|
||||
func (e *MissingInputValueError) Error() string {
|
||||
return "input value is required when input() is used"
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrRuntimeMismatch is used with errors.Is for runtime mismatch failures.
|
||||
ErrRuntimeMismatch = errors.New("runtime mismatch")
|
||||
|
|
|
|||
275
ruki/input_builtin_test.go
Normal file
275
ruki/input_builtin_test.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestInputBuiltin_WithoutDeclaredType_ValidationError(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseAndValidateStatement("update where id = id() set assignee = input()", ExecutorRuntimePlugin)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for input() without declared type")
|
||||
}
|
||||
if got := err.Error(); got == "" {
|
||||
t.Fatal("expected non-empty error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_StringIntoStringField_OK(t *testing.T) {
|
||||
p := newTestParser()
|
||||
vs, err := p.ParseAndValidateStatementWithInput(
|
||||
"update where id = id() set assignee = input()",
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !vs.UsesInputBuiltin() {
|
||||
t.Fatal("expected UsesInputBuiltin() = true")
|
||||
}
|
||||
if !vs.UsesIDBuiltin() {
|
||||
t.Fatal("expected UsesIDBuiltin() = true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_IntIntoIntField_OK(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseAndValidateStatementWithInput(
|
||||
"update where id = id() set priority = input()",
|
||||
ExecutorRuntimePlugin,
|
||||
ValueInt,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_TypeMismatch_Error(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseAndValidateStatementWithInput(
|
||||
"update where id = id() set assignee = input()",
|
||||
ExecutorRuntimePlugin,
|
||||
ValueInt,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected type mismatch error for int input into string field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_DuplicateInput_Error(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseAndValidateStatementWithInput(
|
||||
"update where id = id() set assignee = input(), title = input()",
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate input()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_WithArguments_Error(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseAndValidateStatementWithInput(
|
||||
`update where id = id() set assignee = input("x")`,
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for input() with arguments")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_Executor_ReturnsValue(t *testing.T) {
|
||||
p := newTestParser()
|
||||
vs, err := p.ParseAndValidateStatementWithInput(
|
||||
"update where id = id() set assignee = input()",
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
e := NewExecutor(testSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimePlugin})
|
||||
|
||||
testTask := &task.Task{ID: "TIKI-000001", Title: "test", Status: "ready", Type: "task", Priority: 3}
|
||||
result, err := e.Execute(vs, []*task.Task{testTask}, ExecutionInput{
|
||||
SelectedTaskID: "TIKI-000001",
|
||||
InputValue: "bob",
|
||||
HasInput: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execution error: %v", err)
|
||||
}
|
||||
if result.Update == nil {
|
||||
t.Fatal("expected update result")
|
||||
}
|
||||
if len(result.Update.Updated) == 0 {
|
||||
t.Fatal("expected at least one updated task")
|
||||
}
|
||||
updated := result.Update.Updated[0]
|
||||
if updated.Assignee != "bob" {
|
||||
t.Fatalf("expected assignee = bob, got %v", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_Executor_MissingInput(t *testing.T) {
|
||||
p := newTestParser()
|
||||
vs, err := p.ParseAndValidateStatementWithInput(
|
||||
"update where id = id() set assignee = input()",
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
e := NewExecutor(testSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimePlugin})
|
||||
|
||||
testTask := &task.Task{ID: "TIKI-000001", Title: "test", Status: "ready", Type: "task", Priority: 3}
|
||||
_, err = e.Execute(vs, []*task.Task{testTask}, ExecutionInput{
|
||||
SelectedTaskID: "TIKI-000001",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing input")
|
||||
}
|
||||
var missingInput *MissingInputValueError
|
||||
if !errors.As(err, &missingInput) {
|
||||
t.Fatalf("expected MissingInputValueError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_InWhereClause_Detected(t *testing.T) {
|
||||
p := newTestParser()
|
||||
vs, err := p.ParseAndValidateStatementWithInput(
|
||||
`update where assignee = input() set status = "ready"`,
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !vs.UsesInputBuiltin() {
|
||||
t.Fatal("expected UsesInputBuiltin() = true for input() in where clause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_DuplicateAcrossWhereAndSet(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseAndValidateStatementWithInput(
|
||||
"update where assignee = input() set title = input()",
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate input() across where and set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_InSelectWhere_Detected(t *testing.T) {
|
||||
p := newTestParser()
|
||||
vs, err := p.ParseAndValidateStatementWithInput(
|
||||
`select where assignee = input()`,
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !vs.UsesInputBuiltin() {
|
||||
t.Fatal("expected UsesInputBuiltin() = true for input() in select where")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_InDeleteWhere_Detected(t *testing.T) {
|
||||
p := newTestParser()
|
||||
vs, err := p.ParseAndValidateStatementWithInput(
|
||||
`delete where assignee = input()`,
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !vs.UsesInputBuiltin() {
|
||||
t.Fatal("expected UsesInputBuiltin() = true for input() in delete where")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_InSubquery_Detected(t *testing.T) {
|
||||
p := newTestParser()
|
||||
vs, err := p.ParseAndValidateStatementWithInput(
|
||||
`select where count(select where assignee = input()) >= 1`,
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !vs.UsesInputBuiltin() {
|
||||
t.Fatal("expected UsesInputBuiltin() = true for input() inside subquery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_DuplicateAcrossWhereAndSubquery(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseAndValidateStatementWithInput(
|
||||
`select where assignee = input() and count(select where assignee = input()) >= 1`,
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate input() across where and subquery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_InPipeCommand_Detected(t *testing.T) {
|
||||
p := newTestParser()
|
||||
vs, err := p.ParseAndValidateStatementWithInput(
|
||||
`select id where id = id() | run(input())`,
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !vs.UsesInputBuiltin() {
|
||||
t.Fatal("expected UsesInputBuiltin() = true for input() in pipe command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_DuplicateAcrossWhereAndPipe(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseAndValidateStatementWithInput(
|
||||
`select id where assignee = input() | run(input())`,
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate input() across where and pipe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputBuiltin_InputTypeNotLeaked(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
_, err := p.ParseAndValidateStatementWithInput(
|
||||
"update where id = id() set assignee = input()",
|
||||
ExecutorRuntimePlugin,
|
||||
ValueString,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// after the call, inputType should be cleared — next parse without input should fail
|
||||
_, err = p.ParseAndValidateStatement("update where id = id() set assignee = input()", ExecutorRuntimePlugin)
|
||||
if err == nil {
|
||||
t.Fatal("expected error: inputType should not leak across calls")
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,6 @@ import (
|
|||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/util/duration"
|
||||
)
|
||||
|
||||
// lower.go converts participle grammar structs into clean AST types.
|
||||
|
|
@ -215,7 +212,7 @@ func lowerRule(g *ruleGrammar) (*Rule, error) {
|
|||
// --- time trigger lowering ---
|
||||
|
||||
func lowerTimeTrigger(g *timeTriggerGrammar) (*TimeTrigger, error) {
|
||||
val, unit, err := duration.Parse(g.Interval)
|
||||
val, unit, err := ParseDurationString(g.Interval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid interval: %w", err)
|
||||
}
|
||||
|
|
@ -481,7 +478,7 @@ func unquoteString(s string) string {
|
|||
}
|
||||
|
||||
func parseDateLiteral(s string) (Expr, error) {
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
t, err := ParseDateString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid date literal %q: %w", s, err)
|
||||
}
|
||||
|
|
@ -489,7 +486,7 @@ func parseDateLiteral(s string) (Expr, error) {
|
|||
}
|
||||
|
||||
func parseDurationLiteral(s string) (Expr, error) {
|
||||
val, unit, err := duration.Parse(s)
|
||||
val, unit, err := ParseDurationString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
102
ruki/parse_scalar.go
Normal file
102
ruki/parse_scalar.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/util/duration"
|
||||
)
|
||||
|
||||
const dateFormat = "2006-01-02"
|
||||
|
||||
// ParseDateString parses a YYYY-MM-DD date string into a time.Time.
|
||||
// Shared by DSL literal parsing (lower.go) and user input parsing.
|
||||
func ParseDateString(s string) (time.Time, error) {
|
||||
return time.Parse(dateFormat, s)
|
||||
}
|
||||
|
||||
// ParseDurationString parses a duration string like "2day" into its (value, unit) components.
|
||||
// Shared by DSL literal parsing (lower.go) and user input parsing.
|
||||
func ParseDurationString(s string) (int, string, error) {
|
||||
return duration.Parse(s)
|
||||
}
|
||||
|
||||
// ParseScalarTypeName maps a canonical scalar type name to a ValueType.
|
||||
// Only the 6 user-inputtable scalar types are accepted.
|
||||
func ParseScalarTypeName(name string) (ValueType, error) {
|
||||
switch strings.ToLower(name) {
|
||||
case "string":
|
||||
return ValueString, nil
|
||||
case "int":
|
||||
return ValueInt, nil
|
||||
case "bool":
|
||||
return ValueBool, nil
|
||||
case "date":
|
||||
return ValueDate, nil
|
||||
case "timestamp":
|
||||
return ValueTimestamp, nil
|
||||
case "duration":
|
||||
return ValueDuration, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported input type %q (supported: string, int, bool, date, timestamp, duration)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseScalarValue parses user-supplied text into a native runtime value
|
||||
// matching what the ruki executor produces for the given type.
|
||||
func ParseScalarValue(typ ValueType, text string) (interface{}, error) {
|
||||
switch typ {
|
||||
case ValueString:
|
||||
return text, nil
|
||||
|
||||
case ValueInt:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expected integer, got %q", text)
|
||||
}
|
||||
return n, nil
|
||||
|
||||
case ValueBool:
|
||||
b, err := parseBoolString(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expected true or false, got %q", text)
|
||||
}
|
||||
return b, nil
|
||||
|
||||
case ValueDate:
|
||||
t, err := ParseDateString(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expected date (YYYY-MM-DD), got %q", text)
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case ValueTimestamp:
|
||||
trimmed := strings.TrimSpace(text)
|
||||
t, err := time.Parse(time.RFC3339, trimmed)
|
||||
if err == nil {
|
||||
return t, nil
|
||||
}
|
||||
t, err = ParseDateString(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expected timestamp (RFC3339 or YYYY-MM-DD), got %q", text)
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case ValueDuration:
|
||||
trimmed := strings.TrimSpace(text)
|
||||
val, unit, err := ParseDurationString(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expected duration (e.g. 2day, 1week), got %q", text)
|
||||
}
|
||||
d, err := duration.ToDuration(val, unit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("type %s is not a supported input scalar", typeName(typ))
|
||||
}
|
||||
}
|
||||
180
ruki/parse_scalar_test.go
Normal file
180
ruki/parse_scalar_test.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseScalarTypeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want ValueType
|
||||
wantErr bool
|
||||
}{
|
||||
{"string", ValueString, false},
|
||||
{"int", ValueInt, false},
|
||||
{"bool", ValueBool, false},
|
||||
{"date", ValueDate, false},
|
||||
{"timestamp", ValueTimestamp, false},
|
||||
{"duration", ValueDuration, false},
|
||||
{"String", ValueString, false},
|
||||
{"INT", ValueInt, false},
|
||||
{"enum", 0, true},
|
||||
{"list", 0, true},
|
||||
{"", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := ParseScalarTypeName(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %q: %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("ParseScalarTypeName(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScalarValue_String(t *testing.T) {
|
||||
val, err := ParseScalarValue(ValueString, "hello world")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if val != "hello world" {
|
||||
t.Fatalf("got %v, want %q", val, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScalarValue_Int(t *testing.T) {
|
||||
val, err := ParseScalarValue(ValueInt, "42")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if val != 42 {
|
||||
t.Fatalf("got %v, want 42", val)
|
||||
}
|
||||
|
||||
val, err = ParseScalarValue(ValueInt, " 7 ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if val != 7 {
|
||||
t.Fatalf("got %v, want 7", val)
|
||||
}
|
||||
|
||||
_, err = ParseScalarValue(ValueInt, "abc")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-integer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScalarValue_Bool(t *testing.T) {
|
||||
val, err := ParseScalarValue(ValueBool, "true")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if val != true {
|
||||
t.Fatalf("got %v, want true", val)
|
||||
}
|
||||
|
||||
val, err = ParseScalarValue(ValueBool, "False")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if val != false {
|
||||
t.Fatalf("got %v, want false", val)
|
||||
}
|
||||
|
||||
_, err = ParseScalarValue(ValueBool, "yes")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-bool string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScalarValue_Date(t *testing.T) {
|
||||
val, err := ParseScalarValue(ValueDate, "2025-03-15")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tv, ok := val.(time.Time)
|
||||
if !ok {
|
||||
t.Fatalf("expected time.Time, got %T", val)
|
||||
}
|
||||
if tv.Year() != 2025 || tv.Month() != 3 || tv.Day() != 15 {
|
||||
t.Fatalf("got %v", tv)
|
||||
}
|
||||
|
||||
_, err = ParseScalarValue(ValueDate, "not-a-date")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScalarValue_Timestamp_RFC3339(t *testing.T) {
|
||||
val, err := ParseScalarValue(ValueTimestamp, "2025-03-15T10:30:00Z")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tv, ok := val.(time.Time)
|
||||
if !ok {
|
||||
t.Fatalf("expected time.Time, got %T", val)
|
||||
}
|
||||
if tv.Hour() != 10 || tv.Minute() != 30 {
|
||||
t.Fatalf("got %v", tv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScalarValue_Timestamp_DateFallback(t *testing.T) {
|
||||
val, err := ParseScalarValue(ValueTimestamp, "2025-03-15")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tv, ok := val.(time.Time)
|
||||
if !ok {
|
||||
t.Fatalf("expected time.Time, got %T", val)
|
||||
}
|
||||
if tv.Year() != 2025 || tv.Month() != 3 || tv.Day() != 15 {
|
||||
t.Fatalf("got %v", tv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScalarValue_Timestamp_Invalid(t *testing.T) {
|
||||
_, err := ParseScalarValue(ValueTimestamp, "yesterday")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScalarValue_Duration(t *testing.T) {
|
||||
val, err := ParseScalarValue(ValueDuration, "2day")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, ok := val.(time.Duration)
|
||||
if !ok {
|
||||
t.Fatalf("expected time.Duration, got %T", val)
|
||||
}
|
||||
if d != 2*24*time.Hour {
|
||||
t.Fatalf("got %v, want %v", d, 2*24*time.Hour)
|
||||
}
|
||||
|
||||
_, err = ParseScalarValue(ValueDuration, "abc")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScalarValue_UnsupportedType(t *testing.T) {
|
||||
_, err := ParseScalarValue(ValueEnum, "test")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported type")
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ type Parser struct {
|
|||
schema Schema
|
||||
qualifiers qualifierPolicy // set before each validation pass
|
||||
requireQualifiers bool // when true, bare FieldRef is a parse error (trigger where-guards)
|
||||
inputType *ValueType // set per-call for input() type inference; nil when not available
|
||||
}
|
||||
|
||||
// NewParser constructs a Parser with the given schema for validation.
|
||||
|
|
|
|||
|
|
@ -22,14 +22,16 @@ func (e *UnvalidatedWrapperError) Error() string {
|
|||
|
||||
// ValidatedStatement is an immutable, semantically validated statement wrapper.
|
||||
type ValidatedStatement struct {
|
||||
seal *validationSeal
|
||||
runtime ExecutorRuntimeMode
|
||||
usesIDFunc bool
|
||||
statement *Statement
|
||||
seal *validationSeal
|
||||
runtime ExecutorRuntimeMode
|
||||
usesIDFunc bool
|
||||
usesInputFunc bool
|
||||
statement *Statement
|
||||
}
|
||||
|
||||
func (v *ValidatedStatement) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
|
||||
func (v *ValidatedStatement) UsesIDBuiltin() bool { return v.usesIDFunc }
|
||||
func (v *ValidatedStatement) UsesInputBuiltin() bool { return v.usesInputFunc }
|
||||
func (v *ValidatedStatement) RequiresCreateTemplate() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Create != nil
|
||||
}
|
||||
|
|
@ -199,6 +201,21 @@ func (p *Parser) ParseAndValidateStatement(input string, runtime ExecutorRuntime
|
|||
return NewSemanticValidator(runtime).ValidateStatement(stmt)
|
||||
}
|
||||
|
||||
// ParseAndValidateStatementWithInput parses a statement with an input() type
|
||||
// declaration and applies runtime-aware semantic validation. The inputType is
|
||||
// set on the parser for the duration of the parse so that inferExprType can
|
||||
// resolve input() calls.
|
||||
func (p *Parser) ParseAndValidateStatementWithInput(input string, runtime ExecutorRuntimeMode, inputType ValueType) (*ValidatedStatement, error) {
|
||||
p.inputType = &inputType
|
||||
defer func() { p.inputType = nil }()
|
||||
|
||||
stmt, err := p.ParseStatement(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSemanticValidator(runtime).ValidateStatement(stmt)
|
||||
}
|
||||
|
||||
// ParseAndValidateTrigger parses an event trigger and applies runtime-aware semantic validation.
|
||||
func (p *Parser) ParseAndValidateTrigger(input string, runtime ExecutorRuntimeMode) (*ValidatedTrigger, error) {
|
||||
trig, err := p.ParseTrigger(input)
|
||||
|
|
@ -259,14 +276,22 @@ func (v *SemanticValidator) ValidateStatement(stmt *Statement) (*ValidatedStatem
|
|||
if usesID && v.runtime != ExecutorRuntimePlugin {
|
||||
return nil, fmt.Errorf("id() is only available in plugin runtime")
|
||||
}
|
||||
inputCount, err := countInputUsage(stmt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inputCount > 1 {
|
||||
return nil, fmt.Errorf("input() may only be used once per action")
|
||||
}
|
||||
if err := validateStatementAssignmentsSemantics(stmt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ValidatedStatement{
|
||||
seal: validatedSeal,
|
||||
runtime: v.runtime,
|
||||
usesIDFunc: usesID,
|
||||
statement: cloneStatement(stmt),
|
||||
seal: validatedSeal,
|
||||
runtime: v.runtime,
|
||||
usesIDFunc: usesID,
|
||||
usesInputFunc: inputCount == 1,
|
||||
statement: cloneStatement(stmt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -503,54 +528,199 @@ func scanConditionSemantics(cond Condition) (usesID bool, hasCall bool, err erro
|
|||
}
|
||||
}
|
||||
|
||||
type semanticFlags struct {
|
||||
usesID bool
|
||||
hasCall bool
|
||||
inputCount int
|
||||
}
|
||||
|
||||
func (f *semanticFlags) merge(other semanticFlags) {
|
||||
f.usesID = f.usesID || other.usesID
|
||||
f.hasCall = f.hasCall || other.hasCall
|
||||
f.inputCount += other.inputCount
|
||||
}
|
||||
|
||||
func scanExprSemantics(expr Expr) (usesID bool, hasCall bool, err error) {
|
||||
flags, err := scanExprSemanticsEx(expr)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return flags.usesID, flags.hasCall, nil
|
||||
}
|
||||
|
||||
func scanExprSemanticsEx(expr Expr) (semanticFlags, error) {
|
||||
var f semanticFlags
|
||||
if expr == nil {
|
||||
return false, false, nil
|
||||
return f, nil
|
||||
}
|
||||
switch e := expr.(type) {
|
||||
case *FunctionCall:
|
||||
if e.Name == "id" {
|
||||
usesID = true
|
||||
f.usesID = true
|
||||
}
|
||||
if e.Name == "call" {
|
||||
hasCall = true
|
||||
f.hasCall = true
|
||||
}
|
||||
if e.Name == "input" {
|
||||
f.inputCount++
|
||||
}
|
||||
for _, arg := range e.Args {
|
||||
u, c, err := scanExprSemantics(arg)
|
||||
af, err := scanExprSemanticsEx(arg)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
return f, err
|
||||
}
|
||||
usesID = usesID || u
|
||||
hasCall = hasCall || c
|
||||
f.merge(af)
|
||||
}
|
||||
return usesID, hasCall, nil
|
||||
return f, nil
|
||||
case *BinaryExpr:
|
||||
u1, c1, err := scanExprSemantics(e.Left)
|
||||
lf, err := scanExprSemanticsEx(e.Left)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
return f, err
|
||||
}
|
||||
u2, c2, err := scanExprSemantics(e.Right)
|
||||
rf, err := scanExprSemanticsEx(e.Right)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
return f, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
f.merge(lf)
|
||||
f.merge(rf)
|
||||
return f, nil
|
||||
case *ListLiteral:
|
||||
for _, elem := range e.Elements {
|
||||
u, c, err := scanExprSemantics(elem)
|
||||
ef, err := scanExprSemanticsEx(elem)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
return f, err
|
||||
}
|
||||
usesID = usesID || u
|
||||
hasCall = hasCall || c
|
||||
f.merge(ef)
|
||||
}
|
||||
return usesID, hasCall, nil
|
||||
return f, nil
|
||||
case *SubQuery:
|
||||
return scanConditionSemantics(e.Where)
|
||||
sf, err := scanConditionSemanticsEx(e.Where)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.merge(sf)
|
||||
return f, nil
|
||||
default:
|
||||
return false, false, nil
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
|
||||
func scanConditionSemanticsEx(cond Condition) (semanticFlags, error) {
|
||||
var f semanticFlags
|
||||
if cond == nil {
|
||||
return f, nil
|
||||
}
|
||||
switch c := cond.(type) {
|
||||
case *BinaryCondition:
|
||||
lf, err := scanConditionSemanticsEx(c.Left)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
rf, err := scanConditionSemanticsEx(c.Right)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.merge(lf)
|
||||
f.merge(rf)
|
||||
return f, nil
|
||||
case *NotCondition:
|
||||
return scanConditionSemanticsEx(c.Inner)
|
||||
case *CompareExpr:
|
||||
lf, err := scanExprSemanticsEx(c.Left)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
rf, err := scanExprSemanticsEx(c.Right)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.merge(lf)
|
||||
f.merge(rf)
|
||||
return f, nil
|
||||
case *IsEmptyExpr:
|
||||
return scanExprSemanticsEx(c.Expr)
|
||||
case *InExpr:
|
||||
vf, err := scanExprSemanticsEx(c.Value)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
cf, err := scanExprSemanticsEx(c.Collection)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.merge(vf)
|
||||
f.merge(cf)
|
||||
return f, nil
|
||||
case *QuantifierExpr:
|
||||
ef, err := scanExprSemanticsEx(c.Expr)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
cf, err := scanConditionSemanticsEx(c.Condition)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.merge(ef)
|
||||
f.merge(cf)
|
||||
return f, nil
|
||||
default:
|
||||
return f, fmt.Errorf("unknown condition type %T", c)
|
||||
}
|
||||
}
|
||||
|
||||
func countInputUsage(stmt *Statement) (int, error) {
|
||||
var total semanticFlags
|
||||
switch {
|
||||
case stmt.Select != nil:
|
||||
if stmt.Select.Where != nil {
|
||||
f, err := scanConditionSemanticsEx(stmt.Select.Where)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total.merge(f)
|
||||
}
|
||||
if stmt.Select.Pipe != nil && stmt.Select.Pipe.Run != nil {
|
||||
f, err := scanExprSemanticsEx(stmt.Select.Pipe.Run.Command)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total.merge(f)
|
||||
}
|
||||
case stmt.Create != nil:
|
||||
for _, a := range stmt.Create.Assignments {
|
||||
f, err := scanExprSemanticsEx(a.Value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total.merge(f)
|
||||
}
|
||||
case stmt.Update != nil:
|
||||
if stmt.Update.Where != nil {
|
||||
f, err := scanConditionSemanticsEx(stmt.Update.Where)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total.merge(f)
|
||||
}
|
||||
for _, a := range stmt.Update.Set {
|
||||
f, err := scanExprSemanticsEx(a.Value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total.merge(f)
|
||||
}
|
||||
case stmt.Delete != nil:
|
||||
if stmt.Delete.Where != nil {
|
||||
f, err := scanConditionSemanticsEx(stmt.Delete.Where)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total.merge(f)
|
||||
}
|
||||
}
|
||||
return total.inputCount, nil
|
||||
}
|
||||
|
||||
func cloneStatement(stmt *Statement) *Statement {
|
||||
if stmt == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -515,6 +515,16 @@ func (p *Parser) inferListElementType(e Expr) (ValueType, error) {
|
|||
}
|
||||
|
||||
func (p *Parser) inferFuncCallType(fc *FunctionCall) (ValueType, error) {
|
||||
if fc.Name == "input" {
|
||||
if len(fc.Args) != 0 {
|
||||
return 0, fmt.Errorf("input() takes no arguments, got %d", len(fc.Args))
|
||||
}
|
||||
if p.inputType == nil {
|
||||
return 0, fmt.Errorf("input() requires 'input:' declaration on action")
|
||||
}
|
||||
return *p.inputType, nil
|
||||
}
|
||||
|
||||
builtin, ok := builtinFuncs[fc.Name]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown function %q", fc.Name)
|
||||
|
|
|
|||
|
|
@ -121,10 +121,12 @@ func (s *TikiStore) loadTaskFile(path string, authorMap map[string]*git.AuthorIn
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// validate type strictly — missing or unknown types are load errors
|
||||
taskType, typeOK := taskpkg.ParseType(fm.Type)
|
||||
if !typeOK {
|
||||
return nil, fmt.Errorf("invalid or missing type %q", fm.Type)
|
||||
if fm.Type != "" {
|
||||
return nil, fmt.Errorf("unknown type %q", fm.Type)
|
||||
}
|
||||
taskType = taskpkg.DefaultType()
|
||||
}
|
||||
|
||||
task := &taskpkg.Task{
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
taskpkg "github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/view"
|
||||
"github.com/boolean-maybe/tiki/view/header"
|
||||
"github.com/boolean-maybe/tiki/view/palette"
|
||||
"github.com/boolean-maybe/tiki/view/statusline"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
|
@ -41,7 +42,11 @@ type TestApp struct {
|
|||
taskController *controller.TaskController
|
||||
statuslineConfig *model.StatuslineConfig
|
||||
headerConfig *model.HeaderConfig
|
||||
viewContext *model.ViewContext
|
||||
layoutModel *model.LayoutModel
|
||||
paletteConfig *model.ActionPaletteConfig
|
||||
actionPalette *palette.ActionPalette
|
||||
pages *tview.Pages
|
||||
}
|
||||
|
||||
// NewTestApp bootstraps the full MVC stack for integration testing.
|
||||
|
|
@ -113,11 +118,13 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
viewFactory := view.NewViewFactory(taskStore)
|
||||
|
||||
// 7. Create header widget, statusline, and RootLayout
|
||||
headerWidget := header.NewHeaderWidget(headerConfig)
|
||||
viewContext := model.NewViewContext()
|
||||
headerWidget := header.NewHeaderWidget(headerConfig, viewContext)
|
||||
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
|
||||
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
|
||||
Header: headerWidget,
|
||||
HeaderConfig: headerConfig,
|
||||
ViewContext: viewContext,
|
||||
LayoutModel: layoutModel,
|
||||
ViewFactory: viewFactory,
|
||||
TaskStore: taskStore,
|
||||
|
|
@ -126,9 +133,7 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
StatuslineWidget: statuslineWidget,
|
||||
})
|
||||
|
||||
// Mirror main.go wiring: provide views a focus setter as they become active.
|
||||
rootLayout.SetOnViewActivated(func(v controller.View) {
|
||||
// generic focus settable check (covers TaskEditView and any other view with focus needs)
|
||||
if focusSettable, ok := v.(controller.FocusSettable); ok {
|
||||
focusSettable.SetFocusSetter(func(p tview.Primitive) {
|
||||
app.SetFocus(p)
|
||||
|
|
@ -136,8 +141,6 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
}
|
||||
})
|
||||
|
||||
// IMPORTANT: Retroactively wire focus setter for any view already active
|
||||
// (RootLayout may have activated a view during construction before callback was set)
|
||||
currentView := rootLayout.GetContentView()
|
||||
if currentView != nil {
|
||||
if focusSettable, ok := currentView.(controller.FocusSettable); ok {
|
||||
|
|
@ -147,23 +150,59 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
}
|
||||
}
|
||||
|
||||
// 7.5 Action palette
|
||||
paletteConfig := model.NewActionPaletteConfig()
|
||||
inputRouter.SetHeaderConfig(headerConfig)
|
||||
inputRouter.SetPaletteConfig(paletteConfig)
|
||||
actionPalette := palette.NewActionPalette(viewContext, paletteConfig, inputRouter, navController)
|
||||
actionPalette.SetChangedFunc()
|
||||
|
||||
// Build Pages root
|
||||
pages := tview.NewPages()
|
||||
pages.AddPage("base", rootLayout.GetPrimitive(), true, true)
|
||||
paletteBox := tview.NewFlex()
|
||||
paletteBox.AddItem(tview.NewBox(), 0, 1, false)
|
||||
paletteBox.AddItem(actionPalette.GetPrimitive(), palette.PaletteMinWidth, 0, true)
|
||||
pages.AddPage("palette", paletteBox, true, false)
|
||||
|
||||
var previousFocus tview.Primitive
|
||||
paletteConfig.AddListener(func() {
|
||||
if paletteConfig.IsVisible() {
|
||||
previousFocus = app.GetFocus()
|
||||
actionPalette.OnShow()
|
||||
pages.ShowPage("palette")
|
||||
app.SetFocus(actionPalette.GetFilterInput())
|
||||
} else {
|
||||
pages.HidePage("palette")
|
||||
if previousFocus != nil {
|
||||
app.SetFocus(previousFocus)
|
||||
} else if cv := rootLayout.GetContentView(); cv != nil {
|
||||
app.SetFocus(cv.GetPrimitive())
|
||||
}
|
||||
previousFocus = nil
|
||||
}
|
||||
})
|
||||
|
||||
// 8. Wire up callbacks
|
||||
navController.SetOnViewChanged(func(viewID model.ViewID, params map[string]interface{}) {
|
||||
layoutModel.SetContent(viewID, params)
|
||||
})
|
||||
navController.SetActiveViewGetter(rootLayout.GetContentView)
|
||||
|
||||
// 9. Set up global input capture
|
||||
// 9. Set up global input capture (matches production)
|
||||
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
handled := inputRouter.HandleInput(event, navController.CurrentView())
|
||||
if handled {
|
||||
return nil // consume event
|
||||
if paletteConfig.IsVisible() {
|
||||
return event
|
||||
}
|
||||
return event // pass through
|
||||
statuslineConfig.DismissAutoHide()
|
||||
if inputRouter.HandleInput(event, navController.CurrentView()) {
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
// 10. Set root layout
|
||||
app.SetRoot(rootLayout.GetPrimitive(), true).EnableMouse(false)
|
||||
// 10. Set root (Pages)
|
||||
app.SetRoot(pages, true).EnableMouse(false)
|
||||
|
||||
// Note: Do NOT call app.Run() - we use app.Draw() + screen.Show() for synchronous testing
|
||||
|
||||
|
|
@ -181,7 +220,11 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
taskController: taskController,
|
||||
statuslineConfig: statuslineConfig,
|
||||
headerConfig: headerConfig,
|
||||
viewContext: viewContext,
|
||||
layoutModel: layoutModel,
|
||||
paletteConfig: paletteConfig,
|
||||
actionPalette: actionPalette,
|
||||
pages: pages,
|
||||
}
|
||||
|
||||
// 11. Auto-load plugins since all views are now plugins
|
||||
|
|
@ -194,10 +237,9 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
|
||||
// Draw forces a synchronous draw without running the app event loop
|
||||
func (ta *TestApp) Draw() {
|
||||
// Get screen dimensions and set the root layout's rect
|
||||
_, width, height := ta.Screen.GetContents()
|
||||
ta.RootLayout.GetPrimitive().SetRect(0, 0, width, height)
|
||||
ta.RootLayout.GetPrimitive().Draw(ta.Screen)
|
||||
ta.pages.SetRect(0, 0, width, height)
|
||||
ta.pages.Draw(ta.Screen)
|
||||
ta.Screen.Show()
|
||||
}
|
||||
|
||||
|
|
@ -313,9 +355,9 @@ func (ta *TestApp) DraftTask() *taskpkg.Task {
|
|||
|
||||
// Cleanup tears down the test app and releases resources
|
||||
func (ta *TestApp) Cleanup() {
|
||||
ta.actionPalette.Cleanup()
|
||||
ta.RootLayout.Cleanup()
|
||||
ta.Screen.Fini()
|
||||
// TaskDir cleanup handled automatically by t.TempDir()
|
||||
}
|
||||
|
||||
// LoadPlugins loads plugins from workflow.yaml files and wires them into the test app.
|
||||
|
|
@ -383,44 +425,19 @@ func (ta *TestApp) LoadPlugins() error {
|
|||
ta.statuslineConfig,
|
||||
ta.Schema,
|
||||
)
|
||||
ta.InputRouter.SetHeaderConfig(ta.headerConfig)
|
||||
ta.InputRouter.SetPaletteConfig(ta.paletteConfig)
|
||||
|
||||
// Update global input capture to handle plugin switching keys
|
||||
// Update global input capture (matches production pipeline)
|
||||
ta.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
// Check if search box has focus - if so, let it handle ALL input
|
||||
if activeView := ta.NavController.GetActiveView(); activeView != nil {
|
||||
if searchableView, ok := activeView.(controller.SearchableView); ok {
|
||||
if searchableView.IsSearchBoxFocused() {
|
||||
return event
|
||||
}
|
||||
}
|
||||
if ta.paletteConfig.IsVisible() {
|
||||
return event
|
||||
}
|
||||
|
||||
currentView := ta.NavController.CurrentView()
|
||||
if currentView != nil {
|
||||
// Handle plugin switching between plugins
|
||||
if model.IsPluginViewID(currentView.ViewID) {
|
||||
if action := controller.GetPluginActions().Match(event); action != nil {
|
||||
pluginName := controller.GetPluginNameFromAction(action.ID)
|
||||
if pluginName != "" {
|
||||
targetPluginID := model.MakePluginViewID(pluginName)
|
||||
// Don't switch to the same plugin we're already viewing
|
||||
if currentView.ViewID == targetPluginID {
|
||||
return nil // no-op
|
||||
}
|
||||
// Replace current plugin with target plugin
|
||||
ta.NavController.ReplaceView(targetPluginID, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
ta.statuslineConfig.DismissAutoHide()
|
||||
if ta.InputRouter.HandleInput(event, ta.NavController.CurrentView()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Let InputRouter handle the rest
|
||||
handled := ta.InputRouter.HandleInput(event, ta.NavController.CurrentView())
|
||||
if handled {
|
||||
return nil // consume event
|
||||
}
|
||||
return event // pass through
|
||||
return event
|
||||
})
|
||||
|
||||
// Update ViewFactory with plugins
|
||||
|
|
@ -441,13 +458,14 @@ func (ta *TestApp) LoadPlugins() error {
|
|||
})
|
||||
|
||||
// Recreate RootLayout with new view factory
|
||||
headerWidget := header.NewHeaderWidget(ta.headerConfig)
|
||||
headerWidget := header.NewHeaderWidget(ta.headerConfig, ta.viewContext)
|
||||
ta.RootLayout.Cleanup()
|
||||
slConfig := model.NewStatuslineConfig()
|
||||
slWidget := statusline.NewStatuslineWidget(slConfig)
|
||||
ta.RootLayout = view.NewRootLayout(view.RootLayoutOpts{
|
||||
Header: headerWidget,
|
||||
HeaderConfig: ta.headerConfig,
|
||||
ViewContext: ta.viewContext,
|
||||
LayoutModel: ta.layoutModel,
|
||||
ViewFactory: viewFactory,
|
||||
TaskStore: ta.TaskStore,
|
||||
|
|
@ -477,12 +495,35 @@ func (ta *TestApp) LoadPlugins() error {
|
|||
}
|
||||
}
|
||||
|
||||
// Set new root
|
||||
ta.App.SetRoot(ta.RootLayout.GetPrimitive(), true)
|
||||
// Update palette with new view context
|
||||
ta.actionPalette.Cleanup()
|
||||
ta.actionPalette = palette.NewActionPalette(ta.viewContext, ta.paletteConfig, ta.InputRouter, ta.NavController)
|
||||
ta.actionPalette.SetChangedFunc()
|
||||
|
||||
// Rebuild Pages
|
||||
ta.pages.RemovePage("palette")
|
||||
ta.pages.RemovePage("base")
|
||||
ta.pages.AddPage("base", ta.RootLayout.GetPrimitive(), true, true)
|
||||
paletteBox := tview.NewFlex()
|
||||
paletteBox.AddItem(tview.NewBox(), 0, 1, false)
|
||||
paletteBox.AddItem(ta.actionPalette.GetPrimitive(), palette.PaletteMinWidth, 0, true)
|
||||
ta.pages.AddPage("palette", paletteBox, true, false)
|
||||
|
||||
ta.App.SetRoot(ta.pages, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHeaderConfig returns the header config for testing visibility assertions.
|
||||
func (ta *TestApp) GetHeaderConfig() *model.HeaderConfig {
|
||||
return ta.headerConfig
|
||||
}
|
||||
|
||||
// GetPaletteConfig returns the palette config for testing visibility assertions.
|
||||
func (ta *TestApp) GetPaletteConfig() *model.ActionPaletteConfig {
|
||||
return ta.paletteConfig
|
||||
}
|
||||
|
||||
// GetPluginConfig retrieves the PluginConfig for a given plugin name.
|
||||
// Returns nil if the plugin is not loaded.
|
||||
func (ta *TestApp) GetPluginConfig(pluginName string) *model.PluginConfig {
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Single-line box drawing characters
|
||||
const (
|
||||
BorderHorizontal = '─'
|
||||
BorderVertical = '│'
|
||||
BorderTopLeft = '┌'
|
||||
BorderTopRight = '┐'
|
||||
BorderBottomLeft = '└'
|
||||
BorderBottomRight = '┘'
|
||||
)
|
||||
|
||||
// DrawSingleLineBorder draws a single-line border around the given rectangle
|
||||
// using the TaskBoxUnselectedBorder color from config.
|
||||
// This is useful for primitives that should not use tview's double-line focus borders.
|
||||
func DrawSingleLineBorder(screen tcell.Screen, x, y, width, height int) {
|
||||
if width <= 0 || height <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
colors := config.GetColors()
|
||||
style := tcell.StyleDefault.Foreground(colors.TaskBoxUnselectedBorder.TCell()).Background(colors.ContentBackgroundColor.TCell())
|
||||
|
||||
DrawSingleLineBorderWithStyle(screen, x, y, width, height, style)
|
||||
}
|
||||
|
||||
// DrawSingleLineBorderWithStyle draws a single-line border with a custom style
|
||||
func DrawSingleLineBorderWithStyle(screen tcell.Screen, x, y, width, height int, style tcell.Style) {
|
||||
if width <= 0 || height <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Draw horizontal lines
|
||||
for i := x + 1; i < x+width-1; i++ {
|
||||
screen.SetContent(i, y, BorderHorizontal, nil, style)
|
||||
screen.SetContent(i, y+height-1, BorderHorizontal, nil, style)
|
||||
}
|
||||
|
||||
// Draw vertical lines
|
||||
for i := y + 1; i < y+height-1; i++ {
|
||||
screen.SetContent(x, i, BorderVertical, nil, style)
|
||||
screen.SetContent(x+width-1, i, BorderVertical, nil, style)
|
||||
}
|
||||
|
||||
// Draw corners
|
||||
screen.SetContent(x, y, BorderTopLeft, nil, style)
|
||||
screen.SetContent(x+width-1, y, BorderTopRight, nil, style)
|
||||
screen.SetContent(x, y+height-1, BorderBottomLeft, nil, style)
|
||||
screen.SetContent(x+width-1, y+height-1, BorderBottomRight, nil, style)
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
|
|
@ -18,24 +17,16 @@ import (
|
|||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
//go:embed help/help.md
|
||||
var helpMd string
|
||||
|
||||
//go:embed help/tiki.md
|
||||
var tikiMd string
|
||||
|
||||
//go:embed help/custom.md
|
||||
var customMd string
|
||||
|
||||
// DokiView renders a documentation plugin (navigable markdown)
|
||||
type DokiView struct {
|
||||
root *tview.Flex
|
||||
titleBar tview.Primitive
|
||||
md *markdown.NavigableMarkdown
|
||||
pluginDef *plugin.DokiPlugin
|
||||
registry *controller.ActionRegistry
|
||||
imageManager *navtview.ImageManager
|
||||
mermaidOpts *nav.MermaidOptions
|
||||
root *tview.Flex
|
||||
titleBar tview.Primitive
|
||||
md *markdown.NavigableMarkdown
|
||||
pluginDef *plugin.DokiPlugin
|
||||
registry *controller.ActionRegistry
|
||||
imageManager *navtview.ImageManager
|
||||
mermaidOpts *nav.MermaidOptions
|
||||
actionChangeHandler func()
|
||||
}
|
||||
|
||||
// NewDokiView creates a doki view
|
||||
|
|
@ -93,13 +84,15 @@ func (dv *DokiView) build() {
|
|||
MermaidOptions: dv.mermaidOpts,
|
||||
})
|
||||
|
||||
// fetcher: internal is intentionally preserved as a supported pattern for
|
||||
// code-only internal docs, even though no default workflow currently uses it.
|
||||
//
|
||||
// The default Help plugin previously used this with embedded markdown:
|
||||
// cnt := map[string]string{"Help": helpMd, "tiki.md": tikiMd, "view.md": customMd}
|
||||
// provider := &internalDokiProvider{content: cnt}
|
||||
// That usage was replaced by the action palette (press Ctrl+A to open).
|
||||
case "internal":
|
||||
cnt := map[string]string{
|
||||
"Help": helpMd,
|
||||
"tiki.md": tikiMd,
|
||||
"view.md": customMd,
|
||||
}
|
||||
provider := &internalDokiProvider{content: cnt}
|
||||
provider := &internalDokiProvider{content: map[string]string{}}
|
||||
content, err = provider.FetchContent(nav.NavElement{Text: dv.pluginDef.Text})
|
||||
|
||||
dv.md = markdown.NewNavigableMarkdown(markdown.NavigableMarkdownConfig{
|
||||
|
|
@ -172,6 +165,10 @@ func (dv *DokiView) OnBlur() {
|
|||
}
|
||||
}
|
||||
|
||||
func (dv *DokiView) SetActionChangeHandler(handler func()) {
|
||||
dv.actionChangeHandler = handler
|
||||
}
|
||||
|
||||
// UpdateNavigationActions updates the registry to reflect current navigation state
|
||||
func (dv *DokiView) UpdateNavigationActions() {
|
||||
// Clear and rebuild the registry
|
||||
|
|
@ -212,6 +209,37 @@ func (dv *DokiView) UpdateNavigationActions() {
|
|||
ShowInHeader: true,
|
||||
})
|
||||
}
|
||||
|
||||
if dv.actionChangeHandler != nil {
|
||||
dv.actionChangeHandler()
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePaletteAction maps palette-dispatched actions to the markdown viewer's
|
||||
// existing key-driven behavior by replaying synthetic key events.
|
||||
func (dv *DokiView) HandlePaletteAction(id controller.ActionID) bool {
|
||||
if dv.md == nil {
|
||||
return false
|
||||
}
|
||||
var event *tcell.EventKey
|
||||
switch id {
|
||||
case "navigate_next_link":
|
||||
event = tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)
|
||||
case "navigate_prev_link":
|
||||
event = tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone)
|
||||
case controller.ActionNavigateBack:
|
||||
event = tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)
|
||||
case controller.ActionNavigateForward:
|
||||
event = tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
handler := dv.md.Viewer().InputHandler()
|
||||
if handler != nil {
|
||||
handler(event, nil)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// internalDokiProvider implements navidown.ContentProvider for embedded/internal docs.
|
||||
|
|
|
|||
|
|
@ -55,20 +55,23 @@ type HeaderWidget struct {
|
|||
rightSpacer *tview.Box
|
||||
logo *tview.TextView
|
||||
|
||||
// Model reference
|
||||
headerConfig *model.HeaderConfig
|
||||
listenerID int
|
||||
// Model references
|
||||
headerConfig *model.HeaderConfig
|
||||
viewContext *model.ViewContext
|
||||
headerListenerID int
|
||||
viewContextListenerID int
|
||||
|
||||
// Layout state
|
||||
lastWidth int
|
||||
chartVisible bool
|
||||
}
|
||||
|
||||
// NewHeaderWidget creates a header widget that observes HeaderConfig for all state
|
||||
func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget {
|
||||
// NewHeaderWidget creates a header widget that observes HeaderConfig (burndown/visibility)
|
||||
// and ViewContext (view info + actions) for state.
|
||||
func NewHeaderWidget(headerConfig *model.HeaderConfig, viewContext *model.ViewContext) *HeaderWidget {
|
||||
info := NewInfoWidget()
|
||||
contextHelp := NewContextHelpWidget()
|
||||
chart := NewChartWidgetSimple() // No store dependency, data comes from HeaderConfig
|
||||
chart := NewChartWidgetSimple()
|
||||
|
||||
logo := tview.NewTextView()
|
||||
logo.SetDynamicColors(true)
|
||||
|
|
@ -87,29 +90,27 @@ func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget {
|
|||
chart: chart,
|
||||
rightSpacer: tview.NewBox(),
|
||||
headerConfig: headerConfig,
|
||||
viewContext: viewContext,
|
||||
}
|
||||
|
||||
// Subscribe to header config changes
|
||||
hw.listenerID = headerConfig.AddListener(hw.rebuild)
|
||||
hw.headerListenerID = headerConfig.AddListener(hw.rebuild)
|
||||
if viewContext != nil {
|
||||
hw.viewContextListenerID = viewContext.AddListener(hw.rebuild)
|
||||
}
|
||||
|
||||
hw.rebuild()
|
||||
hw.rebuildLayout(0)
|
||||
return hw
|
||||
}
|
||||
|
||||
// rebuild reads all data from HeaderConfig and updates display
|
||||
// rebuild reads data from ViewContext (view info + actions) and HeaderConfig (burndown)
|
||||
func (h *HeaderWidget) rebuild() {
|
||||
// Update view info from HeaderConfig
|
||||
h.info.SetViewInfo(h.headerConfig.GetViewName(), h.headerConfig.GetViewDescription())
|
||||
if h.viewContext != nil {
|
||||
h.info.SetViewInfo(h.viewContext.GetViewName(), h.viewContext.GetViewDescription())
|
||||
h.contextHelp.SetActionsFromModel(h.viewContext.GetViewActions(), h.viewContext.GetPluginActions())
|
||||
}
|
||||
|
||||
// Update burndown chart from HeaderConfig
|
||||
burndown := h.headerConfig.GetBurndown()
|
||||
h.chart.UpdateBurndown(burndown)
|
||||
|
||||
// Update context help from HeaderConfig
|
||||
viewActions := h.headerConfig.GetViewActions()
|
||||
pluginActions := h.headerConfig.GetPluginActions()
|
||||
h.contextHelp.SetActionsFromModel(viewActions, pluginActions)
|
||||
h.chart.UpdateBurndown(h.headerConfig.GetBurndown())
|
||||
|
||||
if h.lastWidth > 0 {
|
||||
h.rebuildLayout(h.lastWidth)
|
||||
|
|
@ -125,9 +126,12 @@ func (h *HeaderWidget) Draw(screen tcell.Screen) {
|
|||
h.Flex.Draw(screen)
|
||||
}
|
||||
|
||||
// Cleanup removes the listener from HeaderConfig
|
||||
// Cleanup removes all listeners
|
||||
func (h *HeaderWidget) Cleanup() {
|
||||
h.headerConfig.RemoveListener(h.listenerID)
|
||||
h.headerConfig.RemoveListener(h.headerListenerID)
|
||||
if h.viewContext != nil {
|
||||
h.viewContext.RemoveListener(h.viewContextListenerID)
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildLayout recalculates and rebuilds the flex layout based on terminal width.
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ func TestCalculateHeaderLayout_chartHiddenContextFillsAvailable(t *testing.T) {
|
|||
|
||||
func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) {
|
||||
headerConfig := model.NewHeaderConfig()
|
||||
h := NewHeaderWidget(headerConfig)
|
||||
h := NewHeaderWidget(headerConfig, model.NewViewContext())
|
||||
defer h.Cleanup()
|
||||
|
||||
h.contextHelp.width = 10
|
||||
|
|
@ -154,7 +154,7 @@ func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) {
|
|||
|
||||
func TestHeaderWidget_chartVisibilityThreshold_growsWithContextHelp(t *testing.T) {
|
||||
headerConfig := model.NewHeaderConfig()
|
||||
h := NewHeaderWidget(headerConfig)
|
||||
h := NewHeaderWidget(headerConfig, model.NewViewContext())
|
||||
defer h.Cleanup()
|
||||
|
||||
h.contextHelp.width = 60
|
||||
|
|
|
|||
|
|
@ -1,249 +0,0 @@
|
|||
# Customization
|
||||
|
||||
First of all, you just navigated to a linked file. To go back press `Left` arrow or `Alt-Left`
|
||||
To go forward press `Right` arrow or `Alt-Right`
|
||||
|
||||
tiki is highly customizable. `workflow.yaml` lets you define your workflow statuses and configure views (plugins) for how tikis are displayed and organized. Statuses define the lifecycle stages your tasks move through, while plugins control what you see and how you interact with your work. This section covers both.
|
||||
|
||||
## Statuses
|
||||
|
||||
Workflow statuses are defined in `workflow.yaml` under the `statuses:` key. Every tiki project must define its statuses here — there is no hardcoded fallback. The default `workflow.yaml` ships with:
|
||||
|
||||
```yaml
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
emoji: "📥"
|
||||
default: true
|
||||
- key: ready
|
||||
label: Ready
|
||||
emoji: "📋"
|
||||
active: true
|
||||
- key: in_progress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
- key: review
|
||||
label: Review
|
||||
emoji: "👀"
|
||||
active: true
|
||||
- key: done
|
||||
label: Done
|
||||
emoji: "✅"
|
||||
done: true
|
||||
```
|
||||
|
||||
Each status has:
|
||||
- `key` — canonical identifier (lowercase, underscores). Used in filters, actions, and frontmatter.
|
||||
- `label` — display name shown in the UI
|
||||
- `emoji` — emoji shown alongside the label
|
||||
- `active` — marks the status as "active work" (used for burndown charts and activity tracking)
|
||||
- `default` — the status assigned to new tikis (exactly one status should have this)
|
||||
- `done` — marks the status as "completed" (used for completion tracking)
|
||||
|
||||
You can customize these to match your team's workflow. All filters and actions in view definitions must reference valid status keys.
|
||||
|
||||
## Plugins
|
||||
|
||||
tiki TUI app is much like a lego - everything is a customizable view. Here is, for example,
|
||||
how Backlog is defined:
|
||||
|
||||
```yaml
|
||||
views:
|
||||
plugins:
|
||||
- name: Backlog
|
||||
key: "F3"
|
||||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: select where status = "backlog" and type != "epic" order by priority, id
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: update where id = id() set status="ready"
|
||||
```
|
||||
|
||||
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
|
||||
The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready`
|
||||
You define the name, description, hotkey, and `ruki` expressions for filtering and actions. Save this into a `workflow.yaml` file in the config directory
|
||||
|
||||
Likewise the documentation is just a plugin:
|
||||
|
||||
```yaml
|
||||
views:
|
||||
plugins:
|
||||
- name: Docs
|
||||
type: doki
|
||||
fetcher: file
|
||||
url: "index.md"
|
||||
key: "F2"
|
||||
```
|
||||
|
||||
that translates to - show `index.md` file located under `.doc/doki`
|
||||
installed in the same way
|
||||
|
||||
### Multi-lane plugin
|
||||
|
||||
Backlog is a pretty simple plugin in that it displays all tikis in a single lane. Multi-lane tiki plugins offer functionality
|
||||
similar to that of the board. You can define multiple lanes per view and move tikis around with Shift-Left/Shift-Right
|
||||
much like in the board. You can create a multi-lane plugin by defining multiple lanes in its definition and assigning
|
||||
actions to each lane. An action defines what happens when you move a tiki into the lane. Here is a multi-lane plugin
|
||||
definition that roughly mimics the board:
|
||||
|
||||
```yaml
|
||||
name: Custom
|
||||
key: "F4"
|
||||
lanes:
|
||||
- name: Ready
|
||||
columns: 1
|
||||
width: 20
|
||||
filter: select where status = "ready" order by priority, title
|
||||
action: update where id = id() set status="ready"
|
||||
- name: In Progress
|
||||
columns: 1
|
||||
width: 30
|
||||
filter: select where status = "inProgress" order by priority, title
|
||||
action: update where id = id() set status="inProgress"
|
||||
- name: Review
|
||||
columns: 1
|
||||
width: 30
|
||||
filter: select where status = "review" order by priority, title
|
||||
action: update where id = id() set status="review"
|
||||
- name: Done
|
||||
columns: 1
|
||||
width: 20
|
||||
filter: select where status = "done" order by priority, title
|
||||
action: update where id = id() set status="done"
|
||||
```
|
||||
|
||||
### Lane width
|
||||
|
||||
Each lane can optionally specify a `width` as a percentage (1-100) to control how much horizontal space it occupies. Widths are relative proportions — they don't need to sum to 100. If width is omitted, the lane gets an equal share of the remaining space.
|
||||
|
||||
```yaml
|
||||
lanes:
|
||||
- name: Sidebar
|
||||
width: 25
|
||||
- name: Main
|
||||
width: 50
|
||||
- name: Details
|
||||
width: 25
|
||||
```
|
||||
|
||||
If no lanes specify width, all lanes are equally sized (the default behavior).
|
||||
|
||||
### Global plugin actions
|
||||
|
||||
You can define actions under `views.actions` that are available in **all** tiki plugin views:
|
||||
|
||||
```yaml
|
||||
views:
|
||||
actions:
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: update where id = id() set assignee=user()
|
||||
plugins:
|
||||
- name: Kanban
|
||||
...
|
||||
```
|
||||
|
||||
Global actions appear in the header alongside per-plugin actions. If a per-plugin action uses the same key, the per-plugin action takes precedence for that view. When multiple workflow files define `views.actions`, they merge by key — later files override same-keyed globals from earlier files.
|
||||
|
||||
### Per-plugin actions
|
||||
|
||||
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
|
||||
that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active.
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: update where id = id() set status="ready"
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: update where id = id() set assignee=user()
|
||||
```
|
||||
|
||||
Each action has:
|
||||
- `key` - a single printable character used as the keyboard shortcut
|
||||
- `label` - description shown in the header
|
||||
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
|
||||
|
||||
When the shortcut key is pressed, the action is applied to the currently selected tiki.
|
||||
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
|
||||
|
||||
`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki.
|
||||
|
||||
### ruki expressions
|
||||
|
||||
Plugin filters, lane actions, and plugin actions all use the `ruki` language. Filters use `select` statements. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored).
|
||||
|
||||
#### Filter (select)
|
||||
|
||||
The `filter` field uses a `ruki` `select` statement to determine which tikis appear in a lane. Sorting is part of the select — use `order by` to control display order.
|
||||
|
||||
```sql
|
||||
-- basic filter with sort
|
||||
select where status = "backlog" and type != "epic" order by priority, id
|
||||
|
||||
-- recent items, most recent first
|
||||
select where now() - updatedAt < 24hour order by updatedAt desc
|
||||
|
||||
-- multiple conditions
|
||||
select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
|
||||
-- assigned to me
|
||||
select where assignee = user() order by priority
|
||||
```
|
||||
|
||||
#### Action (update)
|
||||
|
||||
The `action` field uses a `ruki` `update` statement. In plugin context, `id()` refers to the currently selected tiki.
|
||||
|
||||
```sql
|
||||
-- set status on move
|
||||
update where id = id() set status="ready"
|
||||
|
||||
-- set multiple fields
|
||||
update where id = id() set status="backlog" priority=2
|
||||
|
||||
-- assign to current user
|
||||
update where id = id() set assignee=user()
|
||||
```
|
||||
|
||||
#### Supported fields
|
||||
|
||||
- `id` - task identifier (e.g., "TIKI-M7N2XK")
|
||||
- `title` - task title text
|
||||
- `type` - task type (must match a key defined in `workflow.yaml` types)
|
||||
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
|
||||
- `assignee` - assigned user
|
||||
- `priority` - numeric priority value (1-5)
|
||||
- `points` - story points estimate
|
||||
- `tags` - list of tags
|
||||
- `dependsOn` - list of dependency tiki IDs
|
||||
- `due` - due date (YYYY-MM-DD format)
|
||||
- `recurrence` - recurrence pattern (cron format)
|
||||
- `createdAt` - creation timestamp
|
||||
- `updatedAt` - last update timestamp
|
||||
|
||||
#### Conditions
|
||||
|
||||
- **Comparison**: `=`, `!=`, `>`, `>=`, `<`, `<=`
|
||||
- **Logical**: `and`, `or`, `not` (precedence: not > and > or)
|
||||
- **Membership**: `"value" in field`, `status not in ["done", "cancelled"]`
|
||||
- **Emptiness**: `assignee is empty`, `tags is not empty`
|
||||
- **Quantifiers**: `dependsOn any status != "done"`, `dependsOn all status = "done"`
|
||||
- **Grouping**: parentheses `()` to control evaluation order
|
||||
|
||||
#### Literals and built-ins
|
||||
|
||||
- Strings: double-quoted (`"ready"`, `"alex"`)
|
||||
- Integers: `1`, `5`
|
||||
- Dates: `2026-03-25`
|
||||
- Durations: `2hour`, `14day`, `3week`, `1month`
|
||||
- Lists: `["bug", "frontend"]`
|
||||
- `user()` — current git user
|
||||
- `now()` — current timestamp
|
||||
- `id()` — currently selected tiki (in plugin context)
|
||||
- `count(select where ...)` — count matching tikis
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
# About
|
||||
|
||||
tiki is a lightweight issue-tracking, project management and knowledge base tool that uses git repo
|
||||
to store issues, stories and documentation.
|
||||
|
||||
- tiki uses Markdown files stored in `tiki` format under `.doc/tiki` subdirectory of a git repo
|
||||
to track issues, stories or epics. Press `Tab` then `Enter` to select this link: [tiki](tiki.md) and read about `tiki` format
|
||||
- Project-related documentation is stored under `.doc/doki` also in Markdown format. They can be linked/back-linked
|
||||
for easier navigation.
|
||||
|
||||
>Since they are stored in git they are automatically versioned and can be perfectly synced to the current
|
||||
state of the repo or its git branch. Also, all past versions and deleted items remain in git history of the repo
|
||||
|
||||
## Board
|
||||
|
||||
Board is a simple Kanban-style board where tikis can be moved around with `Shift-Right` and `Shift-Left`
|
||||
As tikis are moved their status changes correspondingly. Statuses are configurable via `workflow.yaml`.
|
||||
Tikis can be opened for viewing or editing or searched by ID, title, description and tags.
|
||||
|
||||
To quickly capture an idea - hit `n` in the board or any tiki view, type in the title and press Enter
|
||||
You can also edit its status, type and other fields, or open the source file directly for editing in your favorite editor
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation is a Wiki-style knowledge base stored alongside the project files
|
||||
Documentation and various other files such as prompts can also be stored under git version control
|
||||
The documentation can be organized using Markdown links and navigated in the `tiki` cli using Tab/Shift-Tab and Enter
|
||||
|
||||
## AI
|
||||
|
||||
Since Markdown is an AI-native format issues and documentation can easily be created and maintained using AI tools.
|
||||
tiki can optionally install skills to enable AI tools such as `claude`, `gemini`, `codex` or `opencode` to understand its
|
||||
format. Try:
|
||||
|
||||
>create a tiki from @my-markdown.md with title "Fix UI bug"
|
||||
|
||||
or:
|
||||
|
||||
>mark tiki ABC123 as complete
|
||||
|
||||
## Customization
|
||||
|
||||
Press `Tab` then `Enter` to read [customize](view.md) to understand how to customize or extend tiki with your own plugins
|
||||
|
||||
## Configuration
|
||||
|
||||
tiki can be configured via `config.yaml` file stored in the same directory where executable is installed
|
||||
|
||||
## Header
|
||||
|
||||
- Context help showing keyboard shortcuts for the current view
|
||||
- Various statistics - tiki count, git branch and current user name
|
||||
- Burndown chart - number of incomplete tikis remaining
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
# tiki format
|
||||
|
||||
First of all, you just navigated to a linked file. To go back press `Left` arrow or `Alt-Left`
|
||||
To go forward press `Right` arrow or `Alt-Right`
|
||||
|
||||
Keep your tickets in your pockets!
|
||||
|
||||
`tiki` refers to a task or a ticket (hence tiki) stored in your **git** repo
|
||||
|
||||
- like a ticket it can have a status, priority, assignee, points, type and multiple tags attached to it
|
||||
- they are essentially just Markdown files and you can use full Markdown syntax to describe a story or a bug
|
||||
- they are stored in `.doc/tiki` subdirectory and are **git**-controlled - they are added to **git** when they are created,
|
||||
removed when they are done and the entire history is preserved in **git** repo
|
||||
- because they are in **git** they can be perfectly synced up to the state of your repo or a branch
|
||||
- you can use either the `tiki` CLI tool or any of the AI coding assistant to work with your tikis
|
||||
|
||||
## tiki format
|
||||
|
||||
Tiki stores tickets (aka tikis) and documents (aka dokis) in the git repo along with code
|
||||
They are stored under `.doc` directory and are supposed to be checked-in/versioned along with all other files
|
||||
|
||||
The `.doc/` directory contains two main subdirectories:
|
||||
- **doki/**: Documentation files (wiki-style markdown pages)
|
||||
- **tiki/**: Task files (kanban style tasks with YAML frontmatter)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
.doc/
|
||||
├── doki/
|
||||
│ ├── index.md
|
||||
│ ├── page2.md
|
||||
│ ├── page3.md
|
||||
│ └── sub/
|
||||
│ └── page4.md
|
||||
└── tiki/
|
||||
├── tiki-k3x9m2.md
|
||||
├── tiki-7wq4na.md
|
||||
├── tiki-p8j1fz.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
|
||||
## Tiki files
|
||||
|
||||
Tiki files are saved in `.doc/tiki` directory and can be managed via:
|
||||
|
||||
- `tiki` cli
|
||||
- AI tools such as `claude`, `gemini`, `codex` or `opencode`
|
||||
- manually
|
||||
|
||||
A tiki is made of its frontmatter that includes all fields related to a tiki status and types and its description
|
||||
in Markdown format
|
||||
|
||||
```text
|
||||
---
|
||||
title: Sample title
|
||||
type: story
|
||||
status: backlog
|
||||
assignee: booleanmaybe
|
||||
priority: 3
|
||||
points: 10
|
||||
tags:
|
||||
- UX
|
||||
- test
|
||||
dependsOn:
|
||||
- TIKI-ABC123
|
||||
- TIKI-DEF456
|
||||
due: 2026-04-01
|
||||
recurrence: 0 0 * * MON
|
||||
---
|
||||
|
||||
This is the description of a tiki in Markdown:
|
||||
|
||||
# Tests
|
||||
Make sure all tests pass
|
||||
|
||||
## Integration tests
|
||||
Integration test cases
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
#### title
|
||||
|
||||
**Required.** String. The name of the tiki. Must be non-empty, max 200 characters.
|
||||
|
||||
```yaml
|
||||
title: Implement user authentication
|
||||
```
|
||||
|
||||
#### type
|
||||
|
||||
Optional string. Defaults to the first type defined in `workflow.yaml`.
|
||||
|
||||
Valid values are the type keys defined in the `types:` section of `workflow.yaml`.
|
||||
Default types: `story`, `bug`, `spike`, `epic`. Each type can have a label and emoji
|
||||
configured in `workflow.yaml`. Aliases are not supported; use the canonical key.
|
||||
|
||||
```yaml
|
||||
type: bug
|
||||
```
|
||||
|
||||
#### status
|
||||
|
||||
Optional string. Must match a status defined in your project's `workflow.yaml`.
|
||||
Default: the status marked `default: true` in the workflow (typically `backlog`).
|
||||
|
||||
If the value doesn't match any status in the workflow, it falls back to the default.
|
||||
|
||||
```yaml
|
||||
status: in_progress
|
||||
```
|
||||
|
||||
#### priority
|
||||
|
||||
Optional. Stored in the file as an integer 1–5 (1 = highest, 5 = lowest). Default: `3`.
|
||||
|
||||
In the TUI, priority is displayed as text with a color indicator:
|
||||
|
||||
| Value | TUI label | Emoji |
|
||||
|-------|-----------|-------|
|
||||
| 1 | High | 🔴 |
|
||||
| 2 | Medium High | 🟠 |
|
||||
| 3 | Medium | 🟡 |
|
||||
| 4 | Medium Low | 🔵 |
|
||||
| 5 | Low | 🟢 |
|
||||
|
||||
Text aliases are accepted when creating tikis (case-insensitive, hyphens/underscores/spaces as separators):
|
||||
`high` = 1, `medium-high` = 2, `medium` = 3, `medium-low` = 4, `low` = 5.
|
||||
|
||||
```yaml
|
||||
priority: 2
|
||||
```
|
||||
|
||||
#### points
|
||||
|
||||
Optional integer. Range: `0` to `maxPoints` (configurable via `tiki.maxPoints`, default max is 10).
|
||||
`0` means unestimated. Values outside the valid range default to `maxPoints / 2` (typically 5).
|
||||
|
||||
```yaml
|
||||
points: 3
|
||||
```
|
||||
|
||||
#### assignee
|
||||
|
||||
Optional string. Free-form text, typically a username. Default: empty.
|
||||
|
||||
```yaml
|
||||
assignee: booleanmaybe
|
||||
```
|
||||
|
||||
#### tags
|
||||
|
||||
Optional string list. Arbitrary labels attached to a tiki. Empty and whitespace-only strings are filtered out.
|
||||
Default: empty. Both YAML list formats are accepted:
|
||||
|
||||
```yaml
|
||||
# block list
|
||||
tags:
|
||||
- frontend
|
||||
- urgent
|
||||
|
||||
# inline list
|
||||
tags: [frontend, urgent]
|
||||
```
|
||||
|
||||
#### dependsOn
|
||||
|
||||
Optional string list. Each entry must be a valid tiki ID in `TIKI-XXXXXX` format (6-character alphanumeric suffix)
|
||||
referencing an existing tiki. IDs are automatically uppercased. A dependency means this tiki is blocked by the listed tikis.
|
||||
Default: empty. Both YAML list formats are accepted:
|
||||
|
||||
```yaml
|
||||
# block list
|
||||
dependsOn:
|
||||
- TIKI-ABC123
|
||||
- TIKI-DEF456
|
||||
|
||||
# inline list
|
||||
dependsOn: [TIKI-ABC123, TIKI-DEF456]
|
||||
```
|
||||
|
||||
#### due
|
||||
|
||||
Optional date in `YYYY-MM-DD` format (date-only, no time component).
|
||||
Represents when the task should be completed by. Empty string or omitted field means no due date.
|
||||
|
||||
```yaml
|
||||
due: 2026-04-01
|
||||
```
|
||||
|
||||
#### recurrence
|
||||
|
||||
Optional cron string specifying a recurrence pattern. Displayed as English in the TUI.
|
||||
This is metadata-only — it does not auto-create tasks on completion.
|
||||
|
||||
Supported patterns:
|
||||
|
||||
| Cron | Display |
|
||||
|------|---------|
|
||||
| (empty) | None |
|
||||
| `0 0 * * *` | Daily |
|
||||
| `0 0 * * MON` | Weekly on Monday |
|
||||
| `0 0 * * TUE` | Weekly on Tuesday |
|
||||
| `0 0 * * WED` | Weekly on Wednesday |
|
||||
| `0 0 * * THU` | Weekly on Thursday |
|
||||
| `0 0 * * FRI` | Weekly on Friday |
|
||||
| `0 0 * * SAT` | Weekly on Saturday |
|
||||
| `0 0 * * SUN` | Weekly on Sunday |
|
||||
| `0 0 1 * *` | Monthly |
|
||||
|
||||
```yaml
|
||||
recurrence: 0 0 * * MON
|
||||
```
|
||||
|
||||
### Derived fields
|
||||
|
||||
Fields such as:
|
||||
- `created by`
|
||||
- `created at`
|
||||
- `updated at`
|
||||
|
||||
are not stored and are calculated from git - the time and git user who created a tiki or the time it was last modified
|
||||
|
||||
|
||||
## Doki files
|
||||
|
||||
Documents are any file in a Markdown format saved under `.doc/doki` directory. They can be organized in subdirectory
|
||||
tree and include links between them or to external Markdown files
|
||||
101
view/input_box.go
Normal file
101
view/input_box.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/controller"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// InputBox is a single-line input field with a configurable prompt
|
||||
type InputBox struct {
|
||||
*tview.InputField
|
||||
onSubmit func(text string) controller.InputSubmitResult
|
||||
onCancel func()
|
||||
}
|
||||
|
||||
// NewInputBox creates a new input box widget with the default "> " prompt
|
||||
func NewInputBox() *InputBox {
|
||||
colors := config.GetColors()
|
||||
inputField := tview.NewInputField()
|
||||
|
||||
inputField.SetLabel("> ")
|
||||
inputField.SetLabelColor(colors.InputBoxLabelColor.TCell())
|
||||
inputField.SetFieldBackgroundColor(colors.InputBoxBackgroundColor.TCell())
|
||||
inputField.SetFieldTextColor(colors.InputBoxTextColor.TCell())
|
||||
inputField.SetBorder(true)
|
||||
inputField.SetBorderColor(colors.TaskBoxUnselectedBorder.TCell())
|
||||
|
||||
sb := &InputBox{
|
||||
InputField: inputField,
|
||||
}
|
||||
|
||||
return sb
|
||||
}
|
||||
|
||||
// SetPrompt changes the prompt label displayed before the input text.
|
||||
func (sb *InputBox) SetPrompt(label string) *InputBox {
|
||||
sb.SetLabel(label)
|
||||
return sb
|
||||
}
|
||||
|
||||
// SetSubmitHandler sets the callback for when Enter is pressed.
|
||||
// The callback returns an InputSubmitResult controlling box disposition.
|
||||
func (sb *InputBox) SetSubmitHandler(handler func(text string) controller.InputSubmitResult) *InputBox {
|
||||
sb.onSubmit = handler
|
||||
return sb
|
||||
}
|
||||
|
||||
// SetCancelHandler sets the callback for when Escape is pressed
|
||||
func (sb *InputBox) SetCancelHandler(handler func()) *InputBox {
|
||||
sb.onCancel = handler
|
||||
return sb
|
||||
}
|
||||
|
||||
// Clear clears the input text
|
||||
func (sb *InputBox) Clear() *InputBox {
|
||||
sb.SetText("")
|
||||
return sb
|
||||
}
|
||||
|
||||
// InputHandler handles key input for the input box
|
||||
func (sb *InputBox) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
return sb.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
key := event.Key()
|
||||
|
||||
switch key {
|
||||
case tcell.KeyEnter:
|
||||
if sb.onSubmit != nil {
|
||||
sb.onSubmit(sb.GetText())
|
||||
}
|
||||
return
|
||||
case tcell.KeyEscape:
|
||||
if sb.onCancel != nil {
|
||||
sb.onCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if sb.isAllowedKey(event) {
|
||||
handler := sb.InputField.InputHandler()
|
||||
if handler != nil {
|
||||
handler(event, setFocus)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// isAllowedKey returns true if the key should be processed by the InputField
|
||||
func (sb *InputBox) isAllowedKey(event *tcell.EventKey) bool {
|
||||
key := event.Key()
|
||||
|
||||
switch key {
|
||||
case tcell.KeyBackspace, tcell.KeyBackspace2, tcell.KeyDelete:
|
||||
return true
|
||||
case tcell.KeyRune:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
187
view/input_helper.go
Normal file
187
view/input_helper.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/controller"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type inputMode int
|
||||
|
||||
const (
|
||||
inputModeClosed inputMode = iota
|
||||
inputModeSearchEditing // search box focused, user typing
|
||||
inputModeSearchPassive // search applied, box visible but unfocused
|
||||
inputModeActionInput // action-input box focused, user typing
|
||||
)
|
||||
|
||||
// InputHelper provides reusable input box integration to eliminate duplication across views.
|
||||
// It tracks an explicit mode state machine:
|
||||
//
|
||||
// closed → searchEditing (on search open)
|
||||
// searchEditing → searchPassive (on non-empty Enter)
|
||||
// searchEditing → closed (on Esc — clears search)
|
||||
// searchPassive → closed (on Esc — clears search)
|
||||
// searchPassive → actionInput (on action-input open — temporarily replaces)
|
||||
// actionInput → searchPassive (on Enter/Esc if search was passive before)
|
||||
// actionInput → closed (on Enter/Esc if no prior search)
|
||||
type InputHelper struct {
|
||||
inputBox *InputBox
|
||||
mode inputMode
|
||||
savedSearchQuery string // saved when action-input temporarily replaces passive search
|
||||
onSubmit func(text string) controller.InputSubmitResult
|
||||
onCancel func()
|
||||
onClose func() // called when the helper needs the view to rebuild layout (remove widget)
|
||||
onRestorePassive func(query string) // called when action-input ends and passive search should be restored
|
||||
focusSetter func(p tview.Primitive)
|
||||
contentPrimitive tview.Primitive
|
||||
}
|
||||
|
||||
// NewInputHelper creates a new input helper with an initialized input box
|
||||
func NewInputHelper(contentPrimitive tview.Primitive) *InputHelper {
|
||||
helper := &InputHelper{
|
||||
inputBox: NewInputBox(),
|
||||
contentPrimitive: contentPrimitive,
|
||||
}
|
||||
|
||||
helper.inputBox.SetSubmitHandler(func(text string) controller.InputSubmitResult {
|
||||
if helper.onSubmit == nil {
|
||||
return controller.InputClose
|
||||
}
|
||||
result := helper.onSubmit(text)
|
||||
switch result {
|
||||
case controller.InputShowPassive:
|
||||
helper.mode = inputModeSearchPassive
|
||||
helper.inputBox.SetText(strings.TrimSpace(text))
|
||||
if helper.focusSetter != nil {
|
||||
helper.focusSetter(contentPrimitive)
|
||||
}
|
||||
case controller.InputClose:
|
||||
helper.finishInput()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
helper.inputBox.SetCancelHandler(func() {
|
||||
if helper.onCancel != nil {
|
||||
helper.onCancel()
|
||||
}
|
||||
})
|
||||
|
||||
return helper
|
||||
}
|
||||
|
||||
// finishInput handles InputClose: restores passive search or fully closes.
|
||||
func (ih *InputHelper) finishInput() {
|
||||
if ih.mode == inputModeActionInput && ih.savedSearchQuery != "" {
|
||||
query := ih.savedSearchQuery
|
||||
ih.savedSearchQuery = ""
|
||||
ih.mode = inputModeSearchPassive
|
||||
ih.inputBox.SetPrompt("> ")
|
||||
ih.inputBox.SetText(query)
|
||||
if ih.focusSetter != nil {
|
||||
ih.focusSetter(ih.contentPrimitive)
|
||||
}
|
||||
if ih.onRestorePassive != nil {
|
||||
ih.onRestorePassive(query)
|
||||
}
|
||||
return
|
||||
}
|
||||
ih.savedSearchQuery = ""
|
||||
ih.mode = inputModeClosed
|
||||
ih.inputBox.Clear()
|
||||
if ih.focusSetter != nil {
|
||||
ih.focusSetter(ih.contentPrimitive)
|
||||
}
|
||||
if ih.onClose != nil {
|
||||
ih.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// SetSubmitHandler sets the handler called when user submits input (Enter key).
|
||||
func (ih *InputHelper) SetSubmitHandler(handler func(text string) controller.InputSubmitResult) {
|
||||
ih.onSubmit = handler
|
||||
}
|
||||
|
||||
// SetCancelHandler sets the handler called when user cancels input (Escape key)
|
||||
func (ih *InputHelper) SetCancelHandler(handler func()) {
|
||||
ih.onCancel = handler
|
||||
}
|
||||
|
||||
// SetCloseHandler sets the callback for when the input box should be removed from layout.
|
||||
func (ih *InputHelper) SetCloseHandler(handler func()) {
|
||||
ih.onClose = handler
|
||||
}
|
||||
|
||||
// SetRestorePassiveHandler sets the callback for when passive search should be restored
|
||||
// after action-input ends (layout may need prompt text update).
|
||||
func (ih *InputHelper) SetRestorePassiveHandler(handler func(query string)) {
|
||||
ih.onRestorePassive = handler
|
||||
}
|
||||
|
||||
// SetFocusSetter sets the function used to change focus between primitives.
|
||||
func (ih *InputHelper) SetFocusSetter(setter func(p tview.Primitive)) {
|
||||
ih.focusSetter = setter
|
||||
}
|
||||
|
||||
// Show makes the input box visible with the given prompt and initial text.
|
||||
// If the box is in search-passive mode, it saves the search query and
|
||||
// transitions to action-input mode (temporarily replacing the passive indicator).
|
||||
func (ih *InputHelper) Show(prompt, initialText string, mode inputMode) tview.Primitive {
|
||||
if ih.mode == inputModeSearchPassive && mode == inputModeActionInput {
|
||||
ih.savedSearchQuery = ih.inputBox.GetText()
|
||||
}
|
||||
ih.mode = mode
|
||||
ih.inputBox.SetPrompt(prompt)
|
||||
ih.inputBox.SetText(initialText)
|
||||
return ih.inputBox
|
||||
}
|
||||
|
||||
// ShowSearch makes the input box visible in search-editing mode.
|
||||
func (ih *InputHelper) ShowSearch(currentQuery string) tview.Primitive {
|
||||
return ih.Show("> ", currentQuery, inputModeSearchEditing)
|
||||
}
|
||||
|
||||
// Hide hides the input box and clears its text. Generic teardown only.
|
||||
func (ih *InputHelper) Hide() {
|
||||
ih.mode = inputModeClosed
|
||||
ih.savedSearchQuery = ""
|
||||
ih.inputBox.Clear()
|
||||
}
|
||||
|
||||
// IsVisible returns true if the input box is currently visible (any mode except closed)
|
||||
func (ih *InputHelper) IsVisible() bool {
|
||||
return ih.mode != inputModeClosed
|
||||
}
|
||||
|
||||
// IsEditing returns true if the input box is in an active editing mode (focused)
|
||||
func (ih *InputHelper) IsEditing() bool {
|
||||
return ih.mode == inputModeSearchEditing || ih.mode == inputModeActionInput
|
||||
}
|
||||
|
||||
// IsSearchPassive returns true if the input box is in search-passive mode
|
||||
func (ih *InputHelper) IsSearchPassive() bool {
|
||||
return ih.mode == inputModeSearchPassive
|
||||
}
|
||||
|
||||
// Mode returns the current input mode
|
||||
func (ih *InputHelper) Mode() inputMode {
|
||||
return ih.mode
|
||||
}
|
||||
|
||||
// HasFocus returns true if the input box currently has focus
|
||||
func (ih *InputHelper) HasFocus() bool {
|
||||
return ih.IsVisible() && ih.inputBox.HasFocus()
|
||||
}
|
||||
|
||||
// GetFocusSetter returns the focus setter function
|
||||
func (ih *InputHelper) GetFocusSetter() func(p tview.Primitive) {
|
||||
return ih.focusSetter
|
||||
}
|
||||
|
||||
// GetInputBox returns the underlying input box primitive for layout building
|
||||
func (ih *InputHelper) GetInputBox() *InputBox {
|
||||
return ih.inputBox
|
||||
}
|
||||
535
view/palette/action_palette.go
Normal file
535
view/palette/action_palette.go
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
package palette
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/util"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
const PaletteMinWidth = 30
|
||||
|
||||
// sectionType identifies which section a palette row belongs to.
|
||||
type sectionType int
|
||||
|
||||
const (
|
||||
sectionGlobal sectionType = iota
|
||||
sectionViews
|
||||
sectionView
|
||||
)
|
||||
|
||||
// paletteRow is a single entry in the rendered palette list.
|
||||
type paletteRow struct {
|
||||
action controller.Action
|
||||
section sectionType
|
||||
enabled bool
|
||||
separator bool // true for section header/separator rows
|
||||
label string
|
||||
}
|
||||
|
||||
// ActionPalette is a modal overlay listing all available actions, filterable by fuzzy typing.
|
||||
type ActionPalette struct {
|
||||
root *tview.Flex
|
||||
filterInput *tview.InputField
|
||||
listView *tview.TextView
|
||||
hintView *tview.TextView
|
||||
viewContext *model.ViewContext
|
||||
paletteConfig *model.ActionPaletteConfig
|
||||
inputRouter *controller.InputRouter
|
||||
navController *controller.NavigationController
|
||||
|
||||
rows []paletteRow
|
||||
visibleRows []int // indices into rows for current filter
|
||||
selectedIndex int // index into visibleRows
|
||||
lastWidth int // width used for last render, to detect resize
|
||||
|
||||
viewContextListenerID int
|
||||
}
|
||||
|
||||
// NewActionPalette creates the palette widget.
|
||||
func NewActionPalette(
|
||||
viewContext *model.ViewContext,
|
||||
paletteConfig *model.ActionPaletteConfig,
|
||||
inputRouter *controller.InputRouter,
|
||||
navController *controller.NavigationController,
|
||||
) *ActionPalette {
|
||||
colors := config.GetColors()
|
||||
|
||||
ap := &ActionPalette{
|
||||
viewContext: viewContext,
|
||||
paletteConfig: paletteConfig,
|
||||
inputRouter: inputRouter,
|
||||
navController: navController,
|
||||
}
|
||||
|
||||
// filter input
|
||||
ap.filterInput = tview.NewInputField()
|
||||
ap.filterInput.SetLabel(" ")
|
||||
ap.filterInput.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
ap.filterInput.SetFieldTextColor(colors.InputFieldTextColor.TCell())
|
||||
ap.filterInput.SetLabelColor(colors.InputBoxLabelColor.TCell())
|
||||
ap.filterInput.SetPlaceholder("Type to search")
|
||||
ap.filterInput.SetPlaceholderStyle(tcell.StyleDefault.
|
||||
Foreground(colors.TaskDetailPlaceholderColor.TCell()).
|
||||
Background(colors.ContentBackgroundColor.TCell()))
|
||||
ap.filterInput.SetBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
|
||||
// list area
|
||||
ap.listView = tview.NewTextView().SetDynamicColors(true)
|
||||
ap.listView.SetBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
ap.listView.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) {
|
||||
if width != ap.lastWidth && width > 0 {
|
||||
ap.renderList()
|
||||
}
|
||||
return x, y, width, height
|
||||
})
|
||||
|
||||
// bottom hint
|
||||
ap.hintView = tview.NewTextView().SetDynamicColors(true)
|
||||
ap.hintView.SetBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
mutedHex := colors.TaskDetailPlaceholderColor.Hex()
|
||||
ap.hintView.SetText(fmt.Sprintf(" [%s]↑↓ Select ⏎ Run Esc Close", mutedHex))
|
||||
|
||||
ap.root = tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
ap.root.SetBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
ap.root.SetBorder(true)
|
||||
ap.root.SetBorderColor(colors.TaskBoxUnselectedBorder.TCell())
|
||||
ap.root.AddItem(ap.filterInput, 1, 0, true)
|
||||
ap.root.AddItem(ap.listView, 0, 1, false)
|
||||
ap.root.AddItem(ap.hintView, 1, 0, false)
|
||||
|
||||
// wire filter input to intercept all palette keys
|
||||
ap.filterInput.SetInputCapture(ap.handleFilterInput)
|
||||
|
||||
// subscribe to view context changes
|
||||
ap.viewContextListenerID = viewContext.AddListener(func() {
|
||||
ap.rebuildRows()
|
||||
ap.renderList()
|
||||
})
|
||||
|
||||
return ap
|
||||
}
|
||||
|
||||
// GetPrimitive returns the root tview primitive for embedding in a Pages overlay.
|
||||
func (ap *ActionPalette) GetPrimitive() tview.Primitive {
|
||||
return ap.root
|
||||
}
|
||||
|
||||
// GetFilterInput returns the input field that should receive focus when the palette opens.
|
||||
func (ap *ActionPalette) GetFilterInput() *tview.InputField {
|
||||
return ap.filterInput
|
||||
}
|
||||
|
||||
// OnShow resets state and rebuilds rows when the palette becomes visible.
|
||||
func (ap *ActionPalette) OnShow() {
|
||||
ap.filterInput.SetText("")
|
||||
ap.selectedIndex = 0
|
||||
ap.rebuildRows()
|
||||
ap.renderList()
|
||||
}
|
||||
|
||||
// Cleanup removes all listeners.
|
||||
func (ap *ActionPalette) Cleanup() {
|
||||
ap.viewContext.RemoveListener(ap.viewContextListenerID)
|
||||
}
|
||||
|
||||
func (ap *ActionPalette) rebuildRows() {
|
||||
ap.rows = nil
|
||||
|
||||
currentView := ap.navController.CurrentView()
|
||||
activeView := ap.navController.GetActiveView()
|
||||
|
||||
globalActions := controller.DefaultGlobalActions().GetPaletteActions()
|
||||
globalIDs := make(map[controller.ActionID]bool, len(globalActions))
|
||||
for _, a := range globalActions {
|
||||
globalIDs[a.ID] = true
|
||||
}
|
||||
|
||||
// global section
|
||||
if len(globalActions) > 0 {
|
||||
ap.rows = append(ap.rows, paletteRow{separator: true, label: "Global", section: sectionGlobal})
|
||||
for _, a := range globalActions {
|
||||
ap.rows = append(ap.rows, paletteRow{
|
||||
action: a,
|
||||
section: sectionGlobal,
|
||||
enabled: actionEnabled(a, currentView, activeView),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// views section (plugin activation keys) — only if active view shows navigation
|
||||
pluginIDs := make(map[controller.ActionID]bool)
|
||||
if activeView != nil {
|
||||
if np, ok := activeView.(controller.NavigationProvider); ok && np.ShowNavigation() {
|
||||
pluginActions := controller.GetPluginActions().GetPaletteActions()
|
||||
if len(pluginActions) > 0 {
|
||||
ap.rows = append(ap.rows, paletteRow{separator: true, label: "Views", section: sectionViews})
|
||||
for _, a := range pluginActions {
|
||||
pluginIDs[a.ID] = true
|
||||
ap.rows = append(ap.rows, paletteRow{
|
||||
action: a,
|
||||
section: sectionViews,
|
||||
enabled: actionEnabled(a, currentView, activeView),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// view section — current view's own actions, deduped against global + plugin
|
||||
if activeView != nil {
|
||||
viewActions := activeView.GetActionRegistry().GetPaletteActions()
|
||||
var filtered []controller.Action
|
||||
for _, a := range viewActions {
|
||||
if globalIDs[a.ID] || pluginIDs[a.ID] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, a)
|
||||
}
|
||||
if len(filtered) > 0 {
|
||||
ap.rows = append(ap.rows, paletteRow{separator: true, label: "View", section: sectionView})
|
||||
for _, a := range filtered {
|
||||
ap.rows = append(ap.rows, paletteRow{
|
||||
action: a,
|
||||
section: sectionView,
|
||||
enabled: actionEnabled(a, currentView, activeView),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ap.filterRows()
|
||||
}
|
||||
|
||||
func actionEnabled(a controller.Action, currentView *controller.ViewEntry, activeView controller.View) bool {
|
||||
if a.IsEnabled == nil {
|
||||
return true
|
||||
}
|
||||
return a.IsEnabled(currentView, activeView)
|
||||
}
|
||||
|
||||
func (ap *ActionPalette) filterRows() {
|
||||
query := ap.filterInput.GetText()
|
||||
ap.visibleRows = nil
|
||||
|
||||
if query == "" {
|
||||
for i := range ap.rows {
|
||||
ap.visibleRows = append(ap.visibleRows, i)
|
||||
}
|
||||
ap.stripEmptySections()
|
||||
ap.clampSelection()
|
||||
return
|
||||
}
|
||||
|
||||
type scored struct {
|
||||
idx int
|
||||
score int
|
||||
}
|
||||
|
||||
// group by section, score each, sort within section
|
||||
sectionScored := make(map[sectionType][]scored)
|
||||
for i, row := range ap.rows {
|
||||
if row.separator {
|
||||
continue
|
||||
}
|
||||
matched, score := fuzzyMatch(query, row.action.Label)
|
||||
if matched {
|
||||
sectionScored[row.section] = append(sectionScored[row.section], scored{i, score})
|
||||
}
|
||||
}
|
||||
|
||||
for _, section := range []sectionType{sectionGlobal, sectionViews, sectionView} {
|
||||
items := sectionScored[section]
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Slice(items, func(a, b int) bool {
|
||||
if items[a].score != items[b].score {
|
||||
return items[a].score < items[b].score
|
||||
}
|
||||
la := strings.ToLower(ap.rows[items[a].idx].action.Label)
|
||||
lb := strings.ToLower(ap.rows[items[b].idx].action.Label)
|
||||
if la != lb {
|
||||
return la < lb
|
||||
}
|
||||
return ap.rows[items[a].idx].action.ID < ap.rows[items[b].idx].action.ID
|
||||
})
|
||||
|
||||
// find section separator
|
||||
for i, row := range ap.rows {
|
||||
if row.separator && row.section == section {
|
||||
ap.visibleRows = append(ap.visibleRows, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
ap.visibleRows = append(ap.visibleRows, item.idx)
|
||||
}
|
||||
}
|
||||
|
||||
ap.stripEmptySections()
|
||||
ap.clampSelection()
|
||||
}
|
||||
|
||||
// stripEmptySections removes section separators that have no visible action rows after them.
|
||||
func (ap *ActionPalette) stripEmptySections() {
|
||||
var result []int
|
||||
for i, vi := range ap.visibleRows {
|
||||
row := ap.rows[vi]
|
||||
if row.separator {
|
||||
// check if next visible row is a non-separator in same section
|
||||
hasContent := false
|
||||
for j := i + 1; j < len(ap.visibleRows); j++ {
|
||||
next := ap.rows[ap.visibleRows[j]]
|
||||
if next.separator {
|
||||
break
|
||||
}
|
||||
hasContent = true
|
||||
break
|
||||
}
|
||||
if !hasContent {
|
||||
continue
|
||||
}
|
||||
}
|
||||
result = append(result, vi)
|
||||
}
|
||||
ap.visibleRows = result
|
||||
}
|
||||
|
||||
func (ap *ActionPalette) clampSelection() {
|
||||
if ap.selectedIndex >= len(ap.visibleRows) {
|
||||
ap.selectedIndex = 0
|
||||
}
|
||||
// skip to first selectable (non-separator, enabled) row
|
||||
ap.selectedIndex = ap.nextSelectableFrom(ap.selectedIndex, 1)
|
||||
}
|
||||
|
||||
func (ap *ActionPalette) nextSelectableFrom(start, direction int) int {
|
||||
n := len(ap.visibleRows)
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
idx := (start + i*direction + n) % n
|
||||
row := ap.rows[ap.visibleRows[idx]]
|
||||
if !row.separator && row.enabled {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func (ap *ActionPalette) renderList() {
|
||||
colors := config.GetColors()
|
||||
_, _, width, _ := ap.listView.GetInnerRect()
|
||||
if width <= 0 {
|
||||
width = PaletteMinWidth
|
||||
}
|
||||
ap.lastWidth = width
|
||||
|
||||
globalScheme := sectionColors(sectionGlobal)
|
||||
viewsScheme := sectionColors(sectionViews)
|
||||
viewScheme := sectionColors(sectionView)
|
||||
|
||||
mutedHex := colors.TaskDetailPlaceholderColor.Hex()
|
||||
selBgHex := colors.TaskListSelectionBg.Hex()
|
||||
|
||||
var buf strings.Builder
|
||||
|
||||
if len(ap.visibleRows) == 0 {
|
||||
buf.WriteString(fmt.Sprintf("[%s] no matches", mutedHex))
|
||||
ap.listView.SetText(buf.String())
|
||||
return
|
||||
}
|
||||
|
||||
keyColWidth := 12
|
||||
|
||||
for vi, rowIdx := range ap.visibleRows {
|
||||
row := ap.rows[rowIdx]
|
||||
|
||||
if row.separator {
|
||||
if vi > 0 {
|
||||
buf.WriteString("\n")
|
||||
line := strings.Repeat("─", width)
|
||||
buf.WriteString(fmt.Sprintf("[%s]%s[-]", mutedHex, line))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
keyStr := util.FormatKeyBinding(row.action.Key, row.action.Rune, row.action.Modifier)
|
||||
label := row.action.Label
|
||||
|
||||
// truncate label if needed
|
||||
maxLabel := width - keyColWidth - 4
|
||||
if maxLabel < 5 {
|
||||
maxLabel = 5
|
||||
}
|
||||
if len([]rune(label)) > maxLabel {
|
||||
label = string([]rune(label)[:maxLabel-1]) + "…"
|
||||
}
|
||||
|
||||
var scheme sectionColorPair
|
||||
switch row.section {
|
||||
case sectionGlobal:
|
||||
scheme = globalScheme
|
||||
case sectionViews:
|
||||
scheme = viewsScheme
|
||||
case sectionView:
|
||||
scheme = viewScheme
|
||||
}
|
||||
|
||||
selected := vi == ap.selectedIndex
|
||||
|
||||
if vi > 0 {
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
// build visible text: key column + label
|
||||
visibleLen := 1 + keyColWidth + 1 + len([]rune(label)) // leading space + key + space + label
|
||||
pad := ""
|
||||
if visibleLen < width {
|
||||
pad = strings.Repeat(" ", width-visibleLen)
|
||||
}
|
||||
|
||||
if !row.enabled {
|
||||
buf.WriteString(fmt.Sprintf(" [%s]%-*s %s%s[-]", mutedHex, keyColWidth, keyStr, label, pad))
|
||||
} else if selected {
|
||||
buf.WriteString(fmt.Sprintf("[%s:%s:b] %-*s[-:-:-][:%s:] %s%s[-:-:-]",
|
||||
scheme.keyHex, selBgHex, keyColWidth, keyStr,
|
||||
selBgHex, label, pad))
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf(" [%s]%-*s[-] %s%s", scheme.keyHex, keyColWidth, keyStr, label, pad))
|
||||
}
|
||||
}
|
||||
|
||||
ap.listView.SetText(buf.String())
|
||||
}
|
||||
|
||||
type sectionColorPair struct {
|
||||
keyHex string
|
||||
labelHex string
|
||||
}
|
||||
|
||||
func sectionColors(s sectionType) sectionColorPair {
|
||||
colors := config.GetColors()
|
||||
switch s {
|
||||
case sectionGlobal:
|
||||
return sectionColorPair{
|
||||
keyHex: colors.HeaderActionGlobalKeyColor.Hex(),
|
||||
labelHex: colors.HeaderActionGlobalLabelColor.Hex(),
|
||||
}
|
||||
case sectionViews:
|
||||
return sectionColorPair{
|
||||
keyHex: colors.HeaderActionPluginKeyColor.Hex(),
|
||||
labelHex: colors.HeaderActionPluginLabelColor.Hex(),
|
||||
}
|
||||
case sectionView:
|
||||
return sectionColorPair{
|
||||
keyHex: colors.HeaderActionViewKeyColor.Hex(),
|
||||
labelHex: colors.HeaderActionViewLabelColor.Hex(),
|
||||
}
|
||||
default:
|
||||
return sectionColorPair{
|
||||
keyHex: colors.HeaderActionGlobalKeyColor.Hex(),
|
||||
labelHex: colors.HeaderActionGlobalLabelColor.Hex(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleFilterInput owns all palette keyboard behavior.
|
||||
func (ap *ActionPalette) handleFilterInput(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyEscape:
|
||||
ap.paletteConfig.SetVisible(false)
|
||||
return nil
|
||||
|
||||
case tcell.KeyEnter:
|
||||
ap.dispatchSelected()
|
||||
return nil
|
||||
|
||||
case tcell.KeyUp:
|
||||
ap.moveSelection(-1)
|
||||
ap.renderList()
|
||||
return nil
|
||||
|
||||
case tcell.KeyDown:
|
||||
ap.moveSelection(1)
|
||||
ap.renderList()
|
||||
return nil
|
||||
|
||||
case tcell.KeyCtrlU:
|
||||
ap.filterInput.SetText("")
|
||||
ap.filterRows()
|
||||
ap.renderList()
|
||||
return nil
|
||||
|
||||
case tcell.KeyRune:
|
||||
// let the input field handle the rune, then re-filter
|
||||
return event
|
||||
|
||||
case tcell.KeyBackspace, tcell.KeyBackspace2:
|
||||
return event
|
||||
|
||||
default:
|
||||
// swallow everything else
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetChangedFunc wires a callback that re-filters when the input text changes.
|
||||
func (ap *ActionPalette) SetChangedFunc() {
|
||||
ap.filterInput.SetChangedFunc(func(text string) {
|
||||
ap.filterRows()
|
||||
ap.renderList()
|
||||
})
|
||||
}
|
||||
|
||||
func (ap *ActionPalette) moveSelection(direction int) {
|
||||
n := len(ap.visibleRows)
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
start := ap.selectedIndex + direction
|
||||
if start < 0 {
|
||||
start = n - 1
|
||||
} else if start >= n {
|
||||
start = 0
|
||||
}
|
||||
ap.selectedIndex = ap.nextSelectableFrom(start, direction)
|
||||
}
|
||||
|
||||
func (ap *ActionPalette) dispatchSelected() {
|
||||
if ap.selectedIndex >= len(ap.visibleRows) {
|
||||
ap.paletteConfig.SetVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
row := ap.rows[ap.visibleRows[ap.selectedIndex]]
|
||||
if row.separator || !row.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
actionID := row.action.ID
|
||||
|
||||
// close palette BEFORE dispatch (clean focus transition)
|
||||
ap.paletteConfig.SetVisible(false)
|
||||
|
||||
// try view-local handler first
|
||||
if activeView := ap.navController.GetActiveView(); activeView != nil {
|
||||
if handler, ok := activeView.(controller.PaletteActionHandler); ok {
|
||||
if handler.HandlePaletteAction(actionID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fall back to controller-side dispatch
|
||||
ap.inputRouter.HandleAction(actionID, ap.navController.CurrentView())
|
||||
}
|
||||
34
view/palette/fuzzy.go
Normal file
34
view/palette/fuzzy.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package palette
|
||||
|
||||
import "unicode"
|
||||
|
||||
// fuzzyMatch performs a case-insensitive subsequence match of query against text.
|
||||
// Returns (matched, score) where lower score is better.
|
||||
// Score = firstMatchPos + spanLength, where spanLength = lastMatchIdx - firstMatchIdx.
|
||||
func fuzzyMatch(query, text string) (bool, int) {
|
||||
if query == "" {
|
||||
return true, 0
|
||||
}
|
||||
|
||||
queryRunes := []rune(query)
|
||||
textRunes := []rune(text)
|
||||
qi := 0
|
||||
firstMatch := -1
|
||||
lastMatch := -1
|
||||
|
||||
for ti := 0; ti < len(textRunes) && qi < len(queryRunes); ti++ {
|
||||
if unicode.ToLower(textRunes[ti]) == unicode.ToLower(queryRunes[qi]) {
|
||||
if firstMatch == -1 {
|
||||
firstMatch = ti
|
||||
}
|
||||
lastMatch = ti
|
||||
qi++
|
||||
}
|
||||
}
|
||||
|
||||
if qi < len(queryRunes) {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
return true, firstMatch + (lastMatch - firstMatch)
|
||||
}
|
||||
81
view/palette/fuzzy_test.go
Normal file
81
view/palette/fuzzy_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package palette
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFuzzyMatch_EmptyQuery(t *testing.T) {
|
||||
matched, score := fuzzyMatch("", "anything")
|
||||
if !matched {
|
||||
t.Error("empty query should match everything")
|
||||
}
|
||||
if score != 0 {
|
||||
t.Errorf("empty query score should be 0, got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_ExactMatch(t *testing.T) {
|
||||
matched, score := fuzzyMatch("Save", "Save")
|
||||
if !matched {
|
||||
t.Error("exact match should succeed")
|
||||
}
|
||||
if score != 3 {
|
||||
t.Errorf("expected score 3 (pos=0, span=3), got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_PrefixMatch(t *testing.T) {
|
||||
matched, score := fuzzyMatch("Sav", "Save Task")
|
||||
if !matched {
|
||||
t.Error("prefix match should succeed")
|
||||
}
|
||||
if score != 2 {
|
||||
t.Errorf("expected score 2 (pos=0, span=2), got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_SubsequenceMatch(t *testing.T) {
|
||||
matched, score := fuzzyMatch("TH", "Toggle Header")
|
||||
if !matched {
|
||||
t.Error("subsequence match should succeed")
|
||||
}
|
||||
// T at 0, H at 7 → score = 0 + 7 = 7
|
||||
if score != 7 {
|
||||
t.Errorf("expected score 7, got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_CaseInsensitive(t *testing.T) {
|
||||
matched, _ := fuzzyMatch("save", "Save Task")
|
||||
if !matched {
|
||||
t.Error("case-insensitive match should succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_NoMatch(t *testing.T) {
|
||||
matched, _ := fuzzyMatch("xyz", "Save Task")
|
||||
if matched {
|
||||
t.Error("non-matching query should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_ScoreOrdering(t *testing.T) {
|
||||
// "se" matches "Search" (S=0, e=1 → score=1) better than "Save Edit" (S=0, e=5 → score=5)
|
||||
_, scoreSearch := fuzzyMatch("se", "Search")
|
||||
_, scoreSaveEdit := fuzzyMatch("se", "Save Edit")
|
||||
|
||||
if scoreSearch >= scoreSaveEdit {
|
||||
t.Errorf("'Search' should score better than 'Save Edit' for 'se': %d vs %d", scoreSearch, scoreSaveEdit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_SingleChar(t *testing.T) {
|
||||
matched, score := fuzzyMatch("q", "Quit")
|
||||
if !matched {
|
||||
t.Error("single char should match")
|
||||
}
|
||||
// q at position 0 → score = 0 + 0 = 0
|
||||
if score != 0 {
|
||||
t.Errorf("single char at start should score 0, got %d", score)
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,8 @@ type RootLayout struct {
|
|||
contentView controller.View
|
||||
lastParamsKey string
|
||||
|
||||
viewContext *model.ViewContext
|
||||
|
||||
headerListenerID int
|
||||
layoutListenerID int
|
||||
storeListenerID int
|
||||
|
|
@ -50,6 +52,7 @@ type RootLayout struct {
|
|||
type RootLayoutOpts struct {
|
||||
Header *header.HeaderWidget
|
||||
HeaderConfig *model.HeaderConfig
|
||||
ViewContext *model.ViewContext
|
||||
LayoutModel *model.LayoutModel
|
||||
ViewFactory controller.ViewFactory
|
||||
TaskStore store.Store
|
||||
|
|
@ -65,6 +68,7 @@ func NewRootLayout(opts RootLayoutOpts) *RootLayout {
|
|||
header: opts.Header,
|
||||
contentArea: tview.NewFlex().SetDirection(tview.FlexRow),
|
||||
headerConfig: opts.HeaderConfig,
|
||||
viewContext: opts.ViewContext,
|
||||
layoutModel: opts.LayoutModel,
|
||||
viewFactory: opts.ViewFactory,
|
||||
taskStore: opts.TaskStore,
|
||||
|
|
@ -139,20 +143,8 @@ func (rl *RootLayout) onLayoutChange() {
|
|||
rl.contentArea.AddItem(newView.GetPrimitive(), 0, 1, true)
|
||||
rl.contentView = newView
|
||||
|
||||
// Update header with new view's actions
|
||||
rl.headerConfig.SetViewActions(newView.GetActionRegistry().ToHeaderActions())
|
||||
|
||||
// Show or hide plugin navigation keys based on the view's declaration
|
||||
if np, ok := newView.(controller.NavigationProvider); ok && np.ShowNavigation() {
|
||||
rl.headerConfig.SetPluginActions(controller.GetPluginActions().ToHeaderActions())
|
||||
} else {
|
||||
rl.headerConfig.SetPluginActions(nil)
|
||||
}
|
||||
|
||||
// Update header info section with view name and description
|
||||
if vip, ok := newView.(controller.ViewInfoProvider); ok {
|
||||
rl.headerConfig.SetViewInfo(vip.GetViewName(), vip.GetViewDescription())
|
||||
}
|
||||
// Sync view context (writes to both ViewContext and HeaderConfig for header actions)
|
||||
rl.syncViewContextFromView(newView)
|
||||
|
||||
// Update statusline stats from the view
|
||||
rl.updateStatuslineViewStats(newView)
|
||||
|
|
@ -169,6 +161,13 @@ func (rl *RootLayout) onLayoutChange() {
|
|||
})
|
||||
}
|
||||
|
||||
// Wire up action change notifications (registry or enablement changes on the same view)
|
||||
if notifier, ok := newView.(controller.ActionChangeNotifier); ok {
|
||||
notifier.SetActionChangeHandler(func() {
|
||||
rl.syncViewContextFromView(newView)
|
||||
})
|
||||
}
|
||||
|
||||
// Focus the view
|
||||
newView.OnFocus()
|
||||
if newView.GetViewID() == model.TaskEditViewID {
|
||||
|
|
@ -199,6 +198,32 @@ func (rl *RootLayout) onLayoutChange() {
|
|||
rl.app.SetFocus(newView.GetPrimitive())
|
||||
}
|
||||
|
||||
// syncViewContextFromView computes view name/description, view actions, and plugin actions
|
||||
// from the active view, then writes to both ViewContext (single atomic update) and HeaderConfig
|
||||
// (for backward-compatible header display during the transition period).
|
||||
func (rl *RootLayout) syncViewContextFromView(v controller.View) {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
|
||||
viewActions := v.GetActionRegistry().ToHeaderActions()
|
||||
|
||||
var pluginActions []model.HeaderAction
|
||||
if np, ok := v.(controller.NavigationProvider); ok && np.ShowNavigation() {
|
||||
pluginActions = controller.GetPluginActions().ToHeaderActions()
|
||||
}
|
||||
|
||||
var viewName, viewDesc string
|
||||
if vip, ok := v.(controller.ViewInfoProvider); ok {
|
||||
viewName = vip.GetViewName()
|
||||
viewDesc = vip.GetViewDescription()
|
||||
}
|
||||
|
||||
if rl.viewContext != nil {
|
||||
rl.viewContext.SetFromView(v.GetViewID(), viewName, viewDesc, viewActions, pluginActions)
|
||||
}
|
||||
}
|
||||
|
||||
// recomputeHeaderVisibility computes header visibility based on view requirements and user preference
|
||||
func (rl *RootLayout) recomputeHeaderVisibility(v controller.View) {
|
||||
// Start from user preference
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// SearchBox is a single-line input field with a "> " prompt
|
||||
type SearchBox struct {
|
||||
*tview.InputField
|
||||
onSubmit func(text string)
|
||||
onCancel func()
|
||||
}
|
||||
|
||||
// NewSearchBox creates a new search box widget
|
||||
func NewSearchBox() *SearchBox {
|
||||
colors := config.GetColors()
|
||||
inputField := tview.NewInputField()
|
||||
|
||||
// Configure the input field (border drawn manually in Draw)
|
||||
inputField.SetLabel("> ")
|
||||
inputField.SetLabelColor(colors.SearchBoxLabelColor.TCell())
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
|
||||
inputField.SetBorder(false)
|
||||
|
||||
sb := &SearchBox{
|
||||
InputField: inputField,
|
||||
}
|
||||
|
||||
return sb
|
||||
}
|
||||
|
||||
// SetSubmitHandler sets the callback for when Enter is pressed
|
||||
func (sb *SearchBox) SetSubmitHandler(handler func(text string)) *SearchBox {
|
||||
sb.onSubmit = handler
|
||||
return sb
|
||||
}
|
||||
|
||||
// SetCancelHandler sets the callback for when Escape is pressed
|
||||
func (sb *SearchBox) SetCancelHandler(handler func()) *SearchBox {
|
||||
sb.onCancel = handler
|
||||
return sb
|
||||
}
|
||||
|
||||
// Clear clears the search text
|
||||
func (sb *SearchBox) Clear() *SearchBox {
|
||||
sb.SetText("")
|
||||
return sb
|
||||
}
|
||||
|
||||
// Draw renders the search box with single-line borders
|
||||
// (overrides InputField.Draw to avoid double-line focus borders)
|
||||
func (sb *SearchBox) Draw(screen tcell.Screen) {
|
||||
x, y, width, height := sb.GetRect()
|
||||
if width <= 0 || height <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Fill interior with theme-aware background color
|
||||
bgColor := config.GetColors().ContentBackgroundColor.TCell()
|
||||
bgStyle := tcell.StyleDefault.Background(bgColor)
|
||||
for row := y; row < y+height; row++ {
|
||||
for col := x; col < x+width; col++ {
|
||||
screen.SetContent(col, row, ' ', nil, bgStyle)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw single-line border using shared utility
|
||||
DrawSingleLineBorder(screen, x, y, width, height)
|
||||
|
||||
// Draw InputField inside border (offset by 1 for border)
|
||||
sb.SetRect(x+1, y+1, width-2, height-2)
|
||||
sb.InputField.Draw(screen)
|
||||
}
|
||||
|
||||
// InputHandler handles key input for the search box
|
||||
func (sb *SearchBox) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
return sb.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
key := event.Key()
|
||||
|
||||
// Handle submit and cancel
|
||||
switch key {
|
||||
case tcell.KeyEnter:
|
||||
if sb.onSubmit != nil {
|
||||
sb.onSubmit(sb.GetText())
|
||||
}
|
||||
return
|
||||
case tcell.KeyEscape:
|
||||
if sb.onCancel != nil {
|
||||
sb.onCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Only allow typing and basic editing - block everything else
|
||||
if sb.isAllowedKey(event) {
|
||||
handler := sb.InputField.InputHandler()
|
||||
if handler != nil {
|
||||
handler(event, setFocus)
|
||||
}
|
||||
}
|
||||
// All other keys silently ignored (consumed)
|
||||
})
|
||||
}
|
||||
|
||||
// isAllowedKey returns true if the key should be processed by the InputField
|
||||
func (sb *SearchBox) isAllowedKey(event *tcell.EventKey) bool {
|
||||
key := event.Key()
|
||||
|
||||
// Allow basic editing keys
|
||||
switch key {
|
||||
case tcell.KeyBackspace, tcell.KeyBackspace2, tcell.KeyDelete:
|
||||
return true
|
||||
|
||||
// Allow printable characters (letters, digits, symbols)
|
||||
case tcell.KeyRune:
|
||||
return true
|
||||
}
|
||||
|
||||
// Block everything else:
|
||||
// - All arrows (Left, Right, Up, Down)
|
||||
// - Tab, Home, End, PageUp, PageDown
|
||||
// - All function keys (F1-F12)
|
||||
// - All control sequences
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// SearchHelper provides reusable search box integration to eliminate duplication across views
|
||||
type SearchHelper struct {
|
||||
searchBox *SearchBox
|
||||
searchVisible bool
|
||||
onSearchSubmit func(text string)
|
||||
focusSetter func(p tview.Primitive)
|
||||
}
|
||||
|
||||
// NewSearchHelper creates a new search helper with an initialized search box
|
||||
func NewSearchHelper(contentPrimitive tview.Primitive) *SearchHelper {
|
||||
helper := &SearchHelper{
|
||||
searchBox: NewSearchBox(),
|
||||
}
|
||||
|
||||
// Wire up internal handlers - the search box will call these
|
||||
helper.searchBox.SetSubmitHandler(func(text string) {
|
||||
if helper.onSearchSubmit != nil {
|
||||
helper.onSearchSubmit(text)
|
||||
}
|
||||
// Transfer focus back to content after search
|
||||
if helper.focusSetter != nil {
|
||||
helper.focusSetter(contentPrimitive)
|
||||
}
|
||||
})
|
||||
|
||||
return helper
|
||||
}
|
||||
|
||||
// SetSubmitHandler sets the handler called when user submits a search query
|
||||
// This is typically wired to the controller's HandleSearch method
|
||||
func (sh *SearchHelper) SetSubmitHandler(handler func(text string)) {
|
||||
sh.onSearchSubmit = handler
|
||||
}
|
||||
|
||||
// SetCancelHandler sets the handler called when user cancels search (Escape key)
|
||||
// This is typically wired to the view's HideSearch method
|
||||
func (sh *SearchHelper) SetCancelHandler(handler func()) {
|
||||
sh.searchBox.SetCancelHandler(handler)
|
||||
}
|
||||
|
||||
// SetFocusSetter sets the function used to change focus between primitives
|
||||
// This is typically app.SetFocus and is provided by the InputRouter
|
||||
func (sh *SearchHelper) SetFocusSetter(setter func(p tview.Primitive)) {
|
||||
sh.focusSetter = setter
|
||||
}
|
||||
|
||||
// ShowSearch makes the search box visible and returns it for focus management
|
||||
// currentQuery: the query text to restore (e.g., when returning from task detail)
|
||||
func (sh *SearchHelper) ShowSearch(currentQuery string) tview.Primitive {
|
||||
sh.searchVisible = true
|
||||
sh.searchBox.SetText(currentQuery)
|
||||
return sh.searchBox
|
||||
}
|
||||
|
||||
// HideSearch clears and hides the search box
|
||||
func (sh *SearchHelper) HideSearch() {
|
||||
sh.searchVisible = false
|
||||
sh.searchBox.Clear()
|
||||
}
|
||||
|
||||
// IsVisible returns true if the search box is currently visible
|
||||
func (sh *SearchHelper) IsVisible() bool {
|
||||
return sh.searchVisible
|
||||
}
|
||||
|
||||
// HasFocus returns true if the search box currently has focus
|
||||
func (sh *SearchHelper) HasFocus() bool {
|
||||
return sh.searchVisible && sh.searchBox.HasFocus()
|
||||
}
|
||||
|
||||
// GetFocusSetter returns the focus setter function for transferring focus after layout changes
|
||||
func (sh *SearchHelper) GetFocusSetter() func(p tview.Primitive) {
|
||||
return sh.focusSetter
|
||||
}
|
||||
|
||||
// GetSearchBox returns the underlying search box primitive for layout building
|
||||
func (sh *SearchHelper) GetSearchBox() *SearchBox {
|
||||
return sh.searchBox
|
||||
}
|
||||
|
|
@ -78,6 +78,16 @@ func (tv *TaskDetailView) OnFocus() {
|
|||
tv.refresh()
|
||||
}
|
||||
|
||||
// RestoreFocus sets focus to the current description viewer (which may have been
|
||||
// rebuilt by a store-driven refresh while the palette was open).
|
||||
func (tv *TaskDetailView) RestoreFocus() bool {
|
||||
if tv.descView != nil && tv.focusSetter != nil {
|
||||
tv.focusSetter(tv.descView)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// OnBlur is called when the view becomes inactive
|
||||
func (tv *TaskDetailView) OnBlur() {
|
||||
if tv.storeListenerID != 0 {
|
||||
|
|
|
|||
|
|
@ -256,11 +256,13 @@ func (ev *TaskEditView) IsRecurrenceValueFocused() bool {
|
|||
func (ev *TaskEditView) UpdateHeaderForField(field model.EditField) {
|
||||
if ev.descOnly {
|
||||
ev.registry = controller.DescOnlyEditActions()
|
||||
return
|
||||
}
|
||||
if ev.tagsOnly {
|
||||
} else if ev.tagsOnly {
|
||||
ev.registry = controller.TagsOnlyEditActions()
|
||||
return
|
||||
} else {
|
||||
ev.registry = controller.GetActionsForField(field)
|
||||
}
|
||||
|
||||
if ev.actionChangeHandler != nil {
|
||||
ev.actionChangeHandler()
|
||||
}
|
||||
ev.registry = controller.GetActionsForField(field)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,28 +45,34 @@ type TaskEditView struct {
|
|||
tagsEditing bool
|
||||
|
||||
// All callbacks
|
||||
onTitleSave func(string)
|
||||
onTitleChange func(string)
|
||||
onTitleCancel func()
|
||||
onDescSave func(string)
|
||||
onDescCancel func()
|
||||
onStatusSave func(string)
|
||||
onTypeSave func(string)
|
||||
onPrioritySave func(int)
|
||||
onAssigneeSave func(string)
|
||||
onPointsSave func(int)
|
||||
onDueSave func(string)
|
||||
onRecurrenceSave func(string)
|
||||
onTagsSave func(string)
|
||||
onTagsCancel func()
|
||||
onTitleSave func(string)
|
||||
onTitleChange func(string)
|
||||
onTitleCancel func()
|
||||
onDescSave func(string)
|
||||
onDescCancel func()
|
||||
onStatusSave func(string)
|
||||
onTypeSave func(string)
|
||||
onPrioritySave func(int)
|
||||
onAssigneeSave func(string)
|
||||
onPointsSave func(int)
|
||||
onDueSave func(string)
|
||||
onRecurrenceSave func(string)
|
||||
onTagsSave func(string)
|
||||
onTagsCancel func()
|
||||
actionChangeHandler func()
|
||||
}
|
||||
|
||||
// Compile-time interface checks
|
||||
var (
|
||||
_ controller.View = (*TaskEditView)(nil)
|
||||
_ controller.FocusSettable = (*TaskEditView)(nil)
|
||||
_ controller.View = (*TaskEditView)(nil)
|
||||
_ controller.FocusSettable = (*TaskEditView)(nil)
|
||||
_ controller.ActionChangeNotifier = (*TaskEditView)(nil)
|
||||
)
|
||||
|
||||
func (ev *TaskEditView) SetActionChangeHandler(handler func()) {
|
||||
ev.actionChangeHandler = handler
|
||||
}
|
||||
|
||||
// NewTaskEditView creates a task edit view
|
||||
func NewTaskEditView(taskStore store.Store, taskID string, imageManager *navtview.ImageManager) *TaskEditView {
|
||||
ev := &TaskEditView{
|
||||
|
|
@ -75,7 +81,7 @@ func NewTaskEditView(taskStore store.Store, taskID string, imageManager *navtvie
|
|||
taskID: taskID,
|
||||
imageManager: imageManager,
|
||||
},
|
||||
registry: controller.TaskEditViewActions(),
|
||||
registry: controller.GetActionsForField(model.EditFieldTitle),
|
||||
viewID: model.TaskEditViewID,
|
||||
focusedField: model.EditFieldTitle,
|
||||
titleEditing: true,
|
||||
|
|
@ -142,7 +148,11 @@ func (ev *TaskEditView) SetDescOnly(descOnly bool) {
|
|||
ev.descOnly = descOnly
|
||||
if descOnly {
|
||||
ev.focusedField = model.EditFieldDescription
|
||||
ev.registry = controller.DescOnlyEditActions()
|
||||
ev.refresh()
|
||||
if ev.actionChangeHandler != nil {
|
||||
ev.actionChangeHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +166,11 @@ func (ev *TaskEditView) IsDescOnly() bool {
|
|||
func (ev *TaskEditView) SetTagsOnly(tagsOnly bool) {
|
||||
ev.tagsOnly = tagsOnly
|
||||
if tagsOnly {
|
||||
ev.registry = controller.TagsOnlyEditActions()
|
||||
ev.refresh()
|
||||
if ev.actionChangeHandler != nil {
|
||||
ev.actionChangeHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import (
|
|||
type PluginView struct {
|
||||
root *tview.Flex
|
||||
titleBar tview.Primitive
|
||||
searchHelper *SearchHelper
|
||||
inputHelper *InputHelper
|
||||
lanes *tview.Flex
|
||||
laneBoxes []*ScrollableList
|
||||
taskStore store.Store
|
||||
|
|
@ -29,6 +29,7 @@ type PluginView struct {
|
|||
selectionListenerID int
|
||||
getLaneTasks func(lane int) []*task.Task // injected from controller
|
||||
ensureSelection func() bool // injected from controller
|
||||
actionChangeHandler func()
|
||||
}
|
||||
|
||||
// NewPluginView creates a plugin view
|
||||
|
|
@ -81,10 +82,16 @@ func (pv *PluginView) build() {
|
|||
pv.lanes = tview.NewFlex().SetDirection(tview.FlexColumn)
|
||||
pv.laneBoxes = make([]*ScrollableList, 0, len(pv.pluginDef.Lanes))
|
||||
|
||||
// search helper - focus returns to lanes container
|
||||
pv.searchHelper = NewSearchHelper(pv.lanes)
|
||||
pv.searchHelper.SetCancelHandler(func() {
|
||||
pv.HideSearch()
|
||||
// input helper - focus returns to lanes container
|
||||
pv.inputHelper = NewInputHelper(pv.lanes)
|
||||
pv.inputHelper.SetCancelHandler(func() {
|
||||
pv.cancelCurrentInput()
|
||||
})
|
||||
pv.inputHelper.SetCloseHandler(func() {
|
||||
pv.removeInputBoxFromLayout()
|
||||
})
|
||||
pv.inputHelper.SetRestorePassiveHandler(func(_ string) {
|
||||
// layout already has the input box; no rebuild needed
|
||||
})
|
||||
|
||||
// root layout
|
||||
|
|
@ -94,16 +101,18 @@ func (pv *PluginView) build() {
|
|||
pv.refresh()
|
||||
}
|
||||
|
||||
// rebuildLayout rebuilds the root layout based on current state (search visibility)
|
||||
// rebuildLayout rebuilds the root layout based on current state
|
||||
func (pv *PluginView) rebuildLayout() {
|
||||
pv.root.Clear()
|
||||
pv.root.AddItem(pv.titleBar, 1, 0, false)
|
||||
|
||||
// Restore search box if search is active (e.g., returning from task details)
|
||||
if pv.pluginConfig.IsSearchActive() {
|
||||
if pv.inputHelper.IsVisible() {
|
||||
pv.root.AddItem(pv.inputHelper.GetInputBox(), config.InputBoxHeight, 0, false)
|
||||
pv.root.AddItem(pv.lanes, 0, 1, false)
|
||||
} else if pv.pluginConfig.IsSearchActive() {
|
||||
query := pv.pluginConfig.GetSearchQuery()
|
||||
pv.searchHelper.ShowSearch(query)
|
||||
pv.root.AddItem(pv.searchHelper.GetSearchBox(), config.SearchBoxHeight, 0, false)
|
||||
pv.inputHelper.Show("> ", query, inputModeSearchPassive)
|
||||
pv.root.AddItem(pv.inputHelper.GetInputBox(), config.InputBoxHeight, 0, false)
|
||||
pv.root.AddItem(pv.lanes, 0, 1, false)
|
||||
} else {
|
||||
pv.root.AddItem(pv.lanes, 0, 1, true)
|
||||
|
|
@ -186,6 +195,36 @@ func (pv *PluginView) refresh() {
|
|||
// Sync scroll offset from view to model for later lane navigation
|
||||
pv.pluginConfig.SetScrollOffsetForLane(laneIdx, laneContainer.GetScrollOffset())
|
||||
}
|
||||
|
||||
if pv.actionChangeHandler != nil {
|
||||
pv.actionChangeHandler()
|
||||
}
|
||||
}
|
||||
|
||||
func (pv *PluginView) GetSelectedID() string {
|
||||
lane := pv.pluginConfig.GetSelectedLane()
|
||||
tasks := pv.getLaneTasks(lane)
|
||||
idx := pv.pluginConfig.GetSelectedIndexForLane(lane)
|
||||
if idx < 0 || idx >= len(tasks) {
|
||||
return ""
|
||||
}
|
||||
return tasks[idx].ID
|
||||
}
|
||||
|
||||
func (pv *PluginView) SetSelectedID(id string) {
|
||||
for lane := range pv.pluginDef.Lanes {
|
||||
for i, t := range pv.getLaneTasks(lane) {
|
||||
if t.ID == id {
|
||||
pv.pluginConfig.SetSelectedLane(lane)
|
||||
pv.pluginConfig.SetSelectedIndexForLane(lane, i)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pv *PluginView) SetActionChangeHandler(handler func()) {
|
||||
pv.actionChangeHandler = handler
|
||||
}
|
||||
|
||||
// GetPrimitive returns the root tview primitive
|
||||
|
|
@ -225,64 +264,104 @@ func (pv *PluginView) OnBlur() {
|
|||
pv.pluginConfig.RemoveSelectionListener(pv.selectionListenerID)
|
||||
}
|
||||
|
||||
// ShowSearch displays the search box and returns the primitive to focus
|
||||
func (pv *PluginView) ShowSearch() tview.Primitive {
|
||||
if pv.searchHelper.IsVisible() {
|
||||
return pv.searchHelper.GetSearchBox()
|
||||
// ShowInputBox displays the input box with the given prompt and initial text.
|
||||
// If search is currently passive, action-input temporarily replaces it.
|
||||
func (pv *PluginView) ShowInputBox(prompt, initial string) tview.Primitive {
|
||||
wasVisible := pv.inputHelper.IsVisible()
|
||||
|
||||
inputBox := pv.inputHelper.Show(prompt, initial, inputModeActionInput)
|
||||
|
||||
if !wasVisible {
|
||||
pv.root.Clear()
|
||||
pv.root.AddItem(pv.titleBar, 1, 0, false)
|
||||
pv.root.AddItem(pv.inputHelper.GetInputBox(), config.InputBoxHeight, 0, true)
|
||||
pv.root.AddItem(pv.lanes, 0, 1, false)
|
||||
}
|
||||
|
||||
query := pv.pluginConfig.GetSearchQuery()
|
||||
searchBox := pv.searchHelper.ShowSearch(query)
|
||||
|
||||
// Rebuild layout with search box
|
||||
pv.root.Clear()
|
||||
pv.root.AddItem(pv.titleBar, 1, 0, false)
|
||||
pv.root.AddItem(pv.searchHelper.GetSearchBox(), config.SearchBoxHeight, 0, true)
|
||||
pv.root.AddItem(pv.lanes, 0, 1, false)
|
||||
|
||||
return searchBox
|
||||
return inputBox
|
||||
}
|
||||
|
||||
// HideSearch hides the search box and clears search results
|
||||
func (pv *PluginView) HideSearch() {
|
||||
if !pv.searchHelper.IsVisible() {
|
||||
// ShowSearchBox opens the input box in search-editing mode.
|
||||
func (pv *PluginView) ShowSearchBox() tview.Primitive {
|
||||
inputBox := pv.inputHelper.ShowSearch("")
|
||||
|
||||
pv.root.Clear()
|
||||
pv.root.AddItem(pv.titleBar, 1, 0, false)
|
||||
pv.root.AddItem(pv.inputHelper.GetInputBox(), config.InputBoxHeight, 0, true)
|
||||
pv.root.AddItem(pv.lanes, 0, 1, false)
|
||||
|
||||
return inputBox
|
||||
}
|
||||
|
||||
// HideInputBox hides the input box without touching search state.
|
||||
func (pv *PluginView) HideInputBox() {
|
||||
if !pv.inputHelper.IsVisible() {
|
||||
return
|
||||
}
|
||||
pv.inputHelper.Hide()
|
||||
pv.removeInputBoxFromLayout()
|
||||
}
|
||||
|
||||
pv.searchHelper.HideSearch()
|
||||
|
||||
// Clear search results (restores pre-search selection)
|
||||
pv.pluginConfig.ClearSearchResults()
|
||||
|
||||
// Rebuild layout without search box
|
||||
// removeInputBoxFromLayout rebuilds the layout without the input box and restores focus.
|
||||
func (pv *PluginView) removeInputBoxFromLayout() {
|
||||
pv.root.Clear()
|
||||
pv.root.AddItem(pv.titleBar, 1, 0, false)
|
||||
pv.root.AddItem(pv.lanes, 0, 1, true)
|
||||
|
||||
// explicitly transfer focus to lanes (clears cursor from removed search box)
|
||||
if pv.searchHelper.GetFocusSetter() != nil {
|
||||
pv.searchHelper.GetFocusSetter()(pv.lanes)
|
||||
if pv.inputHelper.GetFocusSetter() != nil {
|
||||
pv.inputHelper.GetFocusSetter()(pv.lanes)
|
||||
}
|
||||
}
|
||||
|
||||
// IsSearchVisible returns whether the search box is currently visible
|
||||
func (pv *PluginView) IsSearchVisible() bool {
|
||||
return pv.searchHelper.IsVisible()
|
||||
// cancelCurrentInput handles Esc based on the current input mode.
|
||||
func (pv *PluginView) cancelCurrentInput() {
|
||||
switch pv.inputHelper.Mode() {
|
||||
case inputModeSearchEditing, inputModeSearchPassive:
|
||||
pv.inputHelper.Hide()
|
||||
pv.pluginConfig.ClearSearchResults()
|
||||
pv.removeInputBoxFromLayout()
|
||||
case inputModeActionInput:
|
||||
// finishInput handles restore-passive-search vs full-close
|
||||
pv.inputHelper.finishInput()
|
||||
default:
|
||||
pv.inputHelper.Hide()
|
||||
pv.removeInputBoxFromLayout()
|
||||
}
|
||||
}
|
||||
|
||||
// IsSearchBoxFocused returns whether the search box currently has focus
|
||||
func (pv *PluginView) IsSearchBoxFocused() bool {
|
||||
return pv.searchHelper.HasFocus()
|
||||
// CancelInputBox triggers mode-aware cancel from the router
|
||||
func (pv *PluginView) CancelInputBox() {
|
||||
pv.cancelCurrentInput()
|
||||
}
|
||||
|
||||
// SetSearchSubmitHandler sets the callback for when search is submitted
|
||||
func (pv *PluginView) SetSearchSubmitHandler(handler func(text string)) {
|
||||
pv.searchHelper.SetSubmitHandler(handler)
|
||||
// IsInputBoxVisible returns whether the input box is currently visible
|
||||
func (pv *PluginView) IsInputBoxVisible() bool {
|
||||
return pv.inputHelper.IsVisible()
|
||||
}
|
||||
|
||||
// IsInputBoxFocused returns whether the input box currently has focus
|
||||
func (pv *PluginView) IsInputBoxFocused() bool {
|
||||
return pv.inputHelper.HasFocus()
|
||||
}
|
||||
|
||||
// IsSearchPassive returns true if search is applied and the input box is passive
|
||||
func (pv *PluginView) IsSearchPassive() bool {
|
||||
return pv.inputHelper.IsSearchPassive()
|
||||
}
|
||||
|
||||
// SetInputSubmitHandler sets the callback for when input is submitted
|
||||
func (pv *PluginView) SetInputSubmitHandler(handler func(text string) controller.InputSubmitResult) {
|
||||
pv.inputHelper.SetSubmitHandler(handler)
|
||||
}
|
||||
|
||||
// SetInputCancelHandler sets the callback for when input is cancelled
|
||||
func (pv *PluginView) SetInputCancelHandler(handler func()) {
|
||||
pv.inputHelper.SetCancelHandler(handler)
|
||||
}
|
||||
|
||||
// SetFocusSetter sets the callback for requesting focus changes
|
||||
func (pv *PluginView) SetFocusSetter(setter func(p tview.Primitive)) {
|
||||
pv.searchHelper.SetFocusSetter(setter)
|
||||
pv.inputHelper.SetFocusSetter(setter)
|
||||
}
|
||||
|
||||
// GetStats returns stats for the header and statusline (Total count of filtered tasks)
|
||||
|
|
|
|||
21
workflows/bug-tracker/new.md
Normal file
21
workflows/bug-tracker/new.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
title:
|
||||
type: bug
|
||||
priority: 3
|
||||
severity: medium
|
||||
environment:
|
||||
reportedBy:
|
||||
foundIn:
|
||||
dueBy:
|
||||
regression: false
|
||||
escalations: 0
|
||||
---
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
|
||||
## Expected
|
||||
|
||||
## Actual
|
||||
|
||||
249
workflows/bug-tracker/workflow.yaml
Normal file
249
workflows/bug-tracker/workflow.yaml
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
version: 0.5.0
|
||||
description: |
|
||||
Bug tracker for triaging and resolving customer-reported issues.
|
||||
Six statuses (Open → Triaged → In Progress → In Review → Verified, plus Won't Fix),
|
||||
three task types (Bug, Regression, Incident), and custom fields for severity,
|
||||
environment, reporter, foundIn version, due date, regression flag, and escalation count.
|
||||
fields:
|
||||
- name: severity
|
||||
type: enum
|
||||
values: [critical, high, medium, low]
|
||||
- name: environment
|
||||
type: enum
|
||||
values: [prod, staging, dev]
|
||||
- name: reportedBy
|
||||
type: text
|
||||
- name: foundIn
|
||||
type: text
|
||||
- name: dueBy
|
||||
type: datetime
|
||||
- name: regression
|
||||
type: boolean
|
||||
- name: escalations
|
||||
type: integer
|
||||
|
||||
statuses:
|
||||
- key: open
|
||||
label: Open
|
||||
emoji: "\U0001F195"
|
||||
default: true
|
||||
- key: triaged
|
||||
label: Triaged
|
||||
emoji: "\U0001F4CB"
|
||||
active: true
|
||||
- key: inProgress
|
||||
label: "In Progress"
|
||||
emoji: "\u2699\uFE0F"
|
||||
active: true
|
||||
- key: inReview
|
||||
label: "In Review"
|
||||
emoji: "\U0001F440"
|
||||
active: true
|
||||
- key: verified
|
||||
label: Verified
|
||||
emoji: "\u2705"
|
||||
done: true
|
||||
- key: wontFix
|
||||
label: "Won't Fix"
|
||||
emoji: "\U0001F6AB"
|
||||
|
||||
types:
|
||||
- key: bug
|
||||
label: Bug
|
||||
emoji: "\U0001F41B"
|
||||
- key: regression
|
||||
label: Regression
|
||||
emoji: "\U0001F501"
|
||||
- key: incident
|
||||
label: Incident
|
||||
emoji: "\U0001F525"
|
||||
|
||||
views:
|
||||
actions:
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: update where id = id() set assignee=user()
|
||||
- key: "y"
|
||||
label: "Copy ID"
|
||||
action: select id where id = id() | clipboard()
|
||||
- key: "Y"
|
||||
label: "Copy content"
|
||||
action: select title, description where id = id() | clipboard()
|
||||
- key: "+"
|
||||
label: "Priority up"
|
||||
action: update where id = id() set priority = priority - 1
|
||||
- key: "-"
|
||||
label: "Priority down"
|
||||
action: update where id = id() set priority = priority + 1
|
||||
- key: "R"
|
||||
label: "Mark regression"
|
||||
action: update where id = id() set regression=true
|
||||
- key: "r"
|
||||
label: "Clear regression"
|
||||
action: update where id = id() set regression=false
|
||||
- key: "E"
|
||||
label: "Escalate"
|
||||
action: update where id = id() set escalations=escalations + 1
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
hot: false
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags=tags+[input()]
|
||||
input: string
|
||||
hot: false
|
||||
- key: "T"
|
||||
label: "Remove tag"
|
||||
action: update where id = id() set tags=tags-[input()]
|
||||
input: string
|
||||
hot: false
|
||||
plugins:
|
||||
- name: Triage
|
||||
description: "Bug triage board — move bugs through the resolution pipeline"
|
||||
default: true
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Open
|
||||
filter: select where status = "open" order by priority, createdAt
|
||||
action: update where id = id() set status="open"
|
||||
- name: Triaged
|
||||
filter: select where status = "triaged" order by priority, createdAt
|
||||
action: update where id = id() set status="triaged"
|
||||
- name: In Progress
|
||||
filter: select where status = "inProgress" order by priority, createdAt
|
||||
action: update where id = id() set status="inProgress"
|
||||
- name: In Review
|
||||
filter: select where status = "inReview" order by priority, createdAt
|
||||
action: update where id = id() set status="inReview"
|
||||
- name: Verified
|
||||
filter: select where status = "verified" order by priority, createdAt
|
||||
action: update where id = id() set status="verified"
|
||||
actions:
|
||||
- key: "w"
|
||||
label: "Won't fix"
|
||||
action: update where id = id() set status="wontFix"
|
||||
- name: Severity
|
||||
description: "Bugs grouped by severity level"
|
||||
key: "F2"
|
||||
lanes:
|
||||
- name: Critical
|
||||
columns: 2
|
||||
width: 40
|
||||
filter: select where severity = "critical" and status != "verified" and status != "wontFix" order by priority, createdAt
|
||||
action: update where id = id() set severity="critical"
|
||||
- name: High
|
||||
filter: select where severity = "high" and status != "verified" and status != "wontFix" order by priority, createdAt
|
||||
action: update where id = id() set severity="high"
|
||||
- name: Medium
|
||||
filter: select where severity = "medium" and status != "verified" and status != "wontFix" order by priority, createdAt
|
||||
action: update where id = id() set severity="medium"
|
||||
- name: Low
|
||||
filter: select where severity = "low" and status != "verified" and status != "wontFix" order by priority, createdAt
|
||||
action: update where id = id() set severity="low"
|
||||
actions:
|
||||
- key: "c"
|
||||
label: "Clear severity"
|
||||
action: update where id = id() set severity=empty
|
||||
- name: Environments
|
||||
description: "Bugs grouped by environment — prod, staging, dev"
|
||||
key: "F3"
|
||||
lanes:
|
||||
- name: Prod
|
||||
columns: 1
|
||||
filter: select where environment = "prod" and status != "verified" and status != "wontFix" order by priority, createdAt
|
||||
action: update where id = id() set environment="prod"
|
||||
- name: Staging
|
||||
columns: 1
|
||||
filter: select where environment = "staging" and status != "verified" and status != "wontFix" order by priority, createdAt
|
||||
action: update where id = id() set environment="staging"
|
||||
- name: Dev
|
||||
columns: 1
|
||||
filter: select where environment = "dev" and status != "verified" and status != "wontFix" order by priority, createdAt
|
||||
action: update where id = id() set environment="dev"
|
||||
- name: Unspecified
|
||||
columns: 1
|
||||
filter: select where environment is empty and status != "verified" and status != "wontFix" order by priority, createdAt
|
||||
- name: SLA Watch
|
||||
description: "Deadline tracking — overdue, due today, this week, and unscheduled"
|
||||
key: "F4"
|
||||
view: expanded
|
||||
lanes:
|
||||
- name: Overdue
|
||||
filter: select where dueBy is not empty and dueBy < now() and status != "verified" and status != "wontFix" order by dueBy
|
||||
- name: Due Today
|
||||
filter: select where dueBy >= now() and dueBy < now() + 1day order by priority
|
||||
- name: This Week
|
||||
filter: select where dueBy >= now() + 1day and dueBy < now() + 7day order by dueBy
|
||||
- name: No SLA
|
||||
filter: select where dueBy is empty and status != "verified" and status != "wontFix" order by priority
|
||||
actions:
|
||||
- key: "d"
|
||||
label: "Clear deadline"
|
||||
action: update where id = id() set dueBy=empty
|
||||
- key: "D"
|
||||
label: "Bump one week"
|
||||
action: update where id = id() set dueBy=now() + 7day
|
||||
- name: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
fetcher: file
|
||||
url: "index.md"
|
||||
key: "F5"
|
||||
|
||||
triggers:
|
||||
- description: critical bugs must specify environment
|
||||
ruki: >
|
||||
before create
|
||||
where new.severity = "critical" and new.environment is empty
|
||||
deny "critical bugs require environment"
|
||||
- description: bugs must pass through review before verification
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "verified" and old.status != "inReview"
|
||||
deny "bugs must pass through review before verification"
|
||||
- description: per-assignee WIP limit of 3 in-progress bugs
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "inProgress"
|
||||
and count(select where assignee = new.assignee and status = "inProgress") > 2
|
||||
deny "assignee already at WIP limit (3 in-progress bugs)"
|
||||
- description: cannot delete an active bug
|
||||
ruki: >
|
||||
before delete
|
||||
where old.status = "inProgress" or old.status = "inReview"
|
||||
deny "cannot delete an active bug — move to wontFix or verified first"
|
||||
- description: auto-prioritize critical bugs and set 24h SLA
|
||||
ruki: >
|
||||
after create
|
||||
where new.severity = "critical"
|
||||
update where id = new.id set priority=1 dueBy=now() + 1day
|
||||
- description: tag and bump priority when regression is flagged
|
||||
ruki: >
|
||||
after update
|
||||
where new.regression = true and old.regression = false
|
||||
update where id = new.id set tags=tags + ["regression"] priority=1
|
||||
- description: clear SLA on closure
|
||||
ruki: >
|
||||
after update
|
||||
where new.status = "verified" or new.status = "wontFix"
|
||||
update where id = new.id set dueBy=empty
|
||||
- description: remove deleted task from dependency lists
|
||||
ruki: >
|
||||
after delete
|
||||
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
- description: auto-escalate overdue critical bugs every hour
|
||||
ruki: >
|
||||
every 1hour
|
||||
update where severity = "critical" and dueBy < now() and status != "verified" and status != "wontFix"
|
||||
set escalations=escalations + 1
|
||||
- description: purge closed bugs older than 30 days
|
||||
ruki: >
|
||||
every 1day
|
||||
delete where (status = "verified" or status = "wontFix") and updatedAt < now() - 30day
|
||||
- description: demote stale in-progress bugs back to triaged
|
||||
ruki: >
|
||||
every 1day
|
||||
update where status = "inProgress" and updatedAt < now() - 7day
|
||||
set status="triaged"
|
||||
7
workflows/kanban/new.md
Normal file
7
workflows/kanban/new.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title:
|
||||
points: 1
|
||||
priority: 3
|
||||
tags:
|
||||
- idea
|
||||
---
|
||||
181
workflows/kanban/workflow.yaml
Normal file
181
workflows/kanban/workflow.yaml
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
version: 0.5.0
|
||||
description: |
|
||||
Kanban-style workflow for small-team software work. Five statuses
|
||||
(Backlog → Ready → In Progress → Review → Done) and four task types
|
||||
(Story, Bug, Spike, Epic). Includes an "assign to me" action.
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
emoji: "📥"
|
||||
default: true
|
||||
- key: ready
|
||||
label: Ready
|
||||
emoji: "📋"
|
||||
active: true
|
||||
- key: inProgress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
- key: review
|
||||
label: Review
|
||||
emoji: "👀"
|
||||
active: true
|
||||
- key: done
|
||||
label: Done
|
||||
emoji: "✅"
|
||||
done: true
|
||||
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
emoji: "🌀"
|
||||
- key: bug
|
||||
label: Bug
|
||||
emoji: "💥"
|
||||
- key: spike
|
||||
label: Spike
|
||||
emoji: "🔍"
|
||||
- key: epic
|
||||
label: Epic
|
||||
emoji: "🗂️"
|
||||
|
||||
views:
|
||||
actions:
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: update where id = id() set assignee=user()
|
||||
- key: "y"
|
||||
label: "Copy ID"
|
||||
action: select id where id = id() | clipboard()
|
||||
- key: "Y"
|
||||
label: "Copy content"
|
||||
action: select title, description where id = id() | clipboard()
|
||||
- key: "+"
|
||||
label: "Priority up"
|
||||
action: update where id = id() set priority = priority - 1
|
||||
- key: "-"
|
||||
label: "Priority down"
|
||||
action: update where id = id() set priority = priority + 1
|
||||
- key: "u"
|
||||
label: "Flag urgent"
|
||||
action: update where id = id() set priority=1 tags=tags+["urgent"]
|
||||
hot: false
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
hot: false
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags=tags+[input()]
|
||||
input: string
|
||||
hot: false
|
||||
- key: "T"
|
||||
label: "Remove tag"
|
||||
action: update where id = id() set tags=tags-[input()]
|
||||
input: string
|
||||
hot: false
|
||||
plugins:
|
||||
- name: Kanban
|
||||
description: "Move tiki to change status, search, create or delete\nShift Left/Right to move"
|
||||
default: true
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: select where status = "ready" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="ready"
|
||||
- name: In Progress
|
||||
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="inProgress"
|
||||
- name: Review
|
||||
filter: select where status = "review" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="review"
|
||||
- name: Done
|
||||
filter: select where status = "done" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="done"
|
||||
- name: Backlog
|
||||
description: "Tasks waiting to be picked up, sorted by priority"
|
||||
key: "F3"
|
||||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: select where status = "backlog" and type != "epic" order by priority, id
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Recent
|
||||
description: "Tasks changed in the last 24 hours, most recent first"
|
||||
key: Ctrl-R
|
||||
lanes:
|
||||
- name: Recent
|
||||
columns: 4
|
||||
filter: select where now() - updatedAt < 24hour order by updatedAt desc
|
||||
- name: Roadmap
|
||||
description: "Epics organized by Now, Next, and Later horizons"
|
||||
key: "F4"
|
||||
lanes:
|
||||
- name: Now
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: select where type = "epic" and status = "ready" order by priority, points desc
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Next
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=1
|
||||
- name: Later
|
||||
columns: 2
|
||||
width: 50
|
||||
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=2
|
||||
view: expanded
|
||||
- name: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
fetcher: file
|
||||
url: "index.md"
|
||||
key: "F2"
|
||||
|
||||
triggers:
|
||||
- description: block completion with open dependencies
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "done" and new.dependsOn any status != "done"
|
||||
deny "cannot complete: has open dependencies"
|
||||
- description: tasks must pass through review before completion
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "done" and old.status != "review"
|
||||
deny "tasks must go through review before marking done"
|
||||
- description: remove deleted task from dependency lists
|
||||
ruki: >
|
||||
after delete
|
||||
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
- description: clean up completed tasks after 24 hours
|
||||
ruki: >
|
||||
every 1day
|
||||
delete where status = "done" and updatedAt < now() - 1day
|
||||
- description: tasks must have an assignee before starting
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "inProgress" and new.assignee is empty
|
||||
deny "assign someone before moving to in-progress"
|
||||
- description: auto-complete epics when all child tasks finish
|
||||
ruki: >
|
||||
after update
|
||||
where new.status = "done" and new.type != "epic"
|
||||
update where type = "epic" and new.id in dependsOn and dependsOn all status = "done"
|
||||
set status="done"
|
||||
- description: cannot delete tasks that are actively being worked
|
||||
ruki: >
|
||||
before delete
|
||||
where old.status = "inProgress"
|
||||
deny "cannot delete an in-progress task — move to backlog or done first"
|
||||
- description: spawn next occurrence when recurring task completes
|
||||
ruki: >
|
||||
after update
|
||||
where new.status = "done" and old.recurrence is not empty
|
||||
create title=old.title priority=old.priority tags=old.tags
|
||||
recurrence=old.recurrence due=next_date(old.recurrence) status="backlog"
|
||||
4
workflows/todo/new.md
Normal file
4
workflows/todo/new.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title:
|
||||
priority: 3
|
||||
---
|
||||
59
workflows/todo/workflow.yaml
Normal file
59
workflows/todo/workflow.yaml
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
version: 0.5.0
|
||||
description: |
|
||||
Minimal two-lane todo list. One task type, two statuses (Todo → Done).
|
||||
Use when you just want a lightweight checklist and don't need prioritization,
|
||||
multiple task types, or review stages.
|
||||
statuses:
|
||||
- key: todo
|
||||
label: Todo
|
||||
emoji: "📌"
|
||||
default: true
|
||||
active: true
|
||||
- key: done
|
||||
label: Done
|
||||
emoji: "✅"
|
||||
done: true
|
||||
|
||||
types:
|
||||
- key: task
|
||||
label: Task
|
||||
emoji: "📝"
|
||||
|
||||
views:
|
||||
actions:
|
||||
- key: "y"
|
||||
label: "Copy ID"
|
||||
action: select id where id = id() | clipboard()
|
||||
- key: "Y"
|
||||
label: "Copy content"
|
||||
action: select title, description where id = id() | clipboard()
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags=tags+[input()]
|
||||
input: string
|
||||
hot: false
|
||||
- key: "T"
|
||||
label: "Remove tag"
|
||||
action: update where id = id() set tags=tags-[input()]
|
||||
input: string
|
||||
hot: false
|
||||
plugins:
|
||||
- name: Todo
|
||||
description: "Simple todo list with two lanes"
|
||||
default: true
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Todo
|
||||
columns: 3
|
||||
filter: select where status = "todo" order by priority, createdAt
|
||||
action: update where id = id() set status="todo"
|
||||
- name: Done
|
||||
columns: 1
|
||||
filter: select where status = "done" order by updatedAt desc
|
||||
action: update where id = id() set status="done"
|
||||
|
||||
triggers:
|
||||
- description: clean up completed tasks after 24 hours
|
||||
ruki: >
|
||||
every 1day
|
||||
delete where status = "done" and updatedAt < now() - 1day
|
||||
Loading…
Reference in a new issue