mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
chore: Clean up unused code and call commands endpoint for commands
This commit is contained in:
parent
f70e06a283
commit
ae629b159a
5 changed files with 42 additions and 407 deletions
|
@ -110,8 +110,12 @@ func New(
|
|||
|
||||
slog.Debug("Loaded config", "config", configInfo)
|
||||
|
||||
// Create commands client - assuming server runs on same host with port 3000
|
||||
commandsClient := commands.NewCommandsClient("http://localhost:3000")
|
||||
// Create commands client using the same base URL as the HTTP client
|
||||
baseURL := os.Getenv("OPENCODE_SERVER")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:4096" // Default fallback
|
||||
}
|
||||
commandsClient := commands.NewCommandsClient(baseURL)
|
||||
|
||||
app := &App{
|
||||
Info: appInfo,
|
||||
|
|
|
@ -56,7 +56,7 @@ func NewCommandsClient(baseURL string) *CommandsClient {
|
|||
|
||||
// ListCustomCommands fetches all available custom commands from the server
|
||||
func (c *CommandsClient) ListCustomCommands(ctx context.Context) ([]CustomCommand, error) {
|
||||
url := fmt.Sprintf("%s/commands", c.baseURL)
|
||||
url := fmt.Sprintf("%scommands", c.baseURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
|
@ -83,7 +83,7 @@ func (c *CommandsClient) ListCustomCommands(ctx context.Context) ([]CustomComman
|
|||
|
||||
// GetCustomCommand fetches a specific custom command from the server
|
||||
func (c *CommandsClient) GetCustomCommand(ctx context.Context, name string) (*CustomCommand, error) {
|
||||
url := fmt.Sprintf("%s/commands/%s", c.baseURL, name)
|
||||
url := fmt.Sprintf("%scommands/%s", c.baseURL, name)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
|
@ -114,7 +114,7 @@ func (c *CommandsClient) GetCustomCommand(ctx context.Context, name string) (*Cu
|
|||
|
||||
// ExecuteCustomCommand executes a custom command on the server
|
||||
func (c *CommandsClient) ExecuteCustomCommand(ctx context.Context, name string, arguments *string) (*ExecuteCommandResponse, error) {
|
||||
url := fmt.Sprintf("%s/commands/%s/execute", c.baseURL, name)
|
||||
url := fmt.Sprintf("%scommands/%s/execute", c.baseURL, name)
|
||||
|
||||
reqBody := ExecuteCommandRequest{
|
||||
Arguments: arguments,
|
||||
|
|
|
@ -314,9 +314,9 @@ func IsValidCustomCommand(commandName string, configPath string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// IsValidCustomCommandWithClient checks if a custom command exists via server or filesystem
|
||||
// IsValidCustomCommandWithClient checks if a custom command exists via server
|
||||
func IsValidCustomCommandWithClient(commandName string, configPath string, client *CommandsClient) bool {
|
||||
// Try server first if client is available
|
||||
// Use server endpoint if client is available
|
||||
if client != nil {
|
||||
ctx := context.Background()
|
||||
exists, err := client.CustomCommandExists(ctx, commandName)
|
||||
|
@ -325,8 +325,8 @@ func IsValidCustomCommandWithClient(commandName string, configPath string, clien
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback to local filesystem check
|
||||
return IsValidCustomCommand(commandName, configPath)
|
||||
// Return false if server is not available or returns an error
|
||||
return false
|
||||
}
|
||||
|
||||
func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
package completions
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
|
@ -66,98 +65,29 @@ func getCustomCommandCompletionItem(cmd CustomCommandFile, space int, t theme.Th
|
|||
})
|
||||
}
|
||||
|
||||
// parseMarkdownMetadata extracts description from markdown frontmatter or first heading
|
||||
func parseMarkdownMetadata(filePath string) string {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
// Check for YAML frontmatter
|
||||
if scanner.Scan() {
|
||||
firstLine := strings.TrimSpace(scanner.Text())
|
||||
if firstLine == "---" {
|
||||
// Parse YAML frontmatter
|
||||
descriptionRegex := regexp.MustCompile(`(?i)^description:\s*(.+)$`)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "---" {
|
||||
break
|
||||
}
|
||||
if matches := descriptionRegex.FindStringSubmatch(line); len(matches) > 1 {
|
||||
return strings.Trim(matches[1], `"'`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if first line is a heading and use it as description
|
||||
if strings.HasPrefix(firstLine, "#") {
|
||||
return strings.TrimSpace(strings.TrimLeft(firstLine, "#"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no frontmatter, look for first heading
|
||||
file.Seek(0, 0)
|
||||
scanner = bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "#") {
|
||||
return strings.TrimSpace(strings.TrimLeft(line, "#"))
|
||||
}
|
||||
// Stop at first non-empty, non-comment line
|
||||
if line != "" && !strings.HasPrefix(line, "<!--") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) getCustomCommands() ([]CustomCommandFile, error) {
|
||||
// Try to get commands from server first
|
||||
// Get commands from server endpoint
|
||||
ctx := context.Background()
|
||||
serverCommands, err := c.app.CommandsClient.ListCustomCommands(ctx)
|
||||
if err == nil {
|
||||
// Convert server commands to local format
|
||||
var commands []CustomCommandFile
|
||||
for _, cmd := range serverCommands {
|
||||
description := ""
|
||||
if cmd.Description != nil {
|
||||
description = *cmd.Description
|
||||
}
|
||||
commands = append(commands, CustomCommandFile{
|
||||
Name: cmd.Name,
|
||||
Description: description,
|
||||
Filename: filepath.Base(cmd.FilePath),
|
||||
Content: cmd.Content,
|
||||
})
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// Fallback to local filesystem scanning if server is not available
|
||||
var commands []CustomCommandFile
|
||||
|
||||
// Get global commands from ~/.config/opencode/commands
|
||||
globalCommandsDir := filepath.Join(c.app.Info.Path.Config, "commands")
|
||||
globalCommands, err := c.scanCommandsDirectory(globalCommandsDir, "")
|
||||
if err != nil {
|
||||
// Log error but continue with project commands
|
||||
globalCommands = []CustomCommandFile{}
|
||||
return nil, fmt.Errorf("failed to get commands from server: %w", err)
|
||||
}
|
||||
commands = append(commands, globalCommands...)
|
||||
|
||||
// Get project-level commands from $PWD/.opencode/commands
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
projectCommandsDir := filepath.Join(cwd, ".opencode", "commands")
|
||||
projectCommands, err := c.scanCommandsDirectory(projectCommandsDir, "")
|
||||
if err == nil {
|
||||
commands = append(commands, projectCommands...)
|
||||
slog.Debug("Server commands:" + strconv.Itoa(len(serverCommands)))
|
||||
|
||||
// Convert server commands to local format
|
||||
var commands []CustomCommandFile
|
||||
for _, cmd := range serverCommands {
|
||||
description := ""
|
||||
if cmd.Description != nil {
|
||||
description = *cmd.Description
|
||||
}
|
||||
commands = append(commands, CustomCommandFile{
|
||||
Name: cmd.Name,
|
||||
Description: description,
|
||||
Filename: filepath.Base(cmd.FilePath),
|
||||
Content: cmd.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort commands alphabetically
|
||||
|
@ -168,57 +98,6 @@ func (c *CommandCompletionProvider) getCustomCommands() ([]CustomCommandFile, er
|
|||
return commands, nil
|
||||
}
|
||||
|
||||
// scanCommandsDirectory recursively scans a directory for markdown command files
|
||||
func (c *CommandCompletionProvider) scanCommandsDirectory(baseDir, relativePath string) ([]CustomCommandFile, error) {
|
||||
var commands []CustomCommandFile
|
||||
|
||||
currentDir := filepath.Join(baseDir, relativePath)
|
||||
|
||||
err := filepath.WalkDir(currentDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// If the commands directory doesn't exist, just return empty list
|
||||
if strings.Contains(err.Error(), "no such file or directory") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(d.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate relative path from base commands directory
|
||||
relPath, err := filepath.Rel(baseDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert file path to command name with colon notation
|
||||
// e.g., "foo/bar.md" -> "foo:bar"
|
||||
name := strings.TrimSuffix(relPath, ".md")
|
||||
name = strings.ReplaceAll(name, string(filepath.Separator), ":")
|
||||
|
||||
description := parseMarkdownMetadata(path)
|
||||
commands = append(commands, CustomCommandFile{
|
||||
Name: name,
|
||||
Filename: d.Name(),
|
||||
Content: "", // We don't need content for completion
|
||||
Description: description,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||
t := theme.CurrentTheme()
|
||||
commands := c.app.Commands
|
||||
|
@ -226,7 +105,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
|||
// Get custom commands
|
||||
customCommands, err := c.getCustomCommands()
|
||||
if err != nil {
|
||||
// Log error but continue with built-in commands
|
||||
// If server is not available, return only built-in commands
|
||||
customCommands = []CustomCommandFile{}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -36,14 +32,6 @@ import (
|
|||
// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
|
||||
type InterruptDebounceTimeoutMsg struct{}
|
||||
|
||||
// CustomCommandFile represents a custom command file
|
||||
type CustomCommandFile struct {
|
||||
Name string `json:"name"`
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// InterruptKeyState tracks the state of interrupt key presses for debouncing
|
||||
type InterruptKeyState int
|
||||
|
||||
|
@ -1018,16 +1006,15 @@ func (a appModel) parseCustomCommand(input string) (commandName, arguments strin
|
|||
arguments = parts[1]
|
||||
}
|
||||
|
||||
// Check if this command exists as a custom command
|
||||
customCommands, err := a.getAvailableCustomCommands()
|
||||
// Check if this command exists as a custom command via server
|
||||
ctx := context.Background()
|
||||
exists, err := a.app.CommandsClient.CustomCommandExists(ctx, commandName)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
for _, cmd := range customCommands {
|
||||
if cmd.Name == commandName {
|
||||
return commandName, arguments, true
|
||||
}
|
||||
if exists {
|
||||
return commandName, arguments, true
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
|
@ -1037,7 +1024,7 @@ func (a appModel) parseCustomCommand(input string) (commandName, arguments strin
|
|||
func (a appModel) executeCustomCommandWithArgs(commandName, arguments string) (tea.Model, tea.Cmd) {
|
||||
slog.Debug("Executing custom command with arguments", "command", commandName, "arguments", arguments)
|
||||
|
||||
// Try to execute command via server first
|
||||
// Execute command via server endpoint
|
||||
ctx := context.Background()
|
||||
var args *string
|
||||
if arguments != "" {
|
||||
|
@ -1053,244 +1040,9 @@ func (a appModel) executeCustomCommandWithArgs(commandName, arguments string) (t
|
|||
return a, cmd
|
||||
}
|
||||
|
||||
// Fallback to local execution if server is not available
|
||||
slog.Warn("Server execution failed, falling back to local execution", "command", commandName, "error", err)
|
||||
|
||||
// Convert colon notation back to file path
|
||||
filePath := strings.ReplaceAll(commandName, ":", string(filepath.Separator)) + ".md"
|
||||
|
||||
var commandFile string
|
||||
var content []byte
|
||||
var localErr error
|
||||
|
||||
// Try project-level commands first ($PWD/.opencode/commands)
|
||||
if cwd, cwdErr := os.Getwd(); cwdErr == nil {
|
||||
projectCommandsDir := filepath.Join(cwd, ".opencode", "commands")
|
||||
projectCommandFile := filepath.Join(projectCommandsDir, filePath)
|
||||
content, localErr = os.ReadFile(projectCommandFile)
|
||||
if localErr == nil {
|
||||
commandFile = projectCommandFile
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in project, try global commands (~/.config/opencode/commands)
|
||||
if localErr != nil {
|
||||
globalCommandsDir := filepath.Join(a.app.Info.Path.Config, "commands")
|
||||
globalCommandFile := filepath.Join(globalCommandsDir, filePath)
|
||||
content, localErr = os.ReadFile(globalCommandFile)
|
||||
if localErr == nil {
|
||||
commandFile = globalCommandFile
|
||||
}
|
||||
}
|
||||
|
||||
if localErr != nil {
|
||||
slog.Error("Failed to read custom command file", "command", commandName, "error", localErr)
|
||||
return a, toast.NewErrorToast("Failed to read custom command: " + commandName)
|
||||
}
|
||||
|
||||
slog.Debug("Executing custom command locally", "command", commandName, "file", commandFile, "arguments", arguments)
|
||||
|
||||
// Replace $ARGUMENTS placeholder with actual arguments
|
||||
contentStr := string(content)
|
||||
contentStr = strings.ReplaceAll(contentStr, "$ARGUMENTS", arguments)
|
||||
|
||||
// Process bash commands if any exist
|
||||
processedContent, localErr := a.processBashCommands(contentStr)
|
||||
if localErr != nil {
|
||||
slog.Error("Failed to process bash commands", "error", localErr)
|
||||
return a, toast.NewErrorToast("Failed to process bash commands: " + localErr.Error())
|
||||
}
|
||||
|
||||
// Send the processed command content as a message to the LLM
|
||||
a.app, cmd = a.app.SendChatMessage(context.Background(), processedContent, []opencode.FilePartParam{})
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
// processBashCommands processes bash commands prefixed with ! in the content
|
||||
func (a appModel) processBashCommands(content string) (string, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
var commands []string
|
||||
var cleanLines []string
|
||||
|
||||
// Parse bash commands and clean content
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "!") {
|
||||
command := strings.TrimSpace(trimmed[1:])
|
||||
if command != "" {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
} else {
|
||||
cleanLines = append(cleanLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
// If no bash commands found, return original content
|
||||
if len(commands) == 0 {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
slog.Info("Processing bash commands", "count", len(commands))
|
||||
|
||||
// Execute bash commands and collect results
|
||||
var contextSection strings.Builder
|
||||
contextSection.WriteString("\n\n## Command Context\n\n")
|
||||
contextSection.WriteString("The following bash commands were executed to gather context:\n\n")
|
||||
|
||||
for _, command := range commands {
|
||||
result, err := a.executeBashCommand(command)
|
||||
if err != nil {
|
||||
slog.Error("Failed to execute bash command", "command", command, "error", err)
|
||||
contextSection.WriteString(fmt.Sprintf("### Command: `%s`\n\n", command))
|
||||
contextSection.WriteString(fmt.Sprintf("*Command failed: %s*\n\n", err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
contextSection.WriteString(fmt.Sprintf("### Command: `%s`\n\n", command))
|
||||
if result.ExitCode == 0 {
|
||||
if strings.TrimSpace(result.Stdout) != "" {
|
||||
contextSection.WriteString("```\n")
|
||||
contextSection.WriteString(strings.TrimSpace(result.Stdout))
|
||||
contextSection.WriteString("\n```\n\n")
|
||||
} else {
|
||||
contextSection.WriteString("*No output*\n\n")
|
||||
}
|
||||
} else {
|
||||
contextSection.WriteString(fmt.Sprintf("*Command failed with exit code %d*\n\n", result.ExitCode))
|
||||
if strings.TrimSpace(result.Stderr) != "" {
|
||||
contextSection.WriteString("```\n")
|
||||
contextSection.WriteString(strings.TrimSpace(result.Stderr))
|
||||
contextSection.WriteString("\n```\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine clean content with command context
|
||||
cleanContent := strings.Join(cleanLines, "\n")
|
||||
return cleanContent + contextSection.String(), nil
|
||||
}
|
||||
|
||||
// BashCommandResult represents the result of executing a bash command
|
||||
type BashCommandResult struct {
|
||||
Command string
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
// executeBashCommand executes a single bash command and returns the result
|
||||
func (a appModel) executeBashCommand(command string) (*BashCommandResult, error) {
|
||||
// List of banned commands for security
|
||||
bannedCommands := []string{
|
||||
"axel", "aria2c", "nc", "telnet", "lynx", "w3m", "links", "xh", "chrome", "firefox", "safari",
|
||||
}
|
||||
|
||||
// Check if command is banned
|
||||
for _, banned := range bannedCommands {
|
||||
if strings.HasPrefix(command, banned) {
|
||||
return nil, fmt.Errorf("command '%s' is not allowed", command)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", command)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitError.ExitCode()
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to execute command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &BashCommandResult{
|
||||
Command: command,
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
ExitCode: exitCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getAvailableCustomCommands returns a list of available custom commands
|
||||
func (a appModel) getAvailableCustomCommands() ([]CustomCommandFile, error) {
|
||||
var commands []CustomCommandFile
|
||||
|
||||
// Get global commands from ~/.config/opencode/commands
|
||||
globalCommandsDir := filepath.Join(a.app.Info.Path.Config, "commands")
|
||||
globalCommands, err := a.scanCommandsDirectory(globalCommandsDir, "")
|
||||
if err != nil {
|
||||
globalCommands = []CustomCommandFile{}
|
||||
}
|
||||
commands = append(commands, globalCommands...)
|
||||
|
||||
// Get project-level commands from $PWD/.opencode/commands
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
projectCommandsDir := filepath.Join(cwd, ".opencode", "commands")
|
||||
projectCommands, err := a.scanCommandsDirectory(projectCommandsDir, "")
|
||||
if err == nil {
|
||||
commands = append(commands, projectCommands...)
|
||||
}
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// scanCommandsDirectory recursively scans a directory for markdown command files
|
||||
func (a appModel) scanCommandsDirectory(baseDir, relativePath string) ([]CustomCommandFile, error) {
|
||||
var commands []CustomCommandFile
|
||||
|
||||
currentDir := filepath.Join(baseDir, relativePath)
|
||||
|
||||
err := filepath.WalkDir(currentDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no such file or directory") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(d.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate relative path from base commands directory
|
||||
relPath, err := filepath.Rel(baseDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert file path to command name with colon notation
|
||||
name := strings.TrimSuffix(relPath, ".md")
|
||||
name = strings.ReplaceAll(name, string(filepath.Separator), ":")
|
||||
|
||||
commands = append(commands, CustomCommandFile{
|
||||
Name: name,
|
||||
Filename: d.Name(),
|
||||
Content: "",
|
||||
Description: "",
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
// Server execution failed
|
||||
slog.Error("Failed to execute custom command via server", "command", commandName, "error", err)
|
||||
return a, toast.NewErrorToast("Failed to execute custom command: " + commandName)
|
||||
}
|
||||
|
||||
func NewModel(app *app.App) tea.Model {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue