mirror of
https://github.com/peco/peco.git
synced 2026-09-10 07:16:29 -04:00
Merge pull request #394 from peco/topic/command
[WIP] Remove ExecuteCommand, and add --exec
This commit is contained in:
commit
766fdd73c0
15
Changes
15
Changes
|
|
@ -1,6 +1,21 @@
|
|||
Changes
|
||||
=======
|
||||
|
||||
v0.5.0 - unreleased
|
||||
Backwards Incompatible Changes:
|
||||
* ExecuteCommand has been removed.
|
||||
Features:
|
||||
* A new command line option `--exec` has been added. This allows you to
|
||||
execute external commands via shell, and should be used as replacement
|
||||
to `ExecuteCommand`
|
||||
* A new configuration option `MaxScanBufferSize` has been added. Whereas
|
||||
bufio.Scanner (which peco internally relies on) only accepts lines that
|
||||
are < 64kb when reading the input, specifying this option in the config
|
||||
allows you to change this limit.
|
||||
Bugs/Fixes
|
||||
* When executing external commands, the screen and the capturing of user
|
||||
input would interfere when getting back to peco.
|
||||
|
||||
v0.4.9 - 01 Mar 2017
|
||||
Bugs/Fixes
|
||||
* SavedSelection under `--selection-prefix` was not properly working
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -183,6 +183,18 @@ If there are multiple lines in the input, the usual selection view is displayed.
|
|||
Specifies the exit status to use when the user cancels the query execution.
|
||||
For historical and back-compatibility reasons, the default is `success`, meaning if the user cancels the query, the exit status is 0. When you choose `error`, peco will exit with a non-zero value.
|
||||
|
||||
### --selection-prefix `string`
|
||||
|
||||
When specified, peco uses the specified prefix instead of changing line color to indicate currently selected line(s). default is to use colors. This option is experimental
|
||||
|
||||
### --exec `string`
|
||||
|
||||
When specified, peco executes the specified external command (via shell), with peco's currently selected line(s) as its input from STDIN.
|
||||
|
||||
Upon exiting from the external command, the control goes back to peco where you can keep browsing your search buffer, and to possibly execute your external command repeatedly afterwards.
|
||||
|
||||
To exit out of peco when running in this mode, you must execute the Cancel command, usually the escape key.
|
||||
|
||||
# Configuration File
|
||||
|
||||
peco by default consults a few locations for the config files.
|
||||
|
|
@ -251,6 +263,18 @@ Default value for StickySelection is false.
|
|||
|
||||
OnCancel is equivalent to `--on-cancel` command line option.
|
||||
|
||||
### MaxScanBufferSize
|
||||
|
||||
```json
|
||||
{
|
||||
"MaxScanBufferSize": 256
|
||||
}
|
||||
```
|
||||
|
||||
Controls the buffer sized (in kilobytes) used by `bufio.Scanner`, which is
|
||||
responsible for reading the input lines. If you believe that your input has
|
||||
very long lines that prohibit peco from reading them, try increasing this number.
|
||||
|
||||
## Keymaps
|
||||
|
||||
Example:
|
||||
|
|
@ -538,23 +562,6 @@ See --layout.
|
|||
}
|
||||
```
|
||||
|
||||
## ExecuteCommand
|
||||
|
||||
```
|
||||
{
|
||||
"Keymap": {
|
||||
"C-e": "peco.ExecuteCommand.Notepad"
|
||||
},
|
||||
"Command": [
|
||||
{
|
||||
"Name": "Notepad",
|
||||
"Args": ["notepad", "$FILE"],
|
||||
"Spawn": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## SelectionPrefix
|
||||
|
||||
`SelectionPrefix` is equivalent to using `--selection-prefix` in the command line.
|
||||
|
|
@ -678,6 +685,7 @@ Much code stolen from https://github.com/mattn/gof
|
|||
* [--select-1](#--select-1)
|
||||
* [--on-cancel `success|error`](#--on-cancel-successerror)
|
||||
* [--selection-prefix `string`](#--selection-prefix-string)
|
||||
* [--exec `string`](#--exec-string)
|
||||
* [Configuration File](#configuration-file)
|
||||
* [Global](#global)
|
||||
* [Prompt](#prompt)
|
||||
|
|
@ -700,7 +708,6 @@ Much code stolen from https://github.com/mattn/gof
|
|||
* [Examples](#examples)
|
||||
* [Layout](#layout)
|
||||
* [SingleKeyJump](#singlekeyjump)
|
||||
* [ExecuteCommand](#executecommand)
|
||||
* [SelectionPrefix](#selectionprefix)
|
||||
* [FAQ](#faq)
|
||||
* [Does peco work on (msys2|cygwin)?](#does-peco-work-on-msys2cygwin)
|
||||
|
|
|
|||
105
action.go
105
action.go
|
|
@ -1,10 +1,8 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"unicode"
|
||||
|
||||
"context"
|
||||
|
|
@ -13,6 +11,7 @@ import (
|
|||
"github.com/lestrrat/go-pdebug"
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/peco/peco/internal/keyseq"
|
||||
"github.com/peco/peco/internal/util"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
|
@ -299,7 +298,44 @@ func doFinish(ctx context.Context, state *Peco, _ termbox.Event) {
|
|||
defer g.End()
|
||||
}
|
||||
|
||||
state.Exit(errCollectResults{})
|
||||
ccarg := state.execOnFinish
|
||||
if len(ccarg) == 0 {
|
||||
state.Exit(errCollectResults{})
|
||||
return
|
||||
}
|
||||
|
||||
sel := NewSelection()
|
||||
state.Selection().Copy(sel)
|
||||
if sel.Len() == 0 {
|
||||
if l, err := state.CurrentLineBuffer().LineAt(state.Location().LineNumber()); err == nil {
|
||||
sel.Add(l)
|
||||
}
|
||||
}
|
||||
|
||||
var stdin bytes.Buffer
|
||||
sel.Ascend(func(it btree.Item) bool {
|
||||
line := it.(line.Line)
|
||||
stdin.WriteString(line.Buffer())
|
||||
stdin.WriteRune('\n')
|
||||
return true
|
||||
})
|
||||
|
||||
var err error
|
||||
state.Hub().SendStatusMsg("Executing " + ccarg)
|
||||
cmd := util.Shell(ccarg)
|
||||
cmd.Stdin = &stdin
|
||||
cmd.Stdout = state.Stdout
|
||||
cmd.Stderr = state.Stderr
|
||||
|
||||
state.screen.Suspend()
|
||||
|
||||
err = cmd.Run()
|
||||
state.screen.Resume()
|
||||
state.ExecQuery()
|
||||
if err != nil {
|
||||
// bail out, or otherwise the user cannot know what happened
|
||||
state.Exit(errors.Wrap(err, `failed to execute command`))
|
||||
}
|
||||
}
|
||||
|
||||
func doCancel(ctx context.Context, state *Peco, e termbox.Event) {
|
||||
|
|
@ -729,64 +765,3 @@ func makeCombinedAction(actions ...Action) ActionFunc {
|
|||
}, toplevel)
|
||||
})
|
||||
}
|
||||
|
||||
func makeCommandAction(state *Peco, cc *CommandConfig) ActionFunc {
|
||||
return func(ctx context.Context, state *Peco, _ termbox.Event) {
|
||||
sel := state.Selection()
|
||||
if sel.Len() == 0 {
|
||||
if l, err := state.CurrentLineBuffer().LineAt(state.Location().LineNumber()); err == nil {
|
||||
sel.Add(l)
|
||||
}
|
||||
}
|
||||
|
||||
sel.Ascend(func(it btree.Item) bool {
|
||||
line := it.(line.Line)
|
||||
|
||||
var f *os.File
|
||||
var err error
|
||||
|
||||
args := append([]string{}, cc.Args...)
|
||||
for i, v := range args {
|
||||
switch v {
|
||||
case "$FILE":
|
||||
if f == nil {
|
||||
f, err = ioutil.TempFile("", "peco")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
f.WriteString(line.Buffer())
|
||||
f.Close()
|
||||
}
|
||||
args[i] = f.Name()
|
||||
case "$LINE":
|
||||
args[i] = line.Buffer()
|
||||
}
|
||||
}
|
||||
state.Hub().SendStatusMsg("Executing " + cc.Name)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
if cc.Spawn {
|
||||
err = cmd.Start()
|
||||
go func() {
|
||||
cmd.Wait()
|
||||
if f != nil {
|
||||
os.Remove(f.Name())
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
cmd.Stdin = state.Stdin
|
||||
cmd.Stdout = state.Stdout
|
||||
cmd.Stderr = state.Stderr
|
||||
err = cmd.Run()
|
||||
if f != nil {
|
||||
os.Remove(f.Name())
|
||||
}
|
||||
state.ExecQuery()
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import (
|
|||
"os"
|
||||
"runtime"
|
||||
|
||||
"context"
|
||||
|
||||
pdebug "github.com/lestrrat/go-pdebug"
|
||||
"github.com/peco/peco"
|
||||
"github.com/peco/peco/internal/util"
|
||||
"context"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -21,6 +23,9 @@ func main() {
|
|||
}
|
||||
|
||||
func _main() int {
|
||||
if pdebug.Enabled {
|
||||
pdebug.DefaultCtx.Writer = os.Stderr
|
||||
}
|
||||
if envvar := os.Getenv("GOMAXPROCS"); envvar == "" {
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
}
|
||||
|
|
@ -42,7 +47,6 @@ func _main() int {
|
|||
return 1
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
return 0
|
||||
|
|
|
|||
24
interface.go
24
interface.go
|
|
@ -70,6 +70,7 @@ type Peco struct {
|
|||
config Config
|
||||
currentLineBuffer Buffer
|
||||
enableSep bool // Enable parsing on separators
|
||||
execOnFinish string
|
||||
filters filter.Set
|
||||
idgen *idgen
|
||||
initialFilter string
|
||||
|
|
@ -78,6 +79,7 @@ type Peco struct {
|
|||
keymap Keymap
|
||||
layoutType string
|
||||
location Location
|
||||
maxScanBufferSize int
|
||||
mutex sync.Mutex
|
||||
onCancel string
|
||||
prompt string
|
||||
|
|
@ -156,16 +158,20 @@ type Screen interface {
|
|||
Init() error
|
||||
Close() error
|
||||
Flush() error
|
||||
PollEvent() chan termbox.Event
|
||||
PollEvent(context.Context) chan termbox.Event
|
||||
Print(PrintArgs) int
|
||||
Resume()
|
||||
SetCell(int, int, rune, termbox.Attribute, termbox.Attribute)
|
||||
Size() (int, int)
|
||||
SendEvent(termbox.Event)
|
||||
Suspend()
|
||||
}
|
||||
|
||||
// Termbox just hands out the processing to the termbox library
|
||||
type Termbox struct {
|
||||
mutex sync.Mutex
|
||||
mutex sync.Mutex
|
||||
resumeCh chan (struct{})
|
||||
suspendCh chan (struct{})
|
||||
}
|
||||
|
||||
// View handles the drawing/updating the screen
|
||||
|
|
@ -292,9 +298,9 @@ type Config struct {
|
|||
OnCancel string `json:"OnCancel"`
|
||||
CustomMatcher map[string][]string
|
||||
CustomFilter map[string]CustomFilterConfig
|
||||
Command []CommandConfig
|
||||
QueryExecutionDelay int
|
||||
StickySelection bool
|
||||
MaxScanBufferSize int
|
||||
|
||||
// If this is true, then the prefix for single key jump mode
|
||||
// is displayed by default.
|
||||
|
|
@ -308,17 +314,6 @@ type SingleKeyJumpConfig struct {
|
|||
ShowPrefix bool `json:"ShowPrefix"`
|
||||
}
|
||||
|
||||
type CommandConfig struct {
|
||||
// Name is the name of the command to execute
|
||||
Name string
|
||||
|
||||
// TODO: need to check if how we use this is correct
|
||||
Args []string
|
||||
|
||||
// Spawn mean the command should be executed asynchronous.
|
||||
Spawn bool
|
||||
}
|
||||
|
||||
// CustomFilterConfig is used to specify configuration parameters
|
||||
// to CustomFilters
|
||||
type CustomFilterConfig struct {
|
||||
|
|
@ -421,6 +416,7 @@ type CLIOptions struct {
|
|||
OptSelect1 bool `long:"select-1" description:"select first item and immediately exit if the input contains only 1 item"`
|
||||
OptOnCancel string `long:"on-cancel" description:"specify action on user cancel. 'success' or 'error'.\ndefault is 'success'. This may change in future versions"`
|
||||
OptSelectionPrefix string `long:"selection-prefix" description:"use a prefix instead of changing line color to indicate currently selected lines.\ndefault is to use colors. This option is experimental"`
|
||||
OptExec string `long:"exec" description:"execute command instead of finishing/terminating peco.\nPlease note that this command will receive selected line(s) from stdin,\nand will be executed via '/bin/sh -c' or 'cmd /c'"`
|
||||
}
|
||||
|
||||
type CLI struct {
|
||||
|
|
|
|||
18
internal/util/shell_unix.go
Normal file
18
internal/util/shell_unix.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// +build !windows
|
||||
|
||||
package util
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func Shell(cmd ...string) *exec.Cmd {
|
||||
const shellpath = `/bin/sh`
|
||||
const shellopt = `-c`
|
||||
|
||||
args := make([]string, len(cmd) + 1)
|
||||
args[0] = shellopt
|
||||
for i := 0; i < len(cmd); i++ {
|
||||
args[i+1] = cmd[i]
|
||||
}
|
||||
|
||||
return exec.Command(shellpath, args...)
|
||||
}
|
||||
18
internal/util/shell_windows.go
Normal file
18
internal/util/shell_windows.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// +build windows
|
||||
|
||||
package util
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func Shell(cmd ...string) *exec.Cmd {
|
||||
const shellpath = `cmd`
|
||||
const shellopt = `/c`
|
||||
|
||||
args := make([]string, len(cmd) + 1)
|
||||
args[0] = shellopt
|
||||
for i := 0; i < len(cmd); i++ {
|
||||
args[i+1] = cmd[i]
|
||||
}
|
||||
|
||||
return exec.Command(shellpath, args...)
|
||||
}
|
||||
30
peco.go
30
peco.go
|
|
@ -1,6 +1,7 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
|
|
@ -113,8 +114,9 @@ func New() *Peco {
|
|||
idgen: newIDGen(),
|
||||
queryExecDelay: 50 * time.Millisecond,
|
||||
readyCh: make(chan struct{}),
|
||||
screen: &Termbox{},
|
||||
screen: NewTermbox(),
|
||||
selection: NewSelection(),
|
||||
maxScanBufferSize: bufio.MaxScanTokenSize,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -326,7 +328,7 @@ func (p *Peco) Run(ctx context.Context) (err error) {
|
|||
loopers := []interface {
|
||||
Loop(ctx context.Context, cancel func()) error
|
||||
}{
|
||||
NewInput(p, p.Keymap(), p.screen.PollEvent()),
|
||||
NewInput(p, p.Keymap(), p.screen.PollEvent(ctx)),
|
||||
NewView(p),
|
||||
NewFilter(p),
|
||||
sig.New(sig.SigReceivedHandlerFunc(func(sig os.Signal) {
|
||||
|
|
@ -482,17 +484,6 @@ func readConfig(cfg *Config, filename string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *Peco) populateCommandList() error {
|
||||
for _, v := range p.config.Command {
|
||||
if len(v.Args) == 0 {
|
||||
continue
|
||||
}
|
||||
makeCommandAction(p, &v).Register("ExecuteCommand." + v.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Peco) ApplyConfig(opts CLIOptions) error {
|
||||
// If layoutType is not set and is set in the config, set it
|
||||
if p.layoutType == "" {
|
||||
|
|
@ -503,6 +494,15 @@ func (p *Peco) ApplyConfig(opts CLIOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
p.maxScanBufferSize = 256
|
||||
if v := p.config.MaxScanBufferSize; v > 0 {
|
||||
p.maxScanBufferSize = v
|
||||
}
|
||||
|
||||
if v := opts.OptExec; len(v) > 0 {
|
||||
p.execOnFinish = v
|
||||
}
|
||||
|
||||
p.enableSep = opts.OptEnableNullSep
|
||||
|
||||
if i := opts.OptInitialIndex; i >= 0 {
|
||||
|
|
@ -540,10 +540,6 @@ func (p *Peco) ApplyConfig(opts CLIOptions) error {
|
|||
p.initialFilter = opts.OptInitialMatcher
|
||||
}
|
||||
|
||||
if err := p.populateCommandList(); err != nil {
|
||||
return errors.Wrap(err, "failed to populate command list")
|
||||
}
|
||||
|
||||
if err := p.populateFilters(); err != nil {
|
||||
return errors.Wrap(err, "failed to populate filters")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,12 +124,14 @@ func (d dummyScreen) Flush() error {
|
|||
d.record("Flush", interceptorArgs{})
|
||||
return nil
|
||||
}
|
||||
func (d dummyScreen) PollEvent() chan termbox.Event {
|
||||
func (d dummyScreen) PollEvent(ctx context.Context) chan termbox.Event {
|
||||
return d.pollCh
|
||||
}
|
||||
func (d dummyScreen) Size() (int, int) {
|
||||
return d.width, d.height
|
||||
}
|
||||
func (d dummyScreen) Resume() {}
|
||||
func (d dummyScreen) Suspend() {}
|
||||
|
||||
func TestIDGen(t *testing.T) {
|
||||
idgen := newIDGen()
|
||||
|
|
|
|||
56
screen.go
56
screen.go
|
|
@ -1,8 +1,10 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"context"
|
||||
"unicode/utf8"
|
||||
|
||||
pdebug "github.com/lestrrat/go-pdebug"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/pkg/errors"
|
||||
|
|
@ -16,6 +18,13 @@ func (t *Termbox) Init() error {
|
|||
return t.PostInit()
|
||||
}
|
||||
|
||||
func NewTermbox() *Termbox {
|
||||
return &Termbox{
|
||||
suspendCh: make(chan struct{}),
|
||||
resumeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Termbox) Close() error {
|
||||
termbox.Close()
|
||||
return nil
|
||||
|
|
@ -38,7 +47,7 @@ func (t *Termbox) Flush() error {
|
|||
// PollEvent returns a channel that you can listen to for
|
||||
// termbox's events. The actual polling is done in a
|
||||
// separate gouroutine
|
||||
func (t *Termbox) PollEvent() chan termbox.Event {
|
||||
func (t *Termbox) PollEvent(ctx context.Context) chan termbox.Event {
|
||||
// XXX termbox.PollEvent() can get stuck on unexpected signal
|
||||
// handling cases. We still would like to wait until the user
|
||||
// (termbox) has some event for us to process, but we don't
|
||||
|
|
@ -49,17 +58,60 @@ func (t *Termbox) PollEvent() chan termbox.Event {
|
|||
// safely be implemented in terms of select {} which is
|
||||
// safe from being stuck.
|
||||
evCh := make(chan termbox.Event)
|
||||
|
||||
go func() {
|
||||
// keep listening to suspend requests here
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.suspendCh:
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("poll event suspended!")
|
||||
}
|
||||
termbox.Interrupt()
|
||||
t.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer func() { recover() }()
|
||||
defer func() { close(evCh) }()
|
||||
|
||||
for {
|
||||
evCh <- termbox.PollEvent()
|
||||
ev := termbox.PollEvent()
|
||||
if ev.Type != termbox.EventInterrupt {
|
||||
evCh <- ev
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.resumeCh:
|
||||
t.Init()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return evCh
|
||||
|
||||
}
|
||||
|
||||
func (t *Termbox) Suspend() {
|
||||
select {
|
||||
case t.suspendCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Termbox) Resume() {
|
||||
select {
|
||||
case t.resumeCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// SetCell writes to the terminal
|
||||
func (t *Termbox) SetCell(x, y int, ch rune, fg, bg termbox.Attribute) {
|
||||
t.mutex.Lock()
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ func (s *Selection) Add(l line.Line) {
|
|||
s.tree.ReplaceOrInsert(l)
|
||||
}
|
||||
|
||||
func (s *Selection) Copy(dst *Selection) {
|
||||
s.Ascend(func(it btree.Item) bool {
|
||||
dst.Add(it.(line.Line))
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Remove removes the specified line from the selection
|
||||
func (s *Selection) Remove(l line.Line) {
|
||||
s.mutex.Lock()
|
||||
|
|
|
|||
|
|
@ -77,7 +77,12 @@ func (s *Source) Setup(ctx context.Context, state *Peco) {
|
|||
// Note: this will be a no-op if notify.Do has been called before
|
||||
defer notify.Do(notifycb)
|
||||
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("Source: using buffer size of %dkb", state.maxScanBufferSize)
|
||||
}
|
||||
scanbuf := make([]byte, state.maxScanBufferSize*1024)
|
||||
scanner := bufio.NewScanner(s.in)
|
||||
scanner.Buffer(scanbuf, state.maxScanBufferSize*1024)
|
||||
defer func() {
|
||||
if util.IsTty(s.in) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ func TestSource(t *testing.T) {
|
|||
|
||||
r := addReadDelay(strings.NewReader(strings.Join(lines, "\n")), 2*time.Second)
|
||||
s := NewSource(r, ig, 0, false)
|
||||
go s.Setup(ctx, &Peco{hub: nullHub{}})
|
||||
p := New()
|
||||
p.hub = nullHub{}
|
||||
go s.Setup(ctx, p)
|
||||
|
||||
timeout := time.After(5 * time.Second)
|
||||
waitout := time.After(1 * time.Second)
|
||||
|
|
|
|||
Loading…
Reference in a new issue