diff --git a/.vscode/launch.json b/.vscode/launch.json index a3a1e557..f8ab61dd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,6 +6,9 @@ "request": "launch", "console": "integratedTerminal", "program": "${workspaceFolder}", + "args": [ + "tldr" + ] }, ] } diff --git a/cmd/root.go b/cmd/root.go index f1f5c6fb..493fcb1a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -80,7 +80,7 @@ func LoadConfig() (tui.Config, error) { return tui.Config{ Extensions: make(map[string]string), Window: tui.WindowOptions{ - Height: 0, + Height: 25, Margin: 0, Border: true, }, @@ -97,18 +97,6 @@ func NewRootCmd() (*cobra.Command, error) { config.Window.Margin = LookupIntEnv("SUNBEAM_MARGIN", config.Window.Margin) config.Window.Border = LookupBoolEnv("SUNBEAM_BORDER", config.Window.Border) - cacheDir, err := os.UserCacheDir() - if err != nil { - return nil, err - } - - cachePath := filepath.Join(cacheDir, "sunbeam", "extensions.json") - - extensions, err := tui.LoadExtensions(config, cachePath) - if err != nil { - return nil, err - } - // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "sunbeam", @@ -118,26 +106,33 @@ func NewRootCmd() (*cobra.Command, error) { Long: `Sunbeam is a command line launcher for your terminal, inspired by fzf and raycast. See https://pomdtr.github.io/sunbeam for more information.`, - RunE: func(cmd *cobra.Command, args []string) error { - if !isatty.IsTerminal(os.Stdout.Fd()) { - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - if err := encoder.Encode(extensions); err != nil { - return err + } + + if len(config.Items) > 0 { + rootCmd.RunE = func(cmd *cobra.Command, args []string) error { + items := make([]types.RootItem, 0) + for _, item := range config.Items { + origin, ok := config.Extensions[item.Extension] + if !ok { + continue } + + item.Origin = origin + items = append(items, item) } - return tui.Draw(tui.NewRootPage(extensions), config.Window) - }, + list := tui.NewRootList("Sunbeam", items...) + return tui.Draw(list, config.Window) + } + } rootCmd.AddGroup( &cobra.Group{ID: coreGroupID, Title: "Core Commands"}, ) - rootCmd.AddCommand(NewCmdUpdate(config, cachePath)) - rootCmd.AddCommand(NewCmdRun(extensions, config.Window)) - rootCmd.AddCommand(NewCmdServe(extensions)) + rootCmd.AddCommand(NewCmdRun(config)) + // rootCmd.AddCommand(NewCmdServe(extensions)) rootCmd.AddCommand(NewValidateCmd()) docCmd := &cobra.Command{ @@ -176,19 +171,55 @@ See https://pomdtr.github.io/sunbeam for more information.`, } rootCmd.AddCommand(manCmd) - if len(extensions.List()) == 0 { - return rootCmd, nil - } - rootCmd.AddGroup( &cobra.Group{ID: extensionGroupID, Title: "Extension Commands"}, ) - for _, alias := range extensions.List() { - cmd, err := NewCustomCmd(extensions, alias, config.Window) - if err != nil { - return nil, err - } - rootCmd.AddCommand(cmd) + for alias, origin := range config.Extensions { + alias := alias + origin := origin + rootCmd.AddCommand(&cobra.Command{ + Use: alias, + Short: origin, + DisableFlagParsing: true, + GroupID: extensionGroupID, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + extension, err := tui.LoadExtension(origin) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + if len(args) == 0 { + commands := make([]string, 0, len(extension.Manifest.Commands)) + for _, command := range extension.Manifest.Commands { + commands = append(commands, command.Name) + } + return commands, cobra.ShellCompDirectiveNoFileComp + } + + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return tui.Draw(tui.NewExtensionPage(origin), config.Window) + } + + extension, err := tui.LoadExtension(origin) + if err != nil { + return err + } + extension.Alias = alias + + extensionCmd, err := NewCustomCmd(extension, config) + if err != nil { + return err + } + + extensionCmd.SilenceErrors = true + extensionCmd.SetArgs(args) + + return extensionCmd.Execute() + }, + }) } return rootCmd, nil @@ -225,69 +256,60 @@ func buildDoc(command *cobra.Command) (string, error) { return out.String(), nil } -func NewCustomCmd(extensions tui.Extensions, alias string, options tui.WindowOptions) (*cobra.Command, error) { - extension, ok := extensions[alias] - if !ok { - return nil, fmt.Errorf("extension %s does not exist", alias) +func NewCustomCmd(extension tui.Extension, config tui.Config) (*cobra.Command, error) { + use := extension.Alias + if use == "" { + use = extension.Origin.String() } cmd := &cobra.Command{ - Use: alias, + Use: use, Short: extension.Manifest.Title, Long: extension.Manifest.Description, Args: cobra.NoArgs, SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - if !isatty.IsTerminal(os.Stdout.Fd()) { - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - if err := encoder.Encode(extension); err != nil { - return err - } - } - - return tui.Draw(tui.NewRootPage(extensions, alias), options) - }, - GroupID: extensionGroupID, } cmd.CompletionOptions.DisableDefaultCmd = true cmd.SetHelpCommand(&cobra.Command{Hidden: true}) - for name, command := range extension.Manifest.Commands { - name := name + for _, command := range extension.Manifest.Commands { command := command subcmd := &cobra.Command{ - Use: name, + Use: command.Name, Short: command.Title, Long: command.Description, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - args := make(map[string]any) - for name, arg := range command.Params { - switch arg.Type { + params := make(map[string]any) + for _, param := range command.Params { + if !cmd.Flags().Changed(param.Name) { + continue + } + + switch param.Type { case types.ParamTypeString: - value, err := cmd.Flags().GetString(name) + value, err := cmd.Flags().GetString(param.Name) if err != nil { return err } - args[name] = value + params[param.Name] = value case types.ParamTypeBoolean: - value, err := cmd.Flags().GetBool(name) + value, err := cmd.Flags().GetBool(param.Name) if err != nil { return err } - args[name] = value + params[param.Name] = value default: - return fmt.Errorf("unsupported argument type: %s", arg.Type) + return fmt.Errorf("unsupported argument type: %s", param.Type) } } if !isatty.IsTerminal(os.Stdout.Fd()) { - output, err := extension.Run(name, tui.CommandInput{ - Params: args, + output, err := extension.Run(command.Name, tui.CommandInput{ + Params: params, }) if err != nil { return err @@ -297,26 +319,59 @@ func NewCustomCmd(extensions tui.Extensions, alias string, options tui.WindowOpt return nil } - return tui.Draw(tui.NewCommand(extensions, types.CommandRef{ - Extension: alias, - Name: name, - Params: args, - }), options) + extensions := tui.Extensions{ + extension.Origin.String(): extension, + } + + if command.Mode == types.CommandModeSilent { + _, err := extension.Run(command.Name, tui.CommandInput{ + Params: params, + }) + + return err + } + + if command.Mode == types.CommandModeAction { + output, err := extension.Run(command.Name, tui.CommandInput{ + Params: params, + }) + if err != nil { + return err + } + + var action types.Action + if err := json.Unmarshal(output, &action); err != nil { + return err + } + + return tui.RunAction(action) + } + + return tui.Draw( + tui.NewCommand( + extensions, + types.CommandRef{ + Origin: extension.Origin.String(), + Name: command.Name, + Params: params, + }), + config.Window, + ) }, } - for name, arg := range command.Params { - switch arg.Type { + for _, param := range command.Params { + switch param.Type { case types.ParamTypeString: - subcmd.Flags().String(name, "", arg.Description) + subcmd.Flags().String(param.Name, "", param.Description) case types.ParamTypeBoolean: - subcmd.Flags().Bool(name, false, arg.Description) + subcmd.Flags().Bool(param.Name, false, param.Description) default: - return nil, fmt.Errorf("unsupported argument type: %s", arg.Type) + return nil, fmt.Errorf("unsupported argument type: %s", param.Type) } - if !arg.Optional { - subcmd.MarkFlagRequired(name) + if !param.Optional { + subcmd.MarkFlagRequired(param.Name) } } diff --git a/cmd/run.go b/cmd/run.go index ce70fc1c..b1c7f530 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -1,45 +1,58 @@ package cmd import ( - "path/filepath" - "github.com/pomdtr/sunbeam/internal/tui" "github.com/spf13/cobra" ) -func NewCmdRun(extensions tui.Extensions, options tui.WindowOptions) *cobra.Command { +func NewCmdRun(config tui.Config) *cobra.Command { cmd := &cobra.Command{ Use: "run [args...]", Short: "Run an extension without installing it", Args: cobra.MinimumNArgs(1), GroupID: coreGroupID, DisableFlagParsing: true, - RunE: func(_ *cobra.Command, args []string) error { - origin, err := tui.ParseOrigin(args[0]) - if err != nil { - return err + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{}, cobra.ShellCompDirectiveDefault } - manifest, err := tui.LoadManifest(origin) + extension, err := tui.LoadExtension(args[0]) if err != nil { - return err + return []string{}, cobra.ShellCompDirectiveDefault + } + + if len(args) == 1 { + commands := make([]string, 0) + for _, command := range extension.Manifest.Commands { + commands = append(commands, command.Name) + } + + return commands, cobra.ShellCompDirectiveNoFileComp + } + + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + if args[0] == "--help" || args[0] == "-h" { + return cmd.Help() } - alias := filepath.Base(origin.Path) - if alias == "/" { - alias = origin.Hostname() + if len(args) == 1 { + return tui.Draw(tui.NewExtensionPage(args[0]), config.Window) } - extensions[alias] = tui.Extension{ - Origin: origin, - Manifest: manifest, + extension, err := tui.LoadExtension(args[0]) + if err != nil { + return err } - scriptCmd, err := NewCustomCmd(extensions, alias, options) + scriptCmd, err := NewCustomCmd(extension, config) if err != nil { return err } scriptCmd.SilenceErrors = true + scriptCmd.SilenceUsage = true scriptCmd.SetArgs(args[1:]) return scriptCmd.Execute() diff --git a/cmd/update.go b/cmd/update.go deleted file mode 100644 index bd103e1e..00000000 --- a/cmd/update.go +++ /dev/null @@ -1,28 +0,0 @@ -package cmd - -import ( - "os" - - "github.com/pomdtr/sunbeam/internal/tui" - "github.com/spf13/cobra" -) - -func NewCmdUpdate(config tui.Config, cachePath string) *cobra.Command { - cmd := &cobra.Command{ - Use: "update", - Short: "update extension cache", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - _ = os.Remove(cachePath) - extensions, err := tui.LoadExtensions(config, cachePath) - if err != nil { - return err - } - - cmd.PrintErrf("Refreshed %d extensions", len(extensions)) - return nil - }, - } - - return cmd -} diff --git a/docs/examples/bitwarden/bitwarden.bash b/docs/examples/bitwarden/bitwarden.bash index 66666103..3ba9868d 100755 --- a/docs/examples/bitwarden/bitwarden.bash +++ b/docs/examples/bitwarden/bitwarden.bash @@ -5,21 +5,29 @@ if ! command -v jq &> /dev/null; then exit 1 fi +if ! command -v bw &> /dev/null; then + echo "bw is not installed" + exit 1 +fi + if [ $# -eq 0 ]; then jq -n '{ title: "Bitwarden Vault", commands: [ { - name: "list", + name: "list-passwords", title: "List Passwords", - mode: "list", + mode: "page", + params: [ + {name: "session", type: "string", optional: true, description: "session token"} + ] } ] }' exit 0 fi -if [ "$1" = "list" ]; then +if [ "$1" = "list-passwords" ]; then bw --nointeraction list items --session "$BW_SESSION" | jq '.[] | { title: .name, subtitle: (.login.username // ""), @@ -38,5 +46,5 @@ if [ "$1" = "list" ]; then key: "l" } ] - }' | jq -s '{items: .}' + }' | jq -s '{type: "list", items: .}' fi diff --git a/docs/examples/file-browser/file-browser.ts b/docs/examples/file-browser/file-browser.ts deleted file mode 100755 index 130f6404..00000000 --- a/docs/examples/file-browser/file-browser.ts +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env -S deno run --allow-read - -import * as sunbeam from "../../../pkg/typescript/mod.ts"; -import * as path from "https://deno.land/std@0.201.0/path/mod.ts"; -import { handler } from "../../../pkg/typescript/http.ts"; - -const extension = new sunbeam.Extension({ - title: "File Browser", - description: "A file browser extension for Sunbeam", -}).command({ - name: "browse", - title: "Browse files", - params: { - root: { - type: "string", - optional: true, - }, - }, - output: "list", - run: async ({ params }) => { - const root = params.root as string || Deno.cwd(); - - const entries = []; - for await (const entry of Deno.readDir(root)) { - entries.push(entry); - } - - const items: sunbeam.Listitem[] = entries.map((entry) => { - const actions: sunbeam.Action[] = []; - const filepath = path.join(root, entry.name); - - if (entry.isDirectory) { - actions.push({ - title: "Browse Directory", - type: "run", - command: { - name: "browse", - params: { - root: path.join(root, entry.name), - }, - }, - }); - } - - return { - title: entry.name, - subtitle: filepath, - actions, - }; - }); - - return { - title: `${root}`, - items, - }; - }, -}); - -Deno.serve(handler(extension)); diff --git a/docs/examples/git/git.bash b/docs/examples/git/git.bash new file mode 100755 index 00000000..b86a0058 --- /dev/null +++ b/docs/examples/git/git.bash @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +# check if jq is installed +if ! command -v jq &> /dev/null; then + echo "jq is not installed" + exit 1 +fi + +# check if jc is installed +if ! command -v jc &> /dev/null; then + echo "jc is not installed" + exit 1 +fi + +# check if git is installed +if ! command -v git &> /dev/null; then + echo "git is not installed" + exit 1 +fi + +if [ $# -eq 0 ]; then + jq -n '{ + title: "Git", + commands: [ + { + name: "list-commits", + title: "List Commits", + mode: "page" + }, + { + name: "commit-form", + title: "Write Commit", + mode: "page" + }, + { + name: "commit", + title: "Commit", + mode: "silent", + params: [ + {name: "message", type: "string", description: "Commit Message"} + ] + } + ] + }' + exit 0 +fi + +INPUT=$(cat) +COMMAND=$1 + +if [ "$COMMAND" = "list-commits" ]; then + git log | jc --git-log | jq '.[] | { + title: .message, + subtitle: .author, + accessories: [ + .commit[:7] + ], + }' | jq -s '{type: "list", items: .}' +elif [ "$COMMAND" = "commit-form" ]; then + jq -n '{ + type: "form", + command: {"name": "commit"}, + inputs: [ + {name: "message", type: "textarea", title: "Commit Message"} + ] + }' +elif [ "$COMMAND" = "commit" ]; then + MESSAGE=$(jq -r '.params.message' <<< "$INPUT") + git commit -m "$MESSAGE" +fi + diff --git a/docs/examples/github/github.bash b/docs/examples/github/github.bash index f81882e6..6fc06346 100755 --- a/docs/examples/github/github.bash +++ b/docs/examples/github/github.bash @@ -8,17 +8,24 @@ if ! command -v jq &> /dev/null; then exit 1 fi +# check if gh is installed +if ! command -v gh &> /dev/null; then + echo "gh is not installed" + exit 1 +fi + if [ $# -eq 0 ]; then jq -n '{ title: "GitHub", commands: [ - {output: "list", name: "list-repos", title: "List Repositories"}, - {output: "list", name: "list-prs", title: "List Pull Requests", params: [{name: "repository", type: "string"}]} + {name: "list-repos", mode: "page", title: "List Repositories"}, + {name: "list-prs", mode: "page", title: "List Pull Requests", params: [{name: "repo", type: "string"}]} ] }' exit 0 fi +INPUT=$(cat) if [ "$1" = "list-repos" ]; then # shellcheck disable=SC2016 gh api "/user/repos?sort=updated" | jq '.[] | @@ -26,15 +33,14 @@ if [ "$1" = "list-repos" ]; then title: .name, subtitle: (.description // ""), actions: [ - { type: "open", title: "Open in Browser", url: .html_url }, - { type: "copy", title: "Copy URL", text: .html_url, key: "o" }, - { type: "run", title: "List Pull Requests", key: "p", command: { name: "list-prs", params: { repository: .full_name }}} + { type: "open", title: "Open in Browser", url: .html_url, exit: true }, + { type: "copy", title: "Copy URL", text: .html_url, exit: true, key: "o" }, + { type: "run", title: "List Pull Requests", key: "p", command: { name: "list-prs", params: { repo: .full_name }}} ] } - ' | jq -s '{items: .}' + ' | jq -s '{type: "list", items: .}' elif [ "$1" == "list-prs" ]; then - eval "$(sunbeam parse bash)" - + REPOSITORY=$(jq -r '.params.repo' <<< "$INPUT") gh pr list --repo "$REPOSITORY" --json author,title,url,number | jq '.[] | { title: .title, @@ -43,9 +49,9 @@ elif [ "$1" == "list-prs" ]; then "#\(.number)" ], actions: [ - {type: "open", title: "Open in Browser", url: .url}, - {type: "copy", title: "Copy URL", text: .url} + {type: "open", title: "Open in Browser", url: .url, exit: true}, + {type: "copy", title: "Copy URL", text: .url, exit: true} ] } - ' | jq -s '{items: .}' + ' | jq -s '{type: "list", items: .}' fi diff --git a/docs/examples/raindrop/raindrop.ts b/docs/examples/raindrop/raindrop.ts index e9ab315d..9261b4b1 100755 --- a/docs/examples/raindrop/raindrop.ts +++ b/docs/examples/raindrop/raindrop.ts @@ -20,7 +20,7 @@ function fetchRaindrop(path: string, init?: RequestInit) { }); } -const extension = new sunbeam.Extension({ +const extension = new sunbeam.ExtensionClass({ title: "Raindrop", description: "Search your Raindrop bookmarks", }).command({ diff --git a/docs/examples/tldr/sunbeam.json b/docs/examples/tldr/sunbeam.json deleted file mode 100644 index de5a3fd0..00000000 --- a/docs/examples/tldr/sunbeam.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "title": "TLDR Pages", - "commands": [ - { - "name": "list", - "entrypoint": "./list.sh" - }, - { - "name": "view", - "entrypoint": "./view.sh", - "arguments": [ - { - "name": "command" - } - ] - } - ] -} diff --git a/docs/examples/tldr/tldr.bash b/docs/examples/tldr/tldr.bash index 4965073b..7e1e2cb7 100755 --- a/docs/examples/tldr/tldr.bash +++ b/docs/examples/tldr/tldr.bash @@ -21,17 +21,11 @@ if [ $# -eq 0 ]; then jq -n ' { title: "TLDR Pages", - # items are displayed in the root list - items: [ - { command: "list" }, - { command: "view", title: "View jq page", params: { page: "jq" }}, - { command: "view", title: "View curl page", params: { page: "curl" }} - ], # each command can be called through the cli - commands: { - list: { mode: "page", title: "List TLDR pages" }, - view: { mode: "page", title: "View TLDR page", params: { page: { type: "string" } } } - } + commands: [ + { name: "list", mode: "page", title: "List TLDR pages" }, + { name: "view", mode: "page", title: "View TLDR page", params: [{ name: "page", type: "string", description: "page to show" }] } + ] }' exit 0 fi diff --git a/docs/examples/vscode/vscode.bash b/docs/examples/vscode/vscode.bash new file mode 100755 index 00000000..b8eaa956 --- /dev/null +++ b/docs/examples/vscode/vscode.bash @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +# check if jq is installed +if ! command -v jq &> /dev/null +then + echo "jq could not be found" + exit +fi + +# check is sqlite3 is installed +if ! command -v sqlite3 &> /dev/null +then + echo "sqlite3 could not be found" + exit +fi + +if [ $# -eq 0 ] +then + jq -n '{ + title: "VS Code", + commands: [ + {name: "list-projects", title: "List Projects", mode: "page"}, + {name: "open-project", title: "Open Project", mode: "silent", params: [{name: "dir", type: "string"}]} + ] + }' + exit 0 +fi + +INPUT="$(cat)" +if [ "$1" = "list-projects" ]; then + dbPath="$HOME/Library/Application Support/Code/User/globalStorage/state.vscdb" + query="SELECT json_extract(value, '$.entries') as entries FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'" + + # get the recently opened paths + sqlite3 "$dbPath" "$query" | jq '.[] | select(.folderUri) | { + title: (.folderUri | split("/") | last), + actions: [ + {type: "run", title: "Open in VS Code", command: {name: "open-project", params: {dir: (.folderUri | sub("^file://"; ""))}}}, + {type: "open", title: "Open Folder", url: .folderUri} + + ] + }' | jq -s '{ + type: "list", + items: . + }' +elif [ "$1" = "open-project" ]; then + dir="$(echo "$INPUT" | jq -r '.params.dir')" + code "$dir" +fi + + diff --git a/go.mod b/go.mod index 3e6656ba..4eaeda19 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/alecthomas/chroma/v2 v2.9.1 + github.com/alessio/shellescape v1.4.2 github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.16.1 github.com/charmbracelet/bubbletea v0.24.2 diff --git a/go.sum b/go.sum index 2bf2b189..25db0058 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/alecthomas/chroma/v2 v2.9.1 h1:0O3lTQh9FxazJ4BYE/MOi/vDGuHn7B+6Bu902N github.com/alecthomas/chroma/v2 v2.9.1/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw= github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= +github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/tui/action.go b/internal/tui/action.go index 6881ac1b..5a8dce5b 100644 --- a/internal/tui/action.go +++ b/internal/tui/action.go @@ -118,8 +118,8 @@ func (al ActionList) Update(msg tea.Msg) (ActionList, tea.Cmd) { default: for _, action := range al.actions { if msg.String() == fmt.Sprintf("alt+%s", action.Key) { + al.Blur() return al, func() tea.Msg { - al.Blur() return action } } diff --git a/internal/tui/command.go b/internal/tui/command.go index 4690c778..54e1e4bd 100644 --- a/internal/tui/command.go +++ b/internal/tui/command.go @@ -5,70 +5,40 @@ import ( "fmt" "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/cli/browser" "github.com/mitchellh/mapstructure" "github.com/pomdtr/sunbeam/pkg/types" ) type Command struct { - header Header - viewport viewport.Model - embed Page - footer Footer + embed Page width, height int - commandRef types.CommandRef - extensions Extensions - command types.Command + ref types.CommandRef } -func NewCommand(extensions Extensions, commandRef types.CommandRef) *Command { - viewport := viewport.New(0, 0) +func NewCommand(extensions Extensions, ref types.CommandRef) *Command { return &Command{ - header: NewHeader(), - footer: NewFooter("Loading..."), - viewport: viewport, - extensions: extensions, - commandRef: commandRef, + ref: ref, } } func (c *Command) Init() tea.Cmd { - extension, ok := c.extensions[c.commandRef.Extension] - if !ok { - return nil - } + detail := NewDetail("Loading...", "") + detail.SetSize(c.width, c.height) + c.embed = detail - command, ok := extension.Commands[c.commandRef.Name] - if !ok { - return nil - } - c.command = command - - if c.command.Mode == "" { - c.embed = NewDetail(c.command.Title, "") - c.embed.SetSize(c.width, c.height) - return tea.Sequence(c.embed.Init()) - } - - return tea.Batch(FocusCmd, c.header.SetIsLoading(true), c.Reload) + return tea.Batch(c.Run, detail.SetIsLoading(true)) } func (c *Command) SetSize(w int, h int) { c.width = w c.height = h - c.header.Width = w - c.footer.Width = w - c.viewport.Width = w - c.viewport.Height = h - lipgloss.Height(c.header.View()) - lipgloss.Height(c.footer.View()) - if c.embed != nil { c.embed.SetSize(w, h) } @@ -78,29 +48,17 @@ func (c *Command) Update(msg tea.Msg) (Page, tea.Cmd) { switch msg := msg.(type) { case types.Form: form := msg - if form.Title == "" { - form.Title = c.command.Title - } - - page, ok := c.embed.(*Form) - if !ok { - return c, nil - } - var formitems []FormItem - for _, item := range form.Items { + for _, item := range form.Inputs { formitems = append(formitems, *NewFormItem(item)) } - page.SetItems(formitems...) + page := NewForm(form.Title, formitems...) + page.SetSize(c.width, c.height) c.embed = page return c, c.embed.Init() - case types.Detail: detail := msg - if detail.Title == "" { - detail.Title = c.command.Title - } page := NewDetail(detail.Title, detail.Text, detail.Actions...) if detail.Language != "" { @@ -112,9 +70,6 @@ func (c *Command) Update(msg tea.Msg) (Page, tea.Cmd) { return c, nil case types.List: list := msg - if list.Title == "" { - list.Title = c.command.Title - } page := NewList(list.Title, list.Items...) page.SetSize(c.width, c.height) @@ -123,11 +78,23 @@ func (c *Command) Update(msg tea.Msg) (Page, tea.Cmd) { case types.Action: action := msg return c, func() tea.Msg { - if action.Type == types.ActionTypeRun && action.Command.Extension == "" { - action.Command.Extension = c.commandRef.Extension + if action.Type == types.ActionTypeRun && action.Command.Origin == "" { + action.Command.Origin = c.ref.Origin + + return PushPageMsg{ + Page: NewCommand(c.extensions, action.Command), + } } - return RunAction(c.extensions, action) + if err := RunAction(action); err != nil { + return err + } + + if action.Exit { + return ExitMsg{} + } + + return nil } case error: c.embed = NewErrorPage(msg) @@ -135,40 +102,69 @@ func (c *Command) Update(msg tea.Msg) (Page, tea.Cmd) { return c, c.embed.Init() } - var cmds []tea.Cmd - var cmd tea.Cmd - if c.embed != nil { + var cmd tea.Cmd c.embed, cmd = c.embed.Update(msg) - cmds = append(cmds, cmd) + return c, cmd } - c.header, cmd = c.header.Update(msg) - cmds = append(cmds, cmd) - - return c, tea.Batch(cmds...) + return c, nil } func (c *Command) View() string { - if c.embed != nil { - return c.embed.View() - } - - return lipgloss.JoinVertical(lipgloss.Left, c.header.View(), c.viewport.View(), c.footer.View()) + return c.embed.View() } -func (d *Command) Reload() tea.Msg { - extension, ok := d.extensions[d.commandRef.Extension] +func (c *Command) Run() tea.Msg { + extension, ok := c.extensions[c.ref.Origin] + if !ok { + e, err := LoadExtension(c.ref.Origin) + if err != nil { + return err + } + + c.extensions[c.ref.Origin] = e + extension = e + } + + command, ok := extension.Command(c.ref.Name) if !ok { - return fmt.Errorf("extension %s does not exist", d.commandRef.Extension) + return fmt.Errorf("command %s does not exist", c.ref.Name) } - output, err := extension.Run(d.commandRef.Name, CommandInput{ - Params: d.commandRef.Params, + + output, err := extension.Run(command.Name, CommandInput{ + Params: c.ref.Params, }) if err != nil { return err } + if command.Mode == types.CommandModeSilent { + return ExitMsg{} + } + + if command.Mode == types.CommandModeAction { + var action types.Action + + if err := json.Unmarshal(output, &action); err != nil { + return err + } + + if action.Type == types.ActionTypeRun { + return fmt.Errorf("cannot chain run actions") + } + + if err := RunAction(action); err != nil { + return err + } + + if action.Exit { + return ExitMsg{} + } + + return nil + } + var page map[string]any if err := json.Unmarshal(output, &page); err != nil { return err @@ -191,6 +187,10 @@ func (d *Command) Reload() tea.Msg { return err } + if detail.Title == "" { + detail.Title = command.Title + } + return detail case types.PageTypeList: var list types.List @@ -198,6 +198,10 @@ func (d *Command) Reload() tea.Msg { return err } + if list.Title == "" { + list.Title = command.Title + } + return list case types.PageTypeForm: var form types.Form @@ -205,87 +209,29 @@ func (d *Command) Reload() tea.Msg { return err } + if form.Title == "" { + form.Title = command.Title + } + return form default: return fmt.Errorf("invalid command output") } } -func RunAction(extensions Extensions, action types.Action) tea.Msg { +func RunAction(action types.Action) error { switch action.Type { case types.ActionTypeCopy: if err := clipboard.WriteAll(action.Text); err != nil { return fmt.Errorf("could not copy to clipboard: %s", action.Text) } - - if action.Exit { - return ExitMsg{} - } - return nil case types.ActionTypeOpen: if err := browser.OpenURL(action.Url); err != nil { return fmt.Errorf("could not open url: %s", action.Url) } - - if action.Exit { - return ExitMsg{} - } - return nil - case types.ActionTypeReload: + default: return nil - case types.ActionTypeRun: - extension, ok := extensions[action.Command.Extension] - if !ok { - return fmt.Errorf("extension %s does not exist", action.Command.Extension) - } - command, ok := extension.Commands[action.Command.Name] - if !ok { - return fmt.Errorf("command %s does not exist", action.Command.Name) - } - - switch command.Mode { - case types.CommandModePage: - return PushPageMsg{Page: NewCommand(extensions, action.Command)} - case types.CommandModeSilent: - - if _, err := extension.Run(action.Command.Name, CommandInput{ - Params: action.Command.Params, - }); err != nil { - return err - } - - return ExitMsg{} - case types.CommandModeAction: - output, err := extension.Run(action.Command.Name, CommandInput{ - Params: action.Command.Params, - }) - if err != nil { - return err - } - var action types.Action - if err := json.Unmarshal(output, &action); err != nil { - return err - } - - if action.Type == types.ActionTypeRun { - command, ok := extension.Commands[action.Command.Name] - if !ok { - return fmt.Errorf("command %s does not exist", action.Command.Name) - } - - if command.Mode == types.CommandModeAction { - return fmt.Errorf("too many nested actions") - } - - return PushPageMsg{Page: NewCommand(extensions, action.Command)} - } - - return RunAction(extensions, action) - } - } - - return nil } diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 86e4bf3f..64ddb0a9 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -57,10 +57,11 @@ func NewDetail(title string, text string, actions ...types.Action) *Detail { footer: footer, text: text, } - d.RefreshContent() + d.RefreshContent() return &d } + func (d *Detail) Init() tea.Cmd { return nil } @@ -88,8 +89,8 @@ func (c *Detail) Update(msg tea.Msg) (Page, tea.Cmd) { break } - if len(c.actionList.actions) < 2 { - return c, nil + if len(c.actionList.actions) == 0 { + break } return c, c.actionList.Focus() @@ -152,6 +153,8 @@ func (c *Detail) SetSize(width, height int) { c.actionList.SetSize(width, height) c.viewport.Height = height - lipgloss.Height(c.header.View()) - lipgloss.Height(c.footer.View()) + + c.RefreshContent() } func (c *Detail) View() string { diff --git a/internal/tui/error.go b/internal/tui/error.go index bbef5ed6..0c838733 100644 --- a/internal/tui/error.go +++ b/internal/tui/error.go @@ -16,6 +16,7 @@ type ErrorPage struct { func NewErrorPage(err error) *ErrorPage { viewport := viewport.New(0, 0) + viewport.Style = lipgloss.NewStyle().Padding(0, 1) viewport.SetContent(err.Error()) page := ErrorPage{ header: NewHeader(), diff --git a/internal/tui/extension.go b/internal/tui/extension.go new file mode 100644 index 00000000..24e8bb73 --- /dev/null +++ b/internal/tui/extension.go @@ -0,0 +1,103 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/pomdtr/sunbeam/pkg/types" +) + +type ExtensionPage struct { + origin string + extension Extension + list *List +} + +func NewExtensionPage(origin string) *ExtensionPage { + list := NewList("Loading...") + return &ExtensionPage{ + list: list, + origin: origin, + } +} + +func (c *ExtensionPage) Init() tea.Cmd { + return tea.Batch(c.list.SetIsLoading(true), c.list.Init(), func() tea.Msg { + extension, err := LoadExtension(c.origin) + if err != nil { + return err + } + return extension + }) +} + +func (c *ExtensionPage) SetSize(width, height int) { + c.list.SetSize(width, height) +} + +func IsRootCommand(command types.Command) bool { + for _, param := range command.Params { + if !param.Optional { + return false + } + } + + return true +} + +func (c *ExtensionPage) Update(msg tea.Msg) (Page, tea.Cmd) { + switch msg := msg.(type) { + case Extension: + extension := msg + c.extension = extension + + var items []types.ListItem + for _, command := range extension.Commands { + if IsRootCommand(command) { + items = append(items, types.ListItem{ + Title: command.Title, + Actions: []types.Action{ + { + Type: types.ActionTypeRun, + Title: "Run Command", + Command: types.CommandRef{ + Origin: c.origin, + Name: command.Name, + Params: make(map[string]any), + }, + }, + }, + }) + } + } + + c.list.footer.title = extension.Title + c.list.SetItems(items...) + return c, c.list.SetIsLoading(false) + case types.Action: + action := msg + return c, func() tea.Msg { + if action.Type == types.ActionTypeRun { + return PushPageMsg{NewCommand(Extensions{c.origin: c.extension}, action.Command)} + } + + if err := RunAction(action); err != nil { + return err + } + + if action.Exit { + return ExitMsg{} + } + + return nil + } + } + + var cmd tea.Cmd + page, cmd := c.list.Update(msg) + c.list = page.(*List) + + return c, cmd +} + +func (c *ExtensionPage) View() string { + return c.list.View() +} diff --git a/internal/tui/extensions.go b/internal/tui/extensions.go index 5420a39b..cd519a8c 100644 --- a/internal/tui/extensions.go +++ b/internal/tui/extensions.go @@ -20,20 +20,35 @@ import ( type Config struct { Extensions map[string]string `json:"extensions"` + Items []types.RootItem `json:"items"` Window WindowOptions `json:"window"` } type Extension struct { Origin *url.URL + Alias string types.Manifest } type CommandInput struct { Query string `json:"query,omitempty"` - Params map[string]any `json:"params,omitempty"` + Params map[string]any `json:"params"` +} + +func (e Extension) Command(name string) (types.Command, bool) { + for _, command := range e.Commands { + if command.Name == name { + return command, true + } + } + + return types.Command{}, false } func (ext Extension) Run(commandName string, input CommandInput) ([]byte, error) { + if input.Params == nil { + input.Params = make(map[string]any) + } inputBytes, err := json.Marshal(input) if err != nil { return nil, err @@ -136,107 +151,60 @@ func ParseOrigin(origin string) (*url.URL, error) { return url, nil } -func LoadExtensions(config Config, cachePath string) (Extensions, error) { - cache := make(map[string]types.Manifest) - if f, err := os.Open(cachePath); err == nil { - if err := json.NewDecoder(f).Decode(&cache); err != nil { - return nil, err - } - } - - extensions := make(Extensions) - var dirty bool - for alias, origin := range config.Extensions { - originUrl, err := ParseOrigin(origin) - if err != nil { - return nil, err - } - - if manifest, ok := cache[originUrl.String()]; ok { - extensions[alias] = Extension{ - Origin: originUrl, - Manifest: manifest, - } - continue - } - - manifest, err := LoadManifest(originUrl) - if err != nil { - return nil, err - } - - extensions[originUrl.String()] = Extension{ - Origin: originUrl, - Manifest: manifest, - } - - cache[originUrl.String()] = manifest - dirty = true - } - - if !dirty { - return extensions, nil - } - - if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil { - return nil, err - } - - cacheFile, err := os.Create(cachePath) +func LoadExtension(originRaw string) (Extension, error) { + origin, err := ParseOrigin(originRaw) if err != nil { - return nil, err + return Extension{}, err } - if err := json.NewEncoder(cacheFile).Encode(cache); err != nil { - return nil, err - } - - return extensions, nil -} - -func LoadManifest(origin *url.URL) (types.Manifest, error) { var manifest types.Manifest if origin.Scheme == "file" { command := exec.Command(origin.Path) b, err := command.Output() if err != nil { - return manifest, err + return Extension{}, err } if err := schemas.ValidateManifest(b); err != nil { - return manifest, err + return Extension{}, err } if err := json.Unmarshal(b, &manifest); err != nil { - return manifest, err + return Extension{}, err } - return manifest, nil + return Extension{ + Origin: origin, + Manifest: manifest, + }, nil } resp, err := http.Get(origin.String()) if err != nil { - return manifest, err + return Extension{}, err } defer resp.Body.Close() if resp.StatusCode != 200 { - return manifest, fmt.Errorf("failed to fetch extension manifest: %s", resp.Status) + return Extension{}, fmt.Errorf("failed to fetch extension manifest: %s", resp.Status) } b, err := io.ReadAll(resp.Body) if err != nil { - return manifest, err + return Extension{}, err } if err := schemas.ValidateManifest(b); err != nil { - return manifest, err + return Extension{}, err } if err := json.Unmarshal(b, &manifest); err != nil { - return manifest, err + return Extension{}, err } - return manifest, nil + return Extension{ + Origin: origin, + Manifest: manifest, + }, nil } diff --git a/internal/tui/listitem.go b/internal/tui/listitem.go index 4a5b52d7..e25899db 100644 --- a/internal/tui/listitem.go +++ b/internal/tui/listitem.go @@ -55,14 +55,12 @@ func (i ListItem) Render(width int, selected bool) string { blanks = strings.Repeat(" ", availableWidth) } else if width >= lipgloss.Width(title+accessories) { subtitle = subtitle[:width-lipgloss.Width(title+accessories)] - } else if width >= lipgloss.Width(title) { + } else if width >= lipgloss.Width(accessories) { subtitle = "" - start_index := len(accessories) - (width - lipgloss.Width(title)) + 1 - accessories = " " + accessories[start_index:] + title = title[:width-lipgloss.Width(accessories)] } else { subtitle = "" accessories = "" - // Why is this -1? I don't know, but it works title = title[:min(len(title), width)] } diff --git a/internal/tui/root.go b/internal/tui/root.go index 8f302397..8ac69194 100644 --- a/internal/tui/root.go +++ b/internal/tui/root.go @@ -1,141 +1,146 @@ package tui import ( + "bytes" + "encoding/json" "fmt" - "github.com/charmbracelet/bubbles/key" + "github.com/alessio/shellescape" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/pomdtr/sunbeam/pkg/types" ) type RootList struct { - header Header - footer Footer - filter Filter - - extensions Extensions + list *List } -func NewRootPage(extensions Extensions, allowlist ...string) *RootList { - items := make([]FilterItem, 0) - - allowMap := make(map[string]bool) - for _, alias := range allowlist { - allowMap[alias] = true +func shellCommand(item types.RootItem) (string, error) { + args := []string{"sunbeam"} + if item.Extension != "" { + args = append(args, item.Extension) + } else { + args = append(args, "run", item.Origin) } - for alias, ext := range extensions { - for i, item := range ext.Items { - if _, ok := allowMap[alias]; len(allowMap) > 0 && !ok { - continue + args = append(args, item.Command) + for key, value := range item.Params { + switch v := value.(type) { + case string: + args = append(args, fmt.Sprintf("--%s=%s", key, v)) + case bool: + if v { + args = append(args, fmt.Sprintf("--%s", key)) } + default: + return "", fmt.Errorf("unknown type %T", v) + } + } - command, ok := ext.Commands[item.Command] - if !ok { - continue - } + return shellescape.QuoteCommand(args), nil +} - title := item.Title - if title == "" { - title = command.Title - } +func NewRootList(title string, rootItems ...types.RootItem) *RootList { + items := make([]types.ListItem, 0) + for _, rootitem := range rootItems { + shell, err := shellCommand(rootitem) + if err != nil { + continue + } - items = append(items, ListItem{ - Id: fmt.Sprintf("%s:%d", alias, i), - Title: title, - Subtitle: ext.Title, - Accessories: []string{alias}, - Actions: []types.Action{ - { - Type: types.ActionTypeRun, - Text: "Run", - Command: types.CommandRef{ - Extension: alias, - Name: item.Command, - Params: item.Params, - }, + listitem := types.ListItem{ + Id: shell, + Title: rootitem.Title, + Actions: []types.Action{ + { + Type: types.ActionTypeRun, + Title: "Run Command", + Command: types.CommandRef{ + Origin: rootitem.Origin, + Name: rootitem.Command, + Params: rootitem.Params, }, }, + }, + } + + if rootitem.Extension != "" { + var accessory string + if rootitem.Command != "" { + accessory = fmt.Sprintf("%s %s", rootitem.Extension, rootitem.Command) + } else { + accessory = rootitem.Extension + } + listitem.Accessories = []string{accessory} + listitem.Actions = append(listitem.Actions, types.Action{ + Type: types.ActionTypeCopy, + Title: "Copy as Shell Command", + Text: shell, + Exit: true, }) - } - } + buffer := bytes.Buffer{} + encoder := json.NewEncoder(&buffer) + encoder.SetIndent("", " ") + if err := encoder.Encode(rootitem); err != nil { + continue + } - filter := NewFilter(items...) - filter.DrawLines = true - footer := NewFooter("Sunbeam") - footer.SetBindings( - key.NewBinding(key.WithKeys("enter"), key.WithHelp("↩", "Run")), - ) + listitem.Actions = append(listitem.Actions, types.Action{ + Type: types.ActionTypeCopy, + Title: "Copy as JSON", + Text: buffer.String(), + Exit: true, + }) + } + items = append(items, listitem) + } + list := NewList(title, items...) page := RootList{ - extensions: extensions, - header: NewHeader(), - footer: footer, - filter: filter, + list: list, } return &page } func (c *RootList) Init() tea.Cmd { - return tea.Batch(c.header.Init(), FocusCmd) + return tea.Batch(c.list.Init(), FocusCmd) } func (c *RootList) Update(msg tea.Msg) (Page, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "esc": - return c, PopPageCmd - case "enter": - selection := c.filter.Selection() - if selection == nil { - return c, nil + case types.Action: + action := msg + return c, func() tea.Msg { + if action.Type == types.ActionTypeRun { + if action.Command.Name == "" { + return PushPageMsg{NewExtensionPage(action.Command.Origin)} + } + + return PushPageMsg{NewCommand(make(Extensions), action.Command)} } - item, ok := selection.(ListItem) - if !ok { - return c, nil + if err := RunAction(action); err != nil { + return err } - if len(item.Actions) == 0 { - return c, nil + if action.Exit { + return ExitMsg{} } - action := item.Actions[0] - - return c, PushPageCmd(NewCommand(c.extensions, action.Command)) + return nil } } - var cmds []tea.Cmd - var cmd tea.Cmd + page, cmd := c.list.Update(msg) + c.list = page.(*List) - header, cmd := c.header.Update(msg) - cmds = append(cmds, cmd) - - filter, cmd := c.filter.Update(msg) - cmds = append(cmds, cmd) - - if header.Value() != c.header.Value() { - filter.FilterItems(header.Value()) - } - - c.header = header - c.filter = filter - return c, tea.Batch(cmds...) + return c, cmd } - func (c *RootList) View() string { - return lipgloss.JoinVertical(lipgloss.Left, c.header.View(), c.filter.View(), c.footer.View()) + return c.list.View() } func (c *RootList) SetSize(width, height int) { - c.header.Width = width - c.footer.Width = width - c.filter.Width = width - - c.filter.Height = height - lipgloss.Height(c.header.View()) - lipgloss.Height(c.footer.View()) + c.list.SetSize(width, height) } diff --git a/pkg/schemas/config.schema.json b/pkg/schemas/config.schema.json index 5baa78ae..db6a49c7 100644 --- a/pkg/schemas/config.schema.json +++ b/pkg/schemas/config.schema.json @@ -14,35 +14,58 @@ "items": { "type": "array", "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "extension", - "command" - ], - "properties": { - "title": { - "type": "string" - }, - "extension": { - "type": "string" - }, - "command": { - "type": "string" - }, - "params": { - "type": "object", - "patternProperties": { - ".+": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "boolean" - } - ] - } + "$ref": "#/definitions/rootitem" + } + }, + "window": { + "type": "object", + "additionalProperties": false, + "properties": { + "border": { + "type": "boolean" + }, + "height": { + "type": "integer", + "minimum": 0 + }, + "margin": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "definitions": { + "rootitem": { + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "extension", + "command" + ], + "properties": { + "title": { + "type": "string" + }, + "extension": { + "type": "string" + }, + "command": { + "type": "string" + }, + "params": { + "type": "object", + "patternProperties": { + ".+": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] } } } diff --git a/pkg/schemas/manifest.schema.json b/pkg/schemas/manifest.schema.json index c51f7018..8a8f4a7d 100644 --- a/pkg/schemas/manifest.schema.json +++ b/pkg/schemas/manifest.schema.json @@ -3,7 +3,6 @@ "type": "object", "required": [ "title", - "items", "commands" ], "additionalProperties": false, @@ -11,12 +10,6 @@ "title": { "type": "string" }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/item" - } - }, "homepage": { "type": "string" }, @@ -24,49 +17,18 @@ "type": "string" }, "commands": { - "type": "object", - "patternProperties": { - ".+": { - "$ref": "#/definitions/command" - } + "type": "array", + "items": { + "$ref": "#/definitions/command" } } }, "definitions": { - "item": { - "type": "object", - "required": [ - "command" - ], - "additionalProperties": false, - "properties": { - "command": { - "type": "string" - }, - "title": { - "type": "string" - }, - "params": { - "type": "object", - "patternProperties": { - ".+": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "boolean" - } - ] - } - } - } - } - }, "command": { "type": "object", "required": [ "title", + "name", "mode" ], "additionalProperties": false, @@ -81,29 +43,33 @@ "title": { "type": "string" }, + "name": { + "type": "string" + }, "params": { - "type": "object", - "patternProperties": { - ".+": { - "type": "object", - "required": [ - "type" - ], - "additionalProperties": false, - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "string", - "boolean" - ] - }, - "optional": { - "type": "boolean" - } + "type": "array", + "items": { + "type": "object", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "boolean" + ] + }, + "description": { + "type": "string" + }, + "optional": { + "type": "boolean" } } } diff --git a/pkg/schemas/page.schema.json b/pkg/schemas/page.schema.json index 08d0d054..7a8c58c3 100644 --- a/pkg/schemas/page.schema.json +++ b/pkg/schemas/page.schema.json @@ -110,10 +110,10 @@ "command": { "$ref": "#/definitions/command" }, - "items": { + "inputs": { "type": "array", "items": { - "$ref": "#/definitions/formitem" + "$ref": "#/definitions/input" } } } @@ -152,7 +152,7 @@ } } }, - "formitem": { + "input": { "oneOf": [ { "type": "object", diff --git a/pkg/types/manifest.go b/pkg/types/manifest.go index ebe62174..8e134761 100644 --- a/pkg/types/manifest.go +++ b/pkg/types/manifest.go @@ -1,48 +1,28 @@ package types -import ( - "encoding/json" - "fmt" -) - type Manifest struct { - Title string `json:"title"` - Homepage string `json:"homepage,omitempty"` - Description string `json:"description,omitempty"` - Commands map[string]Command `json:"commands"` - Items []RootItem `json:"items,omitempty"` + Title string `json:"title"` + Homepage string `json:"homepage,omitempty"` + Description string `json:"description,omitempty"` + Commands []Command `json:"commands"` + Items []RootItem `json:"items,omitempty"` } type RootItem struct { - Title string `json:"title,omitempty"` - Command string `json:"command"` - Params map[string]any `json:"params,omitempty"` -} - -type Entrypoint []string - -func (e *Entrypoint) UnmarshalJSON(b []byte) error { - var entrypoint string - if err := json.Unmarshal(b, &entrypoint); err == nil { - *e = Entrypoint{entrypoint} - return nil - } - - var entrypoints []string - if err := json.Unmarshal(b, &entrypoints); err == nil { - *e = Entrypoint(entrypoints) - return nil - } - - return fmt.Errorf("invalid entrypoint: %s", string(b)) + Title string `json:"title"` + Extension string `json:"extension"` + Origin string `json:"-"` + Command string `json:"command"` + Params map[string]any `json:"params,omitempty"` } type Command struct { - Title string `json:"title"` - Mutation bool `json:"mutation,omitempty"` - Description string `json:"description,omitempty"` - Params map[string]CommandParam `json:"params,omitempty"` - Mode CommandMode `json:"mode,omitempty"` + Name string `json:"name"` + Title string `json:"title"` + Mutation bool `json:"mutation,omitempty"` + Description string `json:"description,omitempty"` + Params []CommandParam `json:"params,omitempty"` + Mode CommandMode `json:"mode,omitempty"` } type CommandMode string @@ -54,6 +34,7 @@ const ( ) type CommandParam struct { + Name string `json:"name"` Type ParamType `json:"type"` Optional bool `json:"optional,omitempty"` Description string `json:"description,omitempty"` diff --git a/pkg/types/page.go b/pkg/types/page.go index 63eab828..157f9af3 100644 --- a/pkg/types/page.go +++ b/pkg/types/page.go @@ -23,7 +23,7 @@ type Detail struct { type Form struct { Title string `json:"title,omitempty"` - Items []FormItem `json:"inputs,omitempty"` + Inputs []FormItem `json:"inputs,omitempty"` Command CommandRef `json:"command,omitempty"` } @@ -33,12 +33,11 @@ type EmptyView struct { } type ListItem struct { - Id string `json:"id,omitempty"` - Title string `json:"title"` - Subtitle string `json:"subtitle,omitempty"` - Accessories []string `json:"accessories,omitempty"` - Actions []Action `json:"actions,omitempty"` - Metadata map[string]Metadata `json:"metadata,omitempty"` + Id string `json:"id,omitempty"` + Title string `json:"title"` + Subtitle string `json:"subtitle,omitempty"` + Accessories []string `json:"accessories,omitempty"` + Actions []Action `json:"actions,omitempty"` } type Metadata struct { @@ -100,7 +99,7 @@ type Action struct { } type CommandRef struct { - Extension string `json:"-"` - Name string `json:"name"` - Params map[string]any `json:"params,omitempty"` + Origin string `json:"origin,omitempty"` + Name string `json:"name"` + Params map[string]any `json:"params,omitempty"` } diff --git a/pkg/typescript/config.ts b/pkg/typescript/config.ts index 11620176..e5faa520 100644 --- a/pkg/typescript/config.ts +++ b/pkg/typescript/config.ts @@ -13,16 +13,22 @@ export interface Config { */ [k: string]: string; }; - items?: { - title?: string; - extension: string; - command: string; - params?: { - /** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` ".+". - */ - [k: string]: string | boolean; - }; - }[]; + items?: Rootitem[]; + window?: { + border?: boolean; + height?: number; + margin?: number; + }; +} +export interface Rootitem { + title: string; + extension: string; + command: string; + params?: { + /** + * This interface was referenced by `undefined`'s JSON-Schema definition + * via the `patternProperty` ".+". + */ + [k: string]: string | boolean; + }; } diff --git a/pkg/typescript/example.ts b/pkg/typescript/example.ts deleted file mode 100644 index cf999929..00000000 --- a/pkg/typescript/example.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Extension } from "./extension.ts"; - -new Extension({ - title: "Example", -}).command("list", { - mode: "page", - title: "List", - run: async ({ params }) => { - }, -}); diff --git a/pkg/typescript/exec.ts b/pkg/typescript/exec.ts deleted file mode 100644 index 49210bf2..00000000 --- a/pkg/typescript/exec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Extension } from "./extension.ts" -import { readAll } from "https://deno.land/std@0.201.0/streams/read_all.ts"; - -export default async function exec(extension: Extension) { - if (Deno.args.length == 0) { - console.log(JSON.stringify(extension.toJSON())) - return - } - - const command = Deno.args[0] - const input = JSON.parse(new TextDecoder().decode(await readAll(Deno.stdin))) - const output = await extension.run(command, input) - console.log(JSON.stringify(output)) -} diff --git a/pkg/typescript/extension.ts b/pkg/typescript/extension.ts deleted file mode 100644 index 6b6aca7d..00000000 --- a/pkg/typescript/extension.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Command, Item, Manifest } from "./manifest.ts"; -import type { Action, Page } from "./page.ts"; - -type RunProps = { - params: Record; - query?: string; -}; - -type CommandProps = Command & { - run: Runner; - items?: Item[]; -}; - -export type Runner = ( - props: RunProps, -) => Page | Action | void | Promise; - -export class Extension { - private manifest: Omit; - private _commands: Record = {}; - private _items: Item[] = []; - private runners: Record< - string, - Runner - > = {}; - - constructor(props: Omit) { - this.manifest = { - ...props, - }; - } - - static load( - manifest: Manifest, - runners: Record, - ) { - const extension = new Extension({ - title: manifest.title, - description: manifest.description, - homepage: manifest.homepage, - }); - extension._items.push(...manifest.items); - - for (const [name, command] of Object.entries(manifest.commands)) { - const runner = runners[name]; - if (!runner) { - throw new Error(`Command not found: ${name}`); - } - extension.command(name, { - ...command, - run: runner, - }); - } - - return extension; - } - - toJSON() { - return { - ...this.manifest, - commands: Object.values(this._commands), - items: this._items, - }; - } - - get title() { - return this.manifest.title; - } - - get commands() { - return this._commands; - } - - get items() { - return this._items; - } - - get homepage() { - return this.manifest.homepage; - } - - command( - name: string, - props: CommandProps, - ) { - const { run, ...command } = props; - this._commands[name] = command; - this.runners[name] = run; - if (props.items) { - this._items.push(...props.items); - } - - return this; - } - - run(name: string, props?: RunProps) { - const runner = this.runners[name]; - if (!runner) { - throw new Error(`Command not found: ${name}`); - } - - return runner(props || { params: {} }); - } -} diff --git a/pkg/typescript/http.ts b/pkg/typescript/http.ts deleted file mode 100644 index 78c7dbb4..00000000 --- a/pkg/typescript/http.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Extension } from "./extension.ts"; - -export function handler(extension: Extension) { - return async (req: Request) => { - if (req.method == "GET") { - return Response.json(extension); - } - - if (req.method != "POST") { - return Response.json({ "message": "Method not allowed" }, { - status: 405, - }); - } - - const url = new URL(req.url); - const [, command] = url.pathname.split("/"); - const input = await req.json(); - const output = await extension.run(command, input); - return Response.json(output); - }; -} - -export function handle(extension: Extension, req: Request) { - return handler(extension)(req); -} diff --git a/pkg/typescript/manifest.ts b/pkg/typescript/manifest.ts index e0fc6f60..302b27eb 100644 --- a/pkg/typescript/manifest.ts +++ b/pkg/typescript/manifest.ts @@ -7,42 +7,20 @@ export interface Manifest { title: string; - items: Item[]; homepage?: string; description?: string; - commands: { - [k: string]: Command; - }; -} -export interface Item { - command: string; - title?: string; - params?: { - /** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` ".+". - */ - [k: string]: string | boolean; - }; + commands: Command[]; } -/** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` ".+". - */ export interface Command { mode: "page" | "action" | "silent"; title: string; + name: string; params?: { - /** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` ".+". - */ - [k: string]: { - name?: string; - type: "string" | "boolean"; - optional?: boolean; - }; - }; + name?: string; + type: "string" | "boolean"; + description?: string; + optional?: boolean; + }[]; prefs?: { /** * This interface was referenced by `undefined`'s JSON-Schema definition diff --git a/pkg/typescript/mod.ts b/pkg/typescript/mod.ts index bb0c60c7..5e0203f2 100644 --- a/pkg/typescript/mod.ts +++ b/pkg/typescript/mod.ts @@ -1,14 +1,11 @@ export type { Command, Manifest } from "./manifest.ts"; -export type { Config } from "./config.ts"; +export type { Config, Rootitem } from "./config.ts"; export type { Action, Detail, Form, - Formitem, + Input, List, Listitem, Page, } from "./page.ts"; - -export { Extension } from "./extension.ts"; -export type { Runner } from "./extension.ts"; diff --git a/pkg/typescript/page.ts b/pkg/typescript/page.ts index 48a5e5b7..c64df41a 100644 --- a/pkg/typescript/page.ts +++ b/pkg/typescript/page.ts @@ -95,7 +95,7 @@ export type Command = [k: string]: string | boolean; }; }; -export type Formitem = +export type Input = | { /** * The title of the input. @@ -276,5 +276,5 @@ export interface Form { */ title?: string; command: Command; - items: Formitem[]; + inputs?: Input[]; }