mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
clipboard builtin
This commit is contained in:
parent
3c658f7332
commit
aefb6a757d
22 changed files with 413 additions and 43 deletions
|
|
@ -3,7 +3,7 @@
|
|||
- [Assign to me](#assign-to-me--plugin-action)
|
||||
- [Add tag to task](#add-tag-to-task--plugin-action)
|
||||
- [Custom status + reject action](#custom-status--reject-action)
|
||||
- [Implement with Claude](#implement-with-claude--pipe-action)
|
||||
- [Implement with Claude Code](#implement-with-claude-code--pipe-action)
|
||||
- [Search all tikis](#search-all-tikis--single-lane-plugin)
|
||||
- [Quick assign](#quick-assign--lane-based-assignment)
|
||||
- [Stale task detection](#stale-task-detection--time-trigger--plugin)
|
||||
|
|
|
|||
|
|
@ -136,11 +136,12 @@ Use `call(...)` in a value:
|
|||
create title=call("echo hi")
|
||||
```
|
||||
|
||||
Pipe select results to a shell command:
|
||||
Pipe select results to a shell command or clipboard:
|
||||
|
||||
```sql
|
||||
select id where id = id() | run("echo $1 | pbcopy")
|
||||
select id, title where status = "done" | run("myscript $1 $2")
|
||||
select id where id = id() | clipboard()
|
||||
select description where id = id() | clipboard()
|
||||
```
|
||||
|
||||
## Before triggers
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ after update where new.status = "in progress" run("echo hello")
|
|||
|
||||
## Shell-related forms
|
||||
|
||||
`ruki` includes three shell-related forms:
|
||||
`ruki` includes four shell-related forms:
|
||||
|
||||
**`call(...)`** — a string-returning expression
|
||||
|
||||
|
|
@ -267,11 +267,20 @@ after update where new.status = "in progress" run("echo hello")
|
|||
- `|` is a statement suffix, not an operator
|
||||
- Example: `select id, title where status = "done" | run("myscript $1 $2")`
|
||||
|
||||
Pipe `| run(...)` is distinct from trigger `run()`:
|
||||
**`| clipboard()` on select** — a pipe suffix
|
||||
|
||||
| Form | Context | Field access | Substitution |
|
||||
- Copies selected field values to the system clipboard
|
||||
- Fields within a row are tab-separated; rows are newline-separated
|
||||
- Takes no arguments — grammar enforces empty parentheses
|
||||
- Cross-platform via `atotto/clipboard` (macOS, Linux, Windows)
|
||||
- Example: `select id where id = id() | clipboard()`
|
||||
|
||||
Comparison of pipe targets and trigger actions:
|
||||
|
||||
| Form | Context | Field access | Behavior |
|
||||
|---|---|---|---|
|
||||
| trigger `run()` | after-trigger action | `old.`, `new.` allowed | expression evaluation |
|
||||
| pipe `| run()` | select suffix | field refs disallowed in command | positional args `$1`, `$2` |
|
||||
| trigger `run()` | after-trigger action | `old.`, `new.` allowed | shell execution via expression evaluation |
|
||||
| pipe `\| run()` | select suffix | field refs disallowed in command | shell execution with positional args `$1`, `$2` |
|
||||
| pipe `\| clipboard()` | select suffix | n/a (no arguments) | writes rows to system clipboard |
|
||||
|
||||
See [Semantics](semantics.md#pipe-actions-on-select) for pipe evaluation model.
|
||||
|
|
|
|||
|
|
@ -63,13 +63,14 @@ delete where id = "TIKI-ABC123"
|
|||
delete where status = "cancelled" and "old" in tags
|
||||
```
|
||||
|
||||
`select` may pipe results to a shell command:
|
||||
`select` may pipe results to a shell command or to the clipboard:
|
||||
|
||||
```sql
|
||||
select id, title where status = "done" | run("myscript $1 $2")
|
||||
select id where id = id() | clipboard()
|
||||
```
|
||||
|
||||
Each row executes the command with field values as positional arguments (`$1`, `$2`).
|
||||
`| run(...)` executes the command for each row with field values as positional arguments (`$1`, `$2`). `| clipboard()` copies the selected fields to the system clipboard.
|
||||
|
||||
## Conditions and expressions
|
||||
|
||||
|
|
|
|||
|
|
@ -173,8 +173,11 @@ For the detailed type rules and built-ins, see [Types And Values](types-and-valu
|
|||
|
||||
```text
|
||||
select <fields> where <condition> | run(<command>)
|
||||
select <fields> where <condition> | clipboard()
|
||||
```
|
||||
|
||||
### `| run(...)` — shell execution
|
||||
|
||||
Evaluation model:
|
||||
|
||||
- The `select` runs first, producing zero or more rows.
|
||||
|
|
@ -203,3 +206,30 @@ myscript "TIKI-ABC123" "Fix bug"
|
|||
|
||||
Pipe `| run(...)` on select is distinct from trigger `run()` actions. See [Triggers](triggers.md) for the difference.
|
||||
|
||||
### `| clipboard()` — copy to clipboard
|
||||
|
||||
Evaluation model:
|
||||
|
||||
- The `select` runs first, producing zero or more rows.
|
||||
- The selected field values are written to the system clipboard.
|
||||
- Fields within a row are **tab-separated**; rows are **newline-separated**.
|
||||
- Uses `atotto/clipboard` internally — works on macOS, Linux (requires `xclip` or `xsel`), and Windows.
|
||||
|
||||
Rules:
|
||||
|
||||
- Explicit field names are required — same restriction as `| run(...)`.
|
||||
- `clipboard()` takes no arguments — the grammar enforces empty parentheses.
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select id where id = id() | clipboard()
|
||||
select id, title where status = "done" | clipboard()
|
||||
```
|
||||
|
||||
For a single task with `id = "TIKI-ABC123"` and `title = "Fix bug"`, the clipboard receives:
|
||||
|
||||
```text
|
||||
TIKI-ABC123 Fix bug
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ statement = selectStmt | createStmt | updateStmt | deleteStmt ;
|
|||
|
||||
selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] [ pipeAction ] ;
|
||||
fieldList = identifier { "," identifier } ;
|
||||
pipeAction = "|" runAction ;
|
||||
pipeAction = "|" ( runAction | clipboardAction ) ;
|
||||
clipboardAction = "clipboard" "(" ")" ;
|
||||
createStmt = "create" assignment { assignment } ;
|
||||
|
||||
orderBy = "order" "by" sortField { "," sortField } ;
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ The maximum cascade depth is **8**. Termination is graceful — a warning is log
|
|||
|
||||
## The run() action
|
||||
|
||||
**Note:** This section describes trigger `run()` actions. For the pipe syntax `| run(...)` on select statements, see [Semantics](semantics.md#pipe-actions-on-select) and [Operators And Built-ins](operators-and-builtins.md#shell-related-forms).
|
||||
**Note:** This section describes trigger `run()` actions. For the pipe syntax `| run(...)` and `| clipboard()` on select statements, see [Semantics](semantics.md#pipe-actions-on-select) and [Operators And Built-ins](operators-and-builtins.md#shell-related-forms). Both `run()` and `clipboard()` are pipe-only targets — they cannot appear as trigger actions.
|
||||
|
||||
When an after-trigger uses `run(...)`, the command expression is evaluated to a string, then executed:
|
||||
|
||||
|
|
|
|||
|
|
@ -218,21 +218,23 @@ select where count(select where status = "done" order by priority) >= 1
|
|||
|
||||
## Pipe validation errors
|
||||
|
||||
Pipe actions on `select` have several restrictions:
|
||||
Pipe actions (`| run(...)` and `| clipboard()`) on `select` have several restrictions:
|
||||
|
||||
`select *` with pipe:
|
||||
|
||||
```sql
|
||||
select * where status = "done" | run("echo $1")
|
||||
select * where status = "done" | clipboard()
|
||||
```
|
||||
|
||||
Bare `select` with pipe:
|
||||
|
||||
```sql
|
||||
select | run("echo $1")
|
||||
select | clipboard()
|
||||
```
|
||||
|
||||
Both are rejected because explicit field names are required when using a pipe.
|
||||
Both are rejected because explicit field names are required when using a pipe. This applies to both `run()` and `clipboard()` targets.
|
||||
|
||||
Non-string command:
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ views:
|
|||
- 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()
|
||||
plugins:
|
||||
- name: Kanban
|
||||
description: "Move tiki to new status, search, create or delete"
|
||||
|
|
@ -129,3 +135,9 @@ triggers:
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -231,6 +231,17 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
// non-fatal: continue remaining rows
|
||||
}
|
||||
}
|
||||
case result.Clipboard != nil:
|
||||
if err := service.ExecuteClipboardPipe(result.Clipboard.Rows); err != nil {
|
||||
slog.Error("clipboard pipe failed", "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage("copied to clipboard", model.MessageLevelInfo, true)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -5,6 +5,7 @@ go 1.25.0
|
|||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0
|
||||
github.com/alecthomas/participle/v2 v2.1.4
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/boolean-maybe/navidown v0.4.18
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
|
|
@ -25,7 +26,6 @@ require (
|
|||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
|
|
|
|||
14
ruki/ast.go
14
ruki/ast.go
|
|
@ -13,14 +13,24 @@ type Statement struct {
|
|||
Delete *DeleteStmt
|
||||
}
|
||||
|
||||
// SelectStmt represents "select [fields] [where <condition>] [order by <field> [asc|desc], ...] [| run(...)]".
|
||||
// SelectStmt represents "select [fields] [where <condition>] [order by <field> [asc|desc], ...] [| run(...) | clipboard()]".
|
||||
type SelectStmt struct {
|
||||
Fields []string // nil = all ("select" or "select *"); non-nil = specific fields
|
||||
Where Condition // nil = select all
|
||||
OrderBy []OrderByClause // nil = unordered
|
||||
Pipe *RunAction // optional pipe suffix: "| run(...)"
|
||||
Pipe *PipeAction // optional pipe suffix: "| run(...)" or "| clipboard()"
|
||||
}
|
||||
|
||||
// PipeAction is a discriminated union for pipe targets.
|
||||
// Exactly one variant is non-nil.
|
||||
type PipeAction struct {
|
||||
Run *RunAction
|
||||
Clipboard *ClipboardAction
|
||||
}
|
||||
|
||||
// ClipboardAction represents "clipboard()" as a pipe target.
|
||||
type ClipboardAction struct{}
|
||||
|
||||
// CreateStmt represents "create <field>=<value>...".
|
||||
type CreateStmt struct {
|
||||
Assignments []Assignment
|
||||
|
|
|
|||
|
|
@ -34,11 +34,18 @@ func NewExecutor(schema Schema, userFunc func() string, runtime ExecutorRuntime)
|
|||
// Result holds the output of executing a statement.
|
||||
// Exactly one variant is non-nil.
|
||||
type Result struct {
|
||||
Select *TaskProjection
|
||||
Update *UpdateResult
|
||||
Create *CreateResult
|
||||
Delete *DeleteResult
|
||||
Pipe *PipeResult
|
||||
Select *TaskProjection
|
||||
Update *UpdateResult
|
||||
Create *CreateResult
|
||||
Delete *DeleteResult
|
||||
Pipe *PipeResult
|
||||
Clipboard *ClipboardResult
|
||||
}
|
||||
|
||||
// ClipboardResult holds the row data from a clipboard-piped select.
|
||||
// The service layer writes these to the system clipboard.
|
||||
type ClipboardResult struct {
|
||||
Rows [][]string
|
||||
}
|
||||
|
||||
// PipeResult holds the shell command and per-row positional args from a piped select.
|
||||
|
|
@ -147,7 +154,12 @@ func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result,
|
|||
}
|
||||
|
||||
if sel.Pipe != nil {
|
||||
return e.buildPipeResult(sel.Pipe, sel.Fields, filtered, tasks)
|
||||
switch {
|
||||
case sel.Pipe.Run != nil:
|
||||
return e.buildPipeResult(sel.Pipe.Run, sel.Fields, filtered, tasks)
|
||||
case sel.Pipe.Clipboard != nil:
|
||||
return e.buildClipboardResult(sel.Fields, filtered)
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{
|
||||
|
|
@ -169,6 +181,18 @@ func (e *Executor) buildPipeResult(pipe *RunAction, fields []string, matched []*
|
|||
return nil, fmt.Errorf("pipe command must evaluate to string, got %T", cmdVal)
|
||||
}
|
||||
|
||||
rows := buildFieldRows(fields, matched)
|
||||
return &Result{Pipe: &PipeResult{Command: cmdStr, Rows: rows}}, nil
|
||||
}
|
||||
|
||||
func (e *Executor) buildClipboardResult(fields []string, matched []*task.Task) (*Result, error) {
|
||||
rows := buildFieldRows(fields, matched)
|
||||
return &Result{Clipboard: &ClipboardResult{Rows: rows}}, nil
|
||||
}
|
||||
|
||||
// buildFieldRows extracts the requested fields from matched tasks as string rows.
|
||||
// Shared by both run() and clipboard() pipe targets.
|
||||
func buildFieldRows(fields []string, matched []*task.Task) [][]string {
|
||||
rows := make([][]string, len(matched))
|
||||
for i, t := range matched {
|
||||
row := make([]string, len(fields))
|
||||
|
|
@ -177,8 +201,7 @@ func (e *Executor) buildPipeResult(pipe *RunAction, fields []string, matched []*
|
|||
}
|
||||
rows[i] = row
|
||||
}
|
||||
|
||||
return &Result{Pipe: &PipeResult{Command: cmdStr, Rows: rows}}, nil
|
||||
return rows
|
||||
}
|
||||
|
||||
// pipeArgString space-joins list fields (tags, dependsOn) instead of using Go's
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ type selectGrammar struct {
|
|||
Fields *fieldNamesGrammar `parser:" | @@ )?"`
|
||||
Where *orCond `parser:"( 'where' @@ )?"`
|
||||
OrderBy *orderByGrammar `parser:"@@?"`
|
||||
Pipe *runGrammar `parser:"( Pipe @@ )?"`
|
||||
Pipe *pipeTargetGrammar `parser:"( Pipe @@ )?"`
|
||||
}
|
||||
|
||||
// --- order by grammar ---
|
||||
|
|
@ -73,10 +73,19 @@ type actionGrammar struct {
|
|||
Delete *deleteGrammar `parser:"| @@"`
|
||||
}
|
||||
|
||||
type pipeTargetGrammar struct {
|
||||
Run *runGrammar `parser:" @@"`
|
||||
Clipboard *clipboardGrammar `parser:"| @@"`
|
||||
}
|
||||
|
||||
type runGrammar struct {
|
||||
Command exprGrammar `parser:"'run' '(' @@ ')'"`
|
||||
}
|
||||
|
||||
type clipboardGrammar struct {
|
||||
Keyword string `parser:"@'clipboard' '(' ')'"`
|
||||
}
|
||||
|
||||
type denyGrammar struct {
|
||||
Message string `parser:"'deny' @String"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ var reserved = [...]string{
|
|||
"where", "set", "order", "by",
|
||||
"asc", "desc",
|
||||
"before", "after", "deny", "run",
|
||||
"every",
|
||||
"every", "clipboard",
|
||||
"and", "or", "not",
|
||||
"is", "empty", "in",
|
||||
"any", "all",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ func TestIsReserved(t *testing.T) {
|
|||
{"and", true},
|
||||
{"old", true},
|
||||
{"new", true},
|
||||
{"clipboard", true},
|
||||
{"CLIPBOARD", true},
|
||||
{"title", false},
|
||||
{"priority", false},
|
||||
{"foobar", false},
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ func lowerStatement(g *statementGrammar) (*Statement, error) {
|
|||
return nil, err
|
||||
}
|
||||
if g.Select.Pipe != nil {
|
||||
cmd, err := lowerExpr(&g.Select.Pipe.Command)
|
||||
pipe, err := lowerPipeTarget(g.Select.Pipe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Pipe = &RunAction{Command: cmd}
|
||||
s.Pipe = pipe
|
||||
}
|
||||
return &Statement{Select: s}, nil
|
||||
case g.Create != nil:
|
||||
|
|
@ -68,6 +68,21 @@ func lowerSelect(g *selectGrammar) (*SelectStmt, error) {
|
|||
return &SelectStmt{Fields: fields, Where: where, OrderBy: orderBy}, nil
|
||||
}
|
||||
|
||||
func lowerPipeTarget(g *pipeTargetGrammar) (*PipeAction, error) {
|
||||
switch {
|
||||
case g.Run != nil:
|
||||
cmd, err := lowerExpr(&g.Run.Command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PipeAction{Run: &RunAction{Command: cmd}}, nil
|
||||
case g.Clipboard != nil:
|
||||
return &PipeAction{Clipboard: &ClipboardAction{}}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("empty pipe target")
|
||||
}
|
||||
}
|
||||
|
||||
func lowerCreate(g *createGrammar) (*CreateStmt, error) {
|
||||
assignments, err := lowerAssignments(g.Assignments)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -112,8 +112,11 @@ func TestLowerPipeProducesSelectAndPipe(t *testing.T) {
|
|||
if stmt.Select.Pipe == nil {
|
||||
t.Fatal("expected Select.Pipe non-nil")
|
||||
}
|
||||
if stmt.Select.Pipe.Command == nil {
|
||||
t.Fatal("expected Select.Pipe.Command non-nil")
|
||||
if stmt.Select.Pipe.Run == nil {
|
||||
t.Fatal("expected Select.Pipe.Run non-nil")
|
||||
}
|
||||
if stmt.Select.Pipe.Run.Command == nil {
|
||||
t.Fatal("expected Select.Pipe.Run.Command non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -342,6 +345,178 @@ func TestExecutePipeListFieldSpaceJoined(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- clipboard pipe: parser ---
|
||||
|
||||
func TestParseClipboardPipe(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"basic clipboard", `select id where id = id() | clipboard()`},
|
||||
{"multi-field clipboard", `select id, title where status = "done" | clipboard()`},
|
||||
{"clipboard without where", `select id | clipboard()`},
|
||||
{"clipboard with order by", `select id, priority order by priority | clipboard()`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseAndValidateStatement(tt.input, ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if !stmt.IsPipe() {
|
||||
t.Fatal("expected IsPipe() true")
|
||||
}
|
||||
if !stmt.IsClipboardPipe() {
|
||||
t.Fatal("expected IsClipboardPipe() true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- clipboard pipe: lowering ---
|
||||
|
||||
func TestLowerClipboardPipe(t *testing.T) {
|
||||
p := newTestParser()
|
||||
stmt, err := p.ParseStatement(`select id | clipboard()`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if stmt.Select.Pipe == nil {
|
||||
t.Fatal("expected Select.Pipe non-nil")
|
||||
}
|
||||
if stmt.Select.Pipe.Clipboard == nil {
|
||||
t.Fatal("expected Select.Pipe.Clipboard non-nil")
|
||||
}
|
||||
if stmt.Select.Pipe.Run != nil {
|
||||
t.Fatal("expected Select.Pipe.Run nil for clipboard pipe")
|
||||
}
|
||||
}
|
||||
|
||||
// --- clipboard pipe: validation ---
|
||||
|
||||
func TestClipboardPipeRejectSelectStar(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseStatement(`select * | clipboard()`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for select * with clipboard pipe")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "explicit field names") {
|
||||
t.Errorf("expected 'explicit field names' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardPipeRejectBareSelect(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseStatement(`select | clipboard()`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare select with clipboard pipe")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "explicit field names") {
|
||||
t.Errorf("expected 'explicit field names' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- clipboard pipe: semantic ---
|
||||
|
||||
func TestIsClipboardPipeMethod(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
clipStmt, err := p.ParseAndValidateStatement(`select id where id = id() | clipboard()`, ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if !clipStmt.IsPipe() {
|
||||
t.Error("expected IsPipe() = true")
|
||||
}
|
||||
if !clipStmt.IsClipboardPipe() {
|
||||
t.Error("expected IsClipboardPipe() = true")
|
||||
}
|
||||
|
||||
runStmt, err := p.ParseAndValidateStatement(`select id where id = id() | run("echo $1")`, ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if !runStmt.IsPipe() {
|
||||
t.Error("expected IsPipe() = true for run pipe")
|
||||
}
|
||||
if runStmt.IsClipboardPipe() {
|
||||
t.Error("expected IsClipboardPipe() = false for run pipe")
|
||||
}
|
||||
|
||||
plainStmt, err := p.ParseAndValidateStatement(`select where status = "done"`, ExecutorRuntimeCLI)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if plainStmt.IsClipboardPipe() {
|
||||
t.Error("expected IsClipboardPipe() = false for plain select")
|
||||
}
|
||||
}
|
||||
|
||||
// --- clipboard pipe: executor ---
|
||||
|
||||
func TestExecuteClipboardPipeReturnsResult(t *testing.T) {
|
||||
e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimePlugin})
|
||||
p := newTestParser()
|
||||
tasks := makeTasks()
|
||||
|
||||
stmt, err := p.ParseStatement(`select id, title where status = "done" | clipboard()`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
result, err := e.Execute(stmt, tasks, ExecutionInput{SelectedTaskID: "TIKI-000003"})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if result.Clipboard == nil {
|
||||
t.Fatal("expected Clipboard result")
|
||||
}
|
||||
if result.Pipe != nil {
|
||||
t.Fatal("expected Pipe to be nil when clipboard is present")
|
||||
}
|
||||
if result.Select != nil {
|
||||
t.Fatal("expected Select to be nil when clipboard is present")
|
||||
}
|
||||
if len(result.Clipboard.Rows) != 1 {
|
||||
t.Fatalf("expected 1 row (status=done), got %d", len(result.Clipboard.Rows))
|
||||
}
|
||||
row := result.Clipboard.Rows[0]
|
||||
if len(row) != 2 {
|
||||
t.Fatalf("expected 2 fields, got %d", len(row))
|
||||
}
|
||||
if row[0] != "TIKI-000003" {
|
||||
t.Errorf("row[0] (id) = %q, want %q", row[0], "TIKI-000003")
|
||||
}
|
||||
if row[1] != "Write docs" {
|
||||
t.Errorf("row[1] (title) = %q, want %q", row[1], "Write docs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteClipboardMultipleRows(t *testing.T) {
|
||||
e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimeCLI})
|
||||
p := newTestParser()
|
||||
tasks := makeTasks()
|
||||
|
||||
stmt, err := p.ParseStatement(`select id, title | clipboard()`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if result.Clipboard == nil {
|
||||
t.Fatal("expected Clipboard result")
|
||||
}
|
||||
if len(result.Clipboard.Rows) != len(tasks) {
|
||||
t.Fatalf("expected %d rows, got %d", len(tasks), len(result.Clipboard.Rows))
|
||||
}
|
||||
}
|
||||
|
||||
// --- pipeArgString ---
|
||||
|
||||
func TestPipeArgString(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ func (v *ValidatedStatement) IsDelete() bool {
|
|||
func (v *ValidatedStatement) IsPipe() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Select != nil && v.statement.Select.Pipe != nil
|
||||
}
|
||||
func (v *ValidatedStatement) IsClipboardPipe() bool {
|
||||
return v.IsPipe() && v.statement.Select.Pipe.Clipboard != nil
|
||||
}
|
||||
|
||||
func (v *ValidatedStatement) mustBeSealed() error {
|
||||
if v == nil || v.seal != validatedSeal || v.statement == nil {
|
||||
|
|
@ -391,8 +394,8 @@ func scanSelectSemantics(sel *SelectStmt) (usesID bool, hasCall bool, err error)
|
|||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if sel.Pipe != nil {
|
||||
u2, c2, err := scanExprSemantics(sel.Pipe.Command)
|
||||
if sel.Pipe != nil && sel.Pipe.Run != nil {
|
||||
u2, c2, err := scanExprSemantics(sel.Pipe.Run.Command)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
|
@ -586,7 +589,13 @@ func cloneSelect(sel *SelectStmt) *SelectStmt {
|
|||
OrderBy: orderBy,
|
||||
}
|
||||
if sel.Pipe != nil {
|
||||
out.Pipe = &RunAction{Command: cloneExpr(sel.Pipe.Command)}
|
||||
out.Pipe = &PipeAction{}
|
||||
if sel.Pipe.Run != nil {
|
||||
out.Pipe.Run = &RunAction{Command: cloneExpr(sel.Pipe.Run.Command)}
|
||||
}
|
||||
if sel.Pipe.Clipboard != nil {
|
||||
out.Pipe.Clipboard = &ClipboardAction{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,16 +74,19 @@ func (p *Parser) validateStatement(s *Statement) error {
|
|||
if len(s.Select.Fields) == 0 {
|
||||
return fmt.Errorf("pipe requires explicit field names in select (not select * or bare select)")
|
||||
}
|
||||
typ, err := p.inferExprType(s.Select.Pipe.Command)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pipe command: %w", err)
|
||||
}
|
||||
if typ != ValueString {
|
||||
return fmt.Errorf("pipe command must be string, got %s", typeName(typ))
|
||||
}
|
||||
if exprContainsFieldRef(s.Select.Pipe.Command) {
|
||||
return fmt.Errorf("pipe command must not contain field references — use $1, $2 for positional args")
|
||||
if s.Select.Pipe.Run != nil {
|
||||
typ, err := p.inferExprType(s.Select.Pipe.Run.Command)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pipe command: %w", err)
|
||||
}
|
||||
if typ != ValueString {
|
||||
return fmt.Errorf("pipe command must be string, got %s", typeName(typ))
|
||||
}
|
||||
if exprContainsFieldRef(s.Select.Pipe.Run.Command) {
|
||||
return fmt.Errorf("pipe command must not contain field references — use $1, $2 for positional args")
|
||||
}
|
||||
}
|
||||
// clipboard() has no arguments — grammar enforces empty parens
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
|
|
|
|||
17
service/clipboard.go
Normal file
17
service/clipboard.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
)
|
||||
|
||||
// ExecuteClipboardPipe writes the given rows to the system clipboard.
|
||||
// Fields within a row are tab-separated; rows are newline-separated.
|
||||
func ExecuteClipboardPipe(rows [][]string) error {
|
||||
lines := make([]string, len(rows))
|
||||
for i, row := range rows {
|
||||
lines[i] = strings.Join(row, "\t")
|
||||
}
|
||||
return clipboard.WriteAll(strings.Join(lines, "\n"))
|
||||
}
|
||||
40
service/clipboard_test.go
Normal file
40
service/clipboard_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
)
|
||||
|
||||
func TestExecuteClipboardPipe(t *testing.T) {
|
||||
// skip on headless CI where clipboard is not available
|
||||
if err := clipboard.WriteAll("test"); err != nil {
|
||||
t.Skipf("clipboard not available: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rows [][]string
|
||||
want string
|
||||
}{
|
||||
{"single field single row", [][]string{{"TIKI-000001"}}, "TIKI-000001"},
|
||||
{"multi-field single row", [][]string{{"TIKI-000001", "Fix bug"}}, "TIKI-000001\tFix bug"},
|
||||
{"multi-row", [][]string{{"TIKI-000001", "Fix bug"}, {"TIKI-000002", "Add tests"}}, "TIKI-000001\tFix bug\nTIKI-000002\tAdd tests"},
|
||||
{"empty rows", [][]string{}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := ExecuteClipboardPipe(tt.rows); err != nil {
|
||||
t.Fatalf("ExecuteClipboardPipe() error: %v", err)
|
||||
}
|
||||
got, err := clipboard.ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("clipboard.ReadAll() error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("clipboard = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue