Initial commit
This commit is contained in:
commit
6c071192e1
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
launcher
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 M-Ahall
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
150
commandset.go
Normal file
150
commandset.go
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
// External
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
|
||||||
|
// Internal
|
||||||
|
"launcher/log"
|
||||||
|
|
||||||
|
// Standard
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create reads the file and parses its YAML content.
|
||||||
|
func CreateCommandSet(fName string) (*CommandSet, error) {
|
||||||
|
var commandSet CommandSet
|
||||||
|
fileContent, err := ioutil.ReadFile(fName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = yaml.UnmarshalStrict(fileContent, &commandSet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &commandSet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dump marshals the struct and outputs it.
|
||||||
|
func (cs *CommandSet) Dump() {
|
||||||
|
if str, err := yaml.Marshal(cs); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s\n", str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommand returns the commands with names matching the given regex.
|
||||||
|
func (cs *CommandSet) GetCommands(nameRegex string) ([]*CommandSet, error) {
|
||||||
|
var results []*CommandSet
|
||||||
|
re, err := regexp.Compile(nameRegex)
|
||||||
|
if err != nil {
|
||||||
|
return []*CommandSet{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cmd := range cs.Commands {
|
||||||
|
if re.MatchString(cmd.Name) {
|
||||||
|
// A commandset can be dynamic, which needs to run a script that generates YAML items.
|
||||||
|
if cmd.Dynamic != "" && !cmd.completed {
|
||||||
|
configData, err := exec.Command("/bin/bash", "-c", cmd.Dynamic).Output()
|
||||||
|
if err != nil {
|
||||||
|
if exErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return nil, fmt.Errorf("Script:\n%s", exErr.Stderr)
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ignore the topmost name and add its commands instead,
|
||||||
|
// to prevent a forced extra level as we need one toplevel commandset.
|
||||||
|
var commandSet CommandSet
|
||||||
|
err = yaml.UnmarshalStrict(configData, &commandSet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, commands := range commandSet.Commands {
|
||||||
|
cmd.Commands = append(cmd.Commands, commands)
|
||||||
|
}
|
||||||
|
log.Print("dynamic data: %s", configData)
|
||||||
|
cmd.completed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search takes a search string, splits it and iteratively walks along the
|
||||||
|
// provided commandset.
|
||||||
|
func (cs *CommandSet) Search(search string) ([]SearchResult, error) {
|
||||||
|
// Regex is much more flexible to search by than just string including
|
||||||
|
// searched text. It also makes it possible to use ^ and $ when search
|
||||||
|
// terms isn't conclusive.
|
||||||
|
re := regexp.MustCompile("\\s+")
|
||||||
|
words := re.Split(search, -1)
|
||||||
|
if words[len(words)-1] == "" {
|
||||||
|
words = words[:len(words)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []SearchResult
|
||||||
|
currCommandset := cs
|
||||||
|
|
||||||
|
// We need to return everything when no search term is provided,
|
||||||
|
// as the search term is more akin to a filter.
|
||||||
|
if len(words) == 0 {
|
||||||
|
words = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
|
WordLoop:
|
||||||
|
for _, w := range words {
|
||||||
|
if cmds, err := currCommandset.GetCommands(w); err == nil {
|
||||||
|
switch len(cmds) {
|
||||||
|
// No more results, search is over.
|
||||||
|
case 0:
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
Search: w,
|
||||||
|
Commands: []*CommandSet{},
|
||||||
|
})
|
||||||
|
break WordLoop
|
||||||
|
|
||||||
|
// Only one result
|
||||||
|
case 1:
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
Search: w,
|
||||||
|
Commands: cmds,
|
||||||
|
})
|
||||||
|
currCommandset = cmds[0]
|
||||||
|
|
||||||
|
// Many results - return them to let user know what can be searched,
|
||||||
|
// but the search is over.
|
||||||
|
default:
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
Search: w,
|
||||||
|
Commands: cmds,
|
||||||
|
})
|
||||||
|
break WordLoop
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// error in regex compilation or dynamic command execution
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
Commands: []*CommandSet{},
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh runs the dynamic script, parses the input and puts it into place.
|
||||||
|
func (cs *CommandSet) refresh() error {
|
||||||
|
return nil
|
||||||
|
}
|
77
dir.go
Normal file
77
dir.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
// Standard
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getConfigDir figures out the path of the configuration directory.
|
||||||
|
// Uses env variables appropriate for the OS, or uses the user provided path.
|
||||||
|
func getConfigDir() (string, error) {
|
||||||
|
var envName string
|
||||||
|
var path []string
|
||||||
|
|
||||||
|
// User-provided directory takes priority over default directory.
|
||||||
|
if flagConfigDir != "" {
|
||||||
|
return flagConfigDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux", "darwin":
|
||||||
|
envName = "HOME"
|
||||||
|
path = []string{".config", "launcher"}
|
||||||
|
case "windows":
|
||||||
|
envName = "USERPROFILE"
|
||||||
|
path = []string{"launcher"}
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("Unknown OS: %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
home := os.Getenv(envName)
|
||||||
|
if home == "" {
|
||||||
|
return "", fmt.Errorf("%s is not set", envName)
|
||||||
|
}
|
||||||
|
path = append([]string{home}, path...)
|
||||||
|
configDir := filepath.Join(path...)
|
||||||
|
return configDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// getCommandsetFiles find the home directory and retrieves YAML files
|
||||||
|
// not hidden.
|
||||||
|
func getCommandsetFiles() ([]string, error) {
|
||||||
|
dir, err := getConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfos, err := ioutil.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No filenamer starting with '.', and no filenames not ending in
|
||||||
|
// ".yaml" or ".yml".
|
||||||
|
var filenames []string
|
||||||
|
var filename string
|
||||||
|
for _, entry := range fileInfos {
|
||||||
|
fName := entry.Name()
|
||||||
|
// Shortest considered file would by something like 'a.yml',
|
||||||
|
// 5 characters.
|
||||||
|
if len(fName) < 5 || fName[0] == '.' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m, _ := regexp.Match("(?i)\\.ya?ml", []byte(fName)); m {
|
||||||
|
filename = filepath.Join(dir, entry.Name())
|
||||||
|
filenames = append(filenames, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filenames, nil
|
||||||
|
}
|
19
go.mod
Normal file
19
go.mod
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
module launcher
|
||||||
|
|
||||||
|
go 1.20
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gdamore/tcell/v2 v2.2.0
|
||||||
|
github.com/mattn/go-runewidth v0.0.10
|
||||||
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gdamore/encoding v1.0.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
|
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 // indirect
|
||||||
|
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d // indirect
|
||||||
|
golang.org/x/text v0.3.5 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
)
|
32
go.sum
Normal file
32
go.sum
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||||
|
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||||
|
github.com/gdamore/tcell/v2 v2.2.0 h1:vSyEgKwraXPSOkvCk7IwOSyX+Pv3V2cV9CikJMXg4U4=
|
||||||
|
github.com/gdamore/tcell/v2 v2.2.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
|
||||||
|
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
|
||||||
|
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 h1:46ULzRKLh1CwgRq2dC5SlBzEqqNCi8rreOZnNrbqcIY=
|
||||||
|
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE=
|
||||||
|
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
||||||
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
23
log/pkg.go
Normal file
23
log/pkg.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
// Standard
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
logFile os.File
|
||||||
|
log *bufio.Writer
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
logFile, _ := os.OpenFile("/tmp/launcher.log", os.O_APPEND | os.O_CREATE | os.O_WRONLY, 0644)
|
||||||
|
log = bufio.NewWriter(logFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Print(format string, params ...interface{}) {
|
||||||
|
fmt.Fprintf(log, format+"\n", params...)
|
||||||
|
log.Flush()
|
||||||
|
}
|
66
main.go
Normal file
66
main.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
// Standard
|
||||||
|
"bufio"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mainCommandset CommandSet
|
||||||
|
flagConfigDir string
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var defaultConfigDir string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux", "darwin":
|
||||||
|
defaultConfigDir = "Default is $HOME/.config/launcher/."
|
||||||
|
case "windows":
|
||||||
|
defaultConfigDir = "Default is %USERPROFILE\\/launcher\\."
|
||||||
|
}
|
||||||
|
flag.StringVar(
|
||||||
|
&flagConfigDir,
|
||||||
|
"config",
|
||||||
|
"",
|
||||||
|
"Specify the directory containing configuration.\n"+
|
||||||
|
defaultConfigDir,
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Finding the files to parse commands from
|
||||||
|
files, err := getCommandsetFiles()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actually reading and parsing the YAML files into the
|
||||||
|
// CommandSet structs
|
||||||
|
errors := false
|
||||||
|
for _, fName := range files {
|
||||||
|
cset, err := CreateCommandSet(fName)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("\x1b[31m%s:\x1b[0m\n%s\n\n", fName, err)
|
||||||
|
errors = true
|
||||||
|
} else {
|
||||||
|
mainCommandset.Commands = append(
|
||||||
|
mainCommandset.Commands,
|
||||||
|
cset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors {
|
||||||
|
fmt.Printf("\x1b[33mPress enter to continue\x1b[0m\n")
|
||||||
|
bufio.NewReader(os.Stdin).ReadBytes('\n')
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen needs to be initialized
|
||||||
|
InitUI()
|
||||||
|
LoopUI(&mainCommandset)
|
||||||
|
}
|
29
run_linux.go
Normal file
29
run_linux.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// +build linux
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
// Standard
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func run(cmd *CommandSet) error {
|
||||||
|
screen.Fini()
|
||||||
|
c := exec.Command("/bin/bash", "-c", lastExecutableCommand.Exec)
|
||||||
|
if lastExecutableCommand.Inplace {
|
||||||
|
c.Stdin = os.Stdin
|
||||||
|
c.Stdout = os.Stdout
|
||||||
|
c.Stderr = os.Stderr
|
||||||
|
if err := c.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
Setpgid: true,
|
||||||
|
Pgid: 0,
|
||||||
|
}
|
||||||
|
c.Start()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
28
run_windows.go
Normal file
28
run_windows.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// +build windows
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
// Standard
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func run(cmd *CommandSet) error {
|
||||||
|
screen.Fini()
|
||||||
|
if lastExecutableCommand.Inplace {
|
||||||
|
c := exec.Command("cmd", "/C", cmd.Exec)
|
||||||
|
c.Stdin = os.Stdin
|
||||||
|
c.Stdout = os.Stdout
|
||||||
|
c.Stderr = os.Stderr
|
||||||
|
if err := c.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c := exec.Command("cmd", "/C", "start /b "+cmd.Exec)
|
||||||
|
if err := c.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
18
types.go
Normal file
18
types.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
type CommandSet struct {
|
||||||
|
Name string
|
||||||
|
Exec string
|
||||||
|
Inplace bool
|
||||||
|
Dynamic string
|
||||||
|
Commands []*CommandSet
|
||||||
|
|
||||||
|
// internal state
|
||||||
|
completed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
Search string
|
||||||
|
Error string
|
||||||
|
Commands []*CommandSet
|
||||||
|
}
|
245
ui.go
Normal file
245
ui.go
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
// External
|
||||||
|
tcell "github.com/gdamore/tcell/v2"
|
||||||
|
runewidth "github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
// Internal
|
||||||
|
"launcher/log"
|
||||||
|
|
||||||
|
// Standard
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
screen tcell.Screen
|
||||||
|
|
||||||
|
styleDefault tcell.Style
|
||||||
|
styleHeader tcell.Style
|
||||||
|
styleResultRegular tcell.Style
|
||||||
|
styleResultExecutable tcell.Style
|
||||||
|
styleResultInvalid tcell.Style
|
||||||
|
styleError tcell.Style
|
||||||
|
|
||||||
|
search string
|
||||||
|
searchRunes map[rune]struct{}
|
||||||
|
|
||||||
|
lastExecutableCommand *CommandSet
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init initializes the screen with ANSI command codes.
|
||||||
|
func InitUI() {
|
||||||
|
// The terminal screen needs to be initiated by tcell.
|
||||||
|
var err error
|
||||||
|
screen, err = tcell.NewScreen()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := screen.Init(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
styleDefault = tcell.StyleDefault
|
||||||
|
styleHeader = tcell.StyleDefault.Foreground(tcell.Color106)
|
||||||
|
styleError = tcell.StyleDefault.Foreground(tcell.Color124)
|
||||||
|
styleResultRegular = tcell.StyleDefault.Foreground(tcell.Color246)
|
||||||
|
styleResultExecutable = tcell.StyleDefault.Foreground(tcell.Color106)
|
||||||
|
styleResultInvalid = tcell.StyleDefault.Foreground(tcell.Color124)
|
||||||
|
|
||||||
|
// The accepted runes map is used to lookup every keystroke
|
||||||
|
// before appended to the search string.
|
||||||
|
searchRunes = make(map[rune]struct{})
|
||||||
|
for _, r := range []rune{
|
||||||
|
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
|
||||||
|
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
|
||||||
|
|
||||||
|
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
|
||||||
|
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'Y', 'V', 'W', 'X', 'Y', 'Z',
|
||||||
|
|
||||||
|
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||||
|
|
||||||
|
'.', '-', '_', ':', '+', '@', '*', '^', '$', ' ',
|
||||||
|
} {
|
||||||
|
searchRunes[r] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Want to be sure to start with a clean screen.
|
||||||
|
screen.Clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// addString places a new string in the screen buffer.
|
||||||
|
func addString(x, y int, style tcell.Style, str string) {
|
||||||
|
for _, c := range str {
|
||||||
|
var comb []rune
|
||||||
|
w := runewidth.RuneWidth(c)
|
||||||
|
if w == 0 {
|
||||||
|
comb = []rune{c}
|
||||||
|
c = ' '
|
||||||
|
w = 1
|
||||||
|
}
|
||||||
|
screen.SetContent(x, y, c, comb, style)
|
||||||
|
x += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderScreen composes all application state into a rendered screen image.
|
||||||
|
func renderScreen(cmd *CommandSet) {
|
||||||
|
screen.Clear()
|
||||||
|
lastExecutableCommand = nil
|
||||||
|
|
||||||
|
addString(0, 0, styleHeader, "Command:")
|
||||||
|
addString(0, 1, styleHeader, " Path:")
|
||||||
|
|
||||||
|
addString(9, 0, styleDefault, search)
|
||||||
|
|
||||||
|
results, err := cmd.Search(search)
|
||||||
|
if err != nil {
|
||||||
|
addString(0, 3, styleError, err.Error())
|
||||||
|
} else {
|
||||||
|
pos := 9
|
||||||
|
last := len(results)-1
|
||||||
|
for i, result := range results {
|
||||||
|
// Search errors likely is a regex syntax error or output from dynamic script.
|
||||||
|
if result.Error != "" {
|
||||||
|
errorLines := strings.Split(result.Error, "\n")
|
||||||
|
for j, line := range errorLines {
|
||||||
|
addString(9, 3+j, styleError, line)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(result.Commands) {
|
||||||
|
case 0:
|
||||||
|
addString(pos, 1, styleResultInvalid, result.Search)
|
||||||
|
pos += len(result.Search) + 1
|
||||||
|
|
||||||
|
// A single result was returned.
|
||||||
|
// We add it to the path of uniquely matched commands, and displays all
|
||||||
|
// children if it is the last command, so the user will known what can
|
||||||
|
// be selected after this command.
|
||||||
|
case 1:
|
||||||
|
if i == last && result.Commands[0].Exec != "" {
|
||||||
|
addString(pos, 1, styleResultExecutable, result.Commands[0].Name)
|
||||||
|
|
||||||
|
// lastMatchedCommand is stored to be used for the enter key event,
|
||||||
|
// when the last displayed executable command is executed.
|
||||||
|
lastExecutableCommand = result.Commands[0]
|
||||||
|
} else {
|
||||||
|
addString(pos, 1, styleResultRegular, result.Commands[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == last {
|
||||||
|
// Sorted output is more consistent for finding items
|
||||||
|
sort.SliceStable(result.Commands[0].Commands, func(i, j int) bool {
|
||||||
|
return result.Commands[0].Commands[i].Name < result.Commands[0].Commands[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
for i, matchingCommand := range result.Commands[0].Commands {
|
||||||
|
addString( 9, 3+i, styleDefault, matchingCommand.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos += len(result.Commands[0].Name) + 1
|
||||||
|
|
||||||
|
// More than one command was returned.
|
||||||
|
// A list of them is displayed for the user to choose from.
|
||||||
|
default:
|
||||||
|
addString(pos, 1, styleResultInvalid, result.Search)
|
||||||
|
|
||||||
|
// Sorted output is more consistent for finding items
|
||||||
|
sort.SliceStable(result.Commands, func(i, j int) bool {
|
||||||
|
return result.Commands[i].Name < result.Commands[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
for j, matchingCommand := range result.Commands {
|
||||||
|
addString( 9, 3+j, styleDefault, matchingCommand.Name)
|
||||||
|
}
|
||||||
|
pos += len(result.Search) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.ShowCursor(9+len(search), 0)
|
||||||
|
screen.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop is the main input loop and never returns.
|
||||||
|
func LoopUI(cmd *CommandSet) {
|
||||||
|
for {
|
||||||
|
switch ev := screen.PollEvent().(type) {
|
||||||
|
case *tcell.EventResize:
|
||||||
|
screen.Sync()
|
||||||
|
renderScreen(cmd)
|
||||||
|
|
||||||
|
case *tcell.EventKey:
|
||||||
|
// Program exits with escape.
|
||||||
|
if ev.Key() == tcell.KeyEscape {
|
||||||
|
screen.Fini()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backspace, because of course!
|
||||||
|
if ev.Key() == tcell.KeyBackspace2 || ev.Key() == tcell.KeyBS {
|
||||||
|
if last := len(search)-1; last >= 0 {
|
||||||
|
search = search[:last]
|
||||||
|
renderScreen(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+W for removing last word is a must.
|
||||||
|
if ev.Key() == tcell.KeyCtrlW {
|
||||||
|
// It feels natural to remove all trailing whitespace
|
||||||
|
// along with the previous word.
|
||||||
|
search = strings.TrimRight(search, " ")
|
||||||
|
|
||||||
|
if idx := strings.LastIndex(search, " "); idx > -1 {
|
||||||
|
search = search[:idx+1]
|
||||||
|
} else {
|
||||||
|
// No space before cursor - first word on line.
|
||||||
|
search = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
renderScreen(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And sometimes it is nice to start from the beginning.
|
||||||
|
if ev.Key() == tcell.KeyCtrlU {
|
||||||
|
search = ""
|
||||||
|
renderScreen(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// To make it simple, a small subset of keys are
|
||||||
|
// allowed appending to the search string.
|
||||||
|
if _, ok := searchRunes[ev.Rune()]; ok {
|
||||||
|
search += string(ev.Rune())
|
||||||
|
renderScreen(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands are executed with the Enter key.
|
||||||
|
if ev.Key() == tcell.KeyEnter {
|
||||||
|
if lastExecutableCommand != nil {
|
||||||
|
log.Print("%v", lastExecutableCommand.Exec)
|
||||||
|
err := run(lastExecutableCommand)
|
||||||
|
screen.Fini()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("%s\n", err)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sends ANSI sequences to close the screen and return the console to normal.
|
||||||
|
func Close() {
|
||||||
|
if screen != nil {
|
||||||
|
screen.Fini()
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user