feat: remove TUI from README and codebase
This commit is contained in:
@@ -1,9 +1,8 @@
|
|||||||
PREFIX ?= $(HOME)/.local
|
PREFIX ?= $(HOME)/.local
|
||||||
PROFILE_DIR = $(HOME)/.config/nono/profiles
|
PROFILE_DIR = $(HOME)/.config/nono/profiles
|
||||||
BIN_DIR = $(PREFIX)/bin
|
BIN_DIR = $(PREFIX)/bin
|
||||||
TUI_DIR = tui
|
|
||||||
|
|
||||||
.PHONY: install uninstall validate test tui tui-build tui-install
|
.PHONY: install uninstall validate test
|
||||||
|
|
||||||
install:
|
install:
|
||||||
@./scripts/install.sh
|
@./scripts/install.sh
|
||||||
@@ -18,12 +17,3 @@ validate:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
$(BIN_DIR)/opencode --version
|
$(BIN_DIR)/opencode --version
|
||||||
|
|
||||||
tui:
|
|
||||||
cd $(TUI_DIR) && go run .
|
|
||||||
|
|
||||||
tui-build:
|
|
||||||
cd $(TUI_DIR) && go build -buildvcs=false -o $(BIN_DIR)/nono-tui .
|
|
||||||
|
|
||||||
tui-install: tui-build
|
|
||||||
@echo "Installed nono-tui to $(BIN_DIR)/nono-tui"
|
|
||||||
@@ -105,100 +105,9 @@ nono run --profile opencode-tinfoil --allow-cwd -- /home/linuxbrew/.linuxbrew/bi
|
|||||||
nono run --profile opencode-tinfoil --allow-cwd -- $(npm root -g)/opencode/bin/opencode
|
nono run --profile opencode-tinfoil --allow-cwd -- $(npm root -g)/opencode/bin/opencode
|
||||||
```
|
```
|
||||||
|
|
||||||
## TUI
|
|
||||||
|
|
||||||
A terminal UI for managing the sandbox profile and checking environment status.
|
|
||||||
|
|
||||||
### Run from the repo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make tui
|
|
||||||
```
|
|
||||||
|
|
||||||
### Install for use from anywhere
|
|
||||||
|
|
||||||
Build and install the binary to `~/.local/bin/nono-tui`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make tui-install
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run from any directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nono-tui
|
|
||||||
```
|
|
||||||
|
|
||||||
Make sure `~/.local/bin` is on your `PATH`. If it isn't, add this to your shell config file (e.g., `~/.bashrc` or `~/.zshrc`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
```
|
|
||||||
|
|
||||||
The TUI reads and edits the installed profile at
|
|
||||||
`~/.config/nono/profiles/opencode-tinfoil.json`, so it works regardless of
|
|
||||||
your current directory.
|
|
||||||
|
|
||||||
### What it does
|
|
||||||
|
|
||||||
The TUI has two tabs:
|
|
||||||
|
|
||||||
**Dashboard** — Shows live status of your sandbox setup at a glance:
|
|
||||||
|
|
||||||
```
|
|
||||||
✓ nono installed /home/linuxbrew/.linuxbrew/bin/nono nono 0.x.x
|
|
||||||
✓ profile installed ~/.config/nono/profiles/opencode-tinfoil.json
|
|
||||||
✓ wrapper installed ~/.local/bin/opencode
|
|
||||||
✗ wrapper first on PATH resolves to /opt/homebrew/bin/opencode
|
|
||||||
✓ opencode binary /opt/homebrew/bin/opencode
|
|
||||||
✓ tinfoil credentials ~/.tinfoil
|
|
||||||
|
|
||||||
> Launch opencode in sandbox
|
|
||||||
Validate profile
|
|
||||||
Refresh checks
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `j/k` to navigate the action list, `l` to launch opencode in the sandbox,
|
|
||||||
`v` to validate the profile, and `r` to re-run all checks.
|
|
||||||
|
|
||||||
**Editor** — Edit the installed profile
|
|
||||||
(`~/.config/nono/profiles/opencode-tinfoil.json`) with four sub-tabs:
|
|
||||||
|
|
||||||
| Sub-tab | Key | What you can edit |
|
|
||||||
|---------|-----|-------------------|
|
|
||||||
| Commands | `1` | Allowed binaries (git, node, npm, ...) and blocked binaries (sudo, su, mkfs, ...) |
|
|
||||||
| Filesystem | `2` | Allowed, read-only, write, and denied paths |
|
|
||||||
| Network | `3` | Allowed domains (`*.tinfoil.sh`, `api.openai.com`, ...) and network block toggle |
|
|
||||||
| Environment | `4` | Set environment variables and denied env vars |
|
|
||||||
|
|
||||||
Changes are applied to the live installed profile — they take effect on the
|
|
||||||
next `opencode` launch. Press `s` to save (which also runs `nono profile
|
|
||||||
validate`). Saved files are written as clean JSON (comments are stripped).
|
|
||||||
|
|
||||||
### Keybindings
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| `Tab` | Switch between Dashboard and Editor |
|
|
||||||
| `1`-`4` | Switch editor sub-tab |
|
|
||||||
| `j/k`, `↑/↓` | Move cursor |
|
|
||||||
| `←/→` | Switch between lists within a sub-tab |
|
|
||||||
| `a` | Add entry |
|
|
||||||
| `x` | Remove selected entry |
|
|
||||||
| `enter` | Edit selected entry inline |
|
|
||||||
| `b` | Toggle network block (Network tab only) |
|
|
||||||
| `s` | Save profile + validate |
|
|
||||||
| `l` | Launch opencode in sandbox (Dashboard only) |
|
|
||||||
| `v` | Validate profile (Dashboard only) |
|
|
||||||
| `r` | Refresh checks (Dashboard only) |
|
|
||||||
| `q` | Quit |
|
|
||||||
| `Q` | Force quit (discard unsaved changes) |
|
|
||||||
|
|
||||||
Unsaved changes are marked with `*` in the title. Pressing `q` with unsaved
|
|
||||||
changes warns you; press `Q` to force quit.
|
|
||||||
|
|
||||||
## Tinfoil integration
|
## Tinfoil integration
|
||||||
|
|
||||||
|
|
||||||
The profile allows network access to:
|
The profile allows network access to:
|
||||||
|
|
||||||
- `*.tinfoil.sh`
|
- `*.tinfoil.sh`
|
||||||
@@ -230,14 +139,9 @@ and grants read/write access to `~/.tinfoil` for Tinfoil credentials.
|
|||||||
make uninstall
|
make uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
To also remove the TUI binary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm -f ~/.local/bin/nono-tui
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
||||||
GPL-3.0
|
GPL-3.0
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
-105
@@ -1,105 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CheckStatus int
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusUnknown CheckStatus = iota
|
|
||||||
StatusOK
|
|
||||||
StatusFail
|
|
||||||
)
|
|
||||||
|
|
||||||
type CheckResult struct {
|
|
||||||
Label string
|
|
||||||
Status CheckStatus
|
|
||||||
Detail string
|
|
||||||
}
|
|
||||||
|
|
||||||
func RunChecks() []CheckResult {
|
|
||||||
var results []CheckResult
|
|
||||||
|
|
||||||
if path, err := exec.LookPath("nono"); err == nil {
|
|
||||||
version := getNonoVersion()
|
|
||||||
detail := path
|
|
||||||
if version != "" {
|
|
||||||
detail += " " + version
|
|
||||||
}
|
|
||||||
results = append(results, CheckResult{"nono installed", StatusOK, detail})
|
|
||||||
} else {
|
|
||||||
results = append(results, CheckResult{"nono installed", StatusFail, "not found in PATH"})
|
|
||||||
}
|
|
||||||
|
|
||||||
profilePath, _ := DefaultProfilePath()
|
|
||||||
if profilePath != "" {
|
|
||||||
if info, err := os.Stat(profilePath); err == nil {
|
|
||||||
results = append(results, CheckResult{"profile installed", StatusOK,
|
|
||||||
profilePath + " (" + info.ModTime().Format("2006-01-02") + ")"})
|
|
||||||
} else {
|
|
||||||
results = append(results, CheckResult{"profile installed", StatusFail, "not found"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
wrapperPath := filepath.Join(home, ".local", "bin", "opencode")
|
|
||||||
if _, err := os.Stat(wrapperPath); err == nil {
|
|
||||||
results = append(results, CheckResult{"wrapper installed", StatusOK, wrapperPath})
|
|
||||||
} else {
|
|
||||||
results = append(results, CheckResult{"wrapper installed", StatusFail, "not found"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if path, err := exec.LookPath("opencode"); err == nil {
|
|
||||||
if path == wrapperPath {
|
|
||||||
results = append(results, CheckResult{"wrapper first on PATH", StatusOK, path})
|
|
||||||
} else {
|
|
||||||
results = append(results, CheckResult{"wrapper first on PATH", StatusFail, "resolves to " + path})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
results = append(results, CheckResult{"wrapper first on PATH", StatusFail, "opencode not in PATH"})
|
|
||||||
}
|
|
||||||
|
|
||||||
opencodePath := findRealOpencode(wrapperPath)
|
|
||||||
if opencodePath != "" {
|
|
||||||
results = append(results, CheckResult{"opencode binary", StatusOK, opencodePath})
|
|
||||||
} else {
|
|
||||||
results = append(results, CheckResult{"opencode binary", StatusFail, "real opencode not found"})
|
|
||||||
}
|
|
||||||
|
|
||||||
tinfoilDir := filepath.Join(home, ".tinfoil")
|
|
||||||
if info, err := os.Stat(tinfoilDir); err == nil && info.IsDir() {
|
|
||||||
results = append(results, CheckResult{"tinfoil credentials", StatusOK, tinfoilDir})
|
|
||||||
} else {
|
|
||||||
results = append(results, CheckResult{"tinfoil credentials", StatusFail, "no ~/.tinfoil directory"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
func getNonoVersion() string {
|
|
||||||
cmd := exec.Command("nono", "--version")
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func findRealOpencode(wrapperPath string) string {
|
|
||||||
path := os.Getenv("PATH")
|
|
||||||
for _, dir := range filepath.SplitList(path) {
|
|
||||||
for _, name := range []string{"opencode", "opencode.exe"} {
|
|
||||||
candidate := filepath.Join(dir, name)
|
|
||||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
|
||||||
if candidate != wrapperPath {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DashboardModel struct {
|
|
||||||
checks []CheckResult
|
|
||||||
cursor int
|
|
||||||
maxCursor int
|
|
||||||
output string
|
|
||||||
width int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDashboardModel(checks []CheckResult) DashboardModel {
|
|
||||||
return DashboardModel{
|
|
||||||
checks: checks,
|
|
||||||
maxCursor: 2,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type checksDoneMsg struct {
|
|
||||||
results []CheckResult
|
|
||||||
}
|
|
||||||
|
|
||||||
type launchResultMsg struct {
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type validateResultMsg struct {
|
|
||||||
output string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func runChecksCmd() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return checksDoneMsg{results: RunChecks()}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func launchOpencodeCmd() tea.Cmd {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
wrapper := filepath.Join(home, ".local", "bin", "opencode")
|
|
||||||
cmd := exec.Command(wrapper)
|
|
||||||
return tea.ExecProcess(cmd, func(err error) tea.Msg {
|
|
||||||
return launchResultMsg{err: err}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateProfileCmd() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
cmd := exec.Command("nono", "profile", "validate", "opencode-tinfoil")
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
return validateResultMsg{output: strings.TrimSpace(string(out)), err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case checksDoneMsg:
|
|
||||||
m.checks = msg.results
|
|
||||||
return m, nil
|
|
||||||
case launchResultMsg:
|
|
||||||
if msg.err != nil {
|
|
||||||
m.output = fmt.Sprintf("Launch failed: %v", msg.err)
|
|
||||||
} else {
|
|
||||||
m.output = "opencode exited normally"
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case validateResultMsg:
|
|
||||||
if msg.err != nil {
|
|
||||||
m.output = "Validation: " + msg.output
|
|
||||||
} else {
|
|
||||||
m.output = "Validation OK: " + msg.output
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "up", "k":
|
|
||||||
if m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
}
|
|
||||||
case "down", "j":
|
|
||||||
if m.cursor < m.maxCursor {
|
|
||||||
m.cursor++
|
|
||||||
}
|
|
||||||
case "l":
|
|
||||||
if m.cursor == 0 {
|
|
||||||
return m, launchOpencodeCmd()
|
|
||||||
}
|
|
||||||
case "v":
|
|
||||||
if m.cursor == 1 {
|
|
||||||
return m, validateProfileCmd()
|
|
||||||
}
|
|
||||||
case "r":
|
|
||||||
m.output = "Refreshing..."
|
|
||||||
return m, runChecksCmd()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m DashboardModel) View() string {
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
b.WriteString("Dashboard\n\n")
|
|
||||||
|
|
||||||
for _, check := range m.checks {
|
|
||||||
var icon string
|
|
||||||
style := checkOKStyle
|
|
||||||
if check.Status == StatusOK {
|
|
||||||
icon = "✓"
|
|
||||||
} else {
|
|
||||||
icon = "✗"
|
|
||||||
style = checkFailStyle
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf(" %s %s %s\n",
|
|
||||||
style.Render(icon),
|
|
||||||
labelStyle.Render(check.Label),
|
|
||||||
detailStyle.Render(check.Detail),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
actions := []string{"Launch opencode in sandbox", "Validate profile", "Refresh checks"}
|
|
||||||
for i, action := range actions {
|
|
||||||
cursor := " "
|
|
||||||
if i == m.cursor {
|
|
||||||
cursor = cursorStyle.Render("> ")
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf("%s%s\n", cursor, action))
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.output != "" {
|
|
||||||
b.WriteString("\n" + detailStyle.Render(m.output))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n\n" + helpStyle.Render(" l:launch v:validate r:refresh Tab:editor"))
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
-224
@@ -1,224 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
type editorTab int
|
|
||||||
|
|
||||||
const (
|
|
||||||
tabCommands editorTab = iota
|
|
||||||
tabFilesystem
|
|
||||||
tabNetwork
|
|
||||||
tabEnv
|
|
||||||
)
|
|
||||||
|
|
||||||
var editorTabNames = []string{"Commands", "Filesystem", "Network", "Environment"}
|
|
||||||
|
|
||||||
type EditorModel struct {
|
|
||||||
subTab editorTab
|
|
||||||
cmdLists [2]EditableList
|
|
||||||
fsLists [4]EditableList
|
|
||||||
netList EditableList
|
|
||||||
envLists [2]EditableList
|
|
||||||
netBlock bool
|
|
||||||
listIdx int
|
|
||||||
width int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEditorModel(p *Profile) EditorModel {
|
|
||||||
m := EditorModel{subTab: tabCommands}
|
|
||||||
if p != nil {
|
|
||||||
m.cmdLists[0] = NewEditableList("Allow", p.Commands.Allow)
|
|
||||||
m.cmdLists[1] = NewEditableList("Deny", p.Commands.Deny)
|
|
||||||
m.fsLists[0] = NewEditableList("Allow", p.FS.Allow)
|
|
||||||
m.fsLists[1] = NewEditableList("Read", p.FS.Read)
|
|
||||||
m.fsLists[2] = NewEditableList("Write", p.FS.Write)
|
|
||||||
m.fsLists[3] = NewEditableList("Deny", p.FS.Deny)
|
|
||||||
m.netList = NewEditableList("Allow Domains", p.Network.AllowDomain)
|
|
||||||
m.envLists[0] = NewEditableList("Set Vars (key=value)", keyValueStrings(p.Env.SetVars))
|
|
||||||
m.envLists[1] = NewEditableList("Deny Vars", p.Env.DenyVars)
|
|
||||||
m.netBlock = p.Network.Block
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m EditorModel) listCount() int {
|
|
||||||
switch m.subTab {
|
|
||||||
case tabCommands:
|
|
||||||
return 2
|
|
||||||
case tabFilesystem:
|
|
||||||
return 4
|
|
||||||
case tabNetwork:
|
|
||||||
return 1
|
|
||||||
case tabEnv:
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m EditorModel) currentList() EditableList {
|
|
||||||
switch m.subTab {
|
|
||||||
case tabCommands:
|
|
||||||
return m.cmdLists[m.listIdx]
|
|
||||||
case tabFilesystem:
|
|
||||||
return m.fsLists[m.listIdx]
|
|
||||||
case tabNetwork:
|
|
||||||
return m.netList
|
|
||||||
case tabEnv:
|
|
||||||
return m.envLists[m.listIdx]
|
|
||||||
}
|
|
||||||
return EditableList{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m EditorModel) withList(idx int, l EditableList) EditorModel {
|
|
||||||
switch m.subTab {
|
|
||||||
case tabCommands:
|
|
||||||
m.cmdLists[idx] = l
|
|
||||||
case tabFilesystem:
|
|
||||||
m.fsLists[idx] = l
|
|
||||||
case tabNetwork:
|
|
||||||
m.netList = l
|
|
||||||
case tabEnv:
|
|
||||||
m.envLists[idx] = l
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m EditorModel) listTitles() []string {
|
|
||||||
switch m.subTab {
|
|
||||||
case tabCommands:
|
|
||||||
return []string{"Allow", "Deny"}
|
|
||||||
case tabFilesystem:
|
|
||||||
return []string{"Allow", "Read", "Write", "Deny"}
|
|
||||||
case tabNetwork:
|
|
||||||
return []string{"Allow Domains"}
|
|
||||||
case tabEnv:
|
|
||||||
return []string{"Set Vars", "Deny Vars"}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m EditorModel) Update(msg tea.Msg) (EditorModel, tea.Cmd) {
|
|
||||||
current := m.currentList()
|
|
||||||
if current.IsEditing() {
|
|
||||||
updated, cmd := current.Update(msg)
|
|
||||||
m = m.withList(m.listIdx, updated)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "1":
|
|
||||||
m.subTab = tabCommands
|
|
||||||
m.listIdx = 0
|
|
||||||
return m, nil
|
|
||||||
case "2":
|
|
||||||
m.subTab = tabFilesystem
|
|
||||||
m.listIdx = 0
|
|
||||||
return m, nil
|
|
||||||
case "3":
|
|
||||||
m.subTab = tabNetwork
|
|
||||||
m.listIdx = 0
|
|
||||||
return m, nil
|
|
||||||
case "4":
|
|
||||||
m.subTab = tabEnv
|
|
||||||
m.listIdx = 0
|
|
||||||
return m, nil
|
|
||||||
case "left":
|
|
||||||
if m.listIdx > 0 {
|
|
||||||
m.listIdx--
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case "right":
|
|
||||||
if m.listIdx < m.listCount()-1 {
|
|
||||||
m.listIdx++
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case "b":
|
|
||||||
if m.subTab == tabNetwork {
|
|
||||||
m.netBlock = !m.netBlock
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
updated, cmd := current.Update(msg)
|
|
||||||
m = m.withList(m.listIdx, updated)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m EditorModel) View() string {
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
b.WriteString(" ")
|
|
||||||
for i, name := range editorTabNames {
|
|
||||||
num := fmt.Sprintf("%d", i+1)
|
|
||||||
if editorTab(i) == m.subTab {
|
|
||||||
b.WriteString(activeTabStyle.Render(num + " " + name))
|
|
||||||
} else {
|
|
||||||
b.WriteString(tabStyle.Render(num + " " + name))
|
|
||||||
}
|
|
||||||
b.WriteString(" ")
|
|
||||||
}
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
current := m.currentList()
|
|
||||||
titles := m.listTitles()
|
|
||||||
if len(titles) > 1 {
|
|
||||||
list := current
|
|
||||||
list.title = fmt.Sprintf("%s (%d/%d)", titles[m.listIdx], m.listIdx+1, m.listCount())
|
|
||||||
b.WriteString(list.View())
|
|
||||||
} else {
|
|
||||||
b.WriteString(current.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.subTab == tabNetwork {
|
|
||||||
blockStr := "no"
|
|
||||||
if m.netBlock {
|
|
||||||
blockStr = "yes"
|
|
||||||
}
|
|
||||||
b.WriteString("\n\n Block: " + blockStr + " (b to toggle)")
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n\n" + helpStyle.Render(" 1-4:sub-tab <-/->:switch list a:add x:remove enter:edit s:save Tab:dashboard"))
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m EditorModel) SyncToProfile(p *Profile) {
|
|
||||||
p.Commands.Allow = m.cmdLists[0].Items()
|
|
||||||
p.Commands.Deny = m.cmdLists[1].Items()
|
|
||||||
p.FS.Allow = m.fsLists[0].Items()
|
|
||||||
p.FS.Read = m.fsLists[1].Items()
|
|
||||||
p.FS.Write = m.fsLists[2].Items()
|
|
||||||
p.FS.Deny = m.fsLists[3].Items()
|
|
||||||
p.Network.AllowDomain = m.netList.Items()
|
|
||||||
p.Network.Block = m.netBlock
|
|
||||||
p.Env.SetVars = parseKeyValueStrings(m.envLists[0].Items())
|
|
||||||
p.Env.DenyVars = m.envLists[1].Items()
|
|
||||||
}
|
|
||||||
|
|
||||||
func keyValueStrings(m map[string]string) []string {
|
|
||||||
var result []string
|
|
||||||
for k, v := range m {
|
|
||||||
result = append(result, k+"="+v)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseKeyValueStrings(items []string) map[string]string {
|
|
||||||
result := make(map[string]string)
|
|
||||||
for _, item := range items {
|
|
||||||
parts := strings.SplitN(item, "=", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
result[parts[0]] = parts[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
module nono-opencode-tinfoil/tui
|
|
||||||
|
|
||||||
go 1.18
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/charmbracelet/bubbles v0.16.1
|
|
||||||
github.com/charmbracelet/bubbletea v0.24.2
|
|
||||||
github.com/charmbracelet/lipgloss v0.7.1
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.18 // indirect
|
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
|
||||||
github.com/muesli/reflow v0.3.0 // indirect
|
|
||||||
github.com/muesli/termenv v0.15.1 // indirect
|
|
||||||
github.com/rivo/uniseg v0.2.0 // indirect
|
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
|
||||||
golang.org/x/sys v0.6.0 // indirect
|
|
||||||
golang.org/x/term v0.6.0 // indirect
|
|
||||||
golang.org/x/text v0.3.8 // indirect
|
|
||||||
)
|
|
||||||
-41
@@ -1,41 +0,0 @@
|
|||||||
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=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
|
||||||
github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY=
|
|
||||||
github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
|
|
||||||
github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
|
|
||||||
github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
|
|
||||||
github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
|
|
||||||
github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
|
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
|
||||||
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-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
|
|
||||||
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
|
||||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
|
||||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
|
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
|
||||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
|
||||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
|
||||||
github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
|
|
||||||
github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
|
|
||||||
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/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
|
|
||||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
|
||||||
-143
@@ -1,143 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
type listMode int
|
|
||||||
|
|
||||||
const (
|
|
||||||
listBrowse listMode = iota
|
|
||||||
listAdding
|
|
||||||
listEditing
|
|
||||||
)
|
|
||||||
|
|
||||||
type EditableList struct {
|
|
||||||
title string
|
|
||||||
items []string
|
|
||||||
cursor int
|
|
||||||
mode listMode
|
|
||||||
input textinput.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEditableList(title string, items []string) EditableList {
|
|
||||||
ti := textinput.New()
|
|
||||||
ti.Placeholder = "enter value..."
|
|
||||||
ti.CharLimit = 200
|
|
||||||
return EditableList{
|
|
||||||
title: title,
|
|
||||||
items: items,
|
|
||||||
mode: listBrowse,
|
|
||||||
input: ti,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l EditableList) Items() []string {
|
|
||||||
return l.items
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l EditableList) IsEditing() bool {
|
|
||||||
return l.mode != listBrowse
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l EditableList) Update(msg tea.Msg) (EditableList, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch l.mode {
|
|
||||||
case listBrowse:
|
|
||||||
switch msg.String() {
|
|
||||||
case "up", "k":
|
|
||||||
if l.cursor > 0 {
|
|
||||||
l.cursor--
|
|
||||||
}
|
|
||||||
case "down", "j":
|
|
||||||
if l.cursor < len(l.items)-1 {
|
|
||||||
l.cursor++
|
|
||||||
}
|
|
||||||
case "a":
|
|
||||||
l.mode = listAdding
|
|
||||||
l.input.SetValue("")
|
|
||||||
l.input.Focus()
|
|
||||||
return l, textinput.Blink
|
|
||||||
case "x", "delete":
|
|
||||||
if len(l.items) > 0 && l.cursor < len(l.items) {
|
|
||||||
l.items = append(l.items[:l.cursor], l.items[l.cursor+1:]...)
|
|
||||||
if l.cursor > len(l.items)-1 {
|
|
||||||
l.cursor = len(l.items) - 1
|
|
||||||
}
|
|
||||||
if l.cursor < 0 {
|
|
||||||
l.cursor = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
if len(l.items) > 0 {
|
|
||||||
l.mode = listEditing
|
|
||||||
l.input.SetValue(l.items[l.cursor])
|
|
||||||
l.input.Focus()
|
|
||||||
return l, textinput.Blink
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case listAdding, listEditing:
|
|
||||||
switch msg.String() {
|
|
||||||
case "enter":
|
|
||||||
val := strings.TrimSpace(l.input.Value())
|
|
||||||
if val != "" {
|
|
||||||
if l.mode == listAdding {
|
|
||||||
l.items = append(l.items, val)
|
|
||||||
l.cursor = len(l.items) - 1
|
|
||||||
} else {
|
|
||||||
l.items[l.cursor] = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
l.mode = listBrowse
|
|
||||||
l.input.Blur()
|
|
||||||
case "esc":
|
|
||||||
l.mode = listBrowse
|
|
||||||
l.input.Blur()
|
|
||||||
default:
|
|
||||||
var cmd tea.Cmd
|
|
||||||
l.input, cmd = l.input.Update(msg)
|
|
||||||
return l, cmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return l, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l EditableList) View() string {
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
switch l.mode {
|
|
||||||
case listAdding:
|
|
||||||
b.WriteString(fmt.Sprintf("%s (adding)\n", l.title))
|
|
||||||
b.WriteString(fmt.Sprintf("> %s\n", l.input.View()))
|
|
||||||
b.WriteString(helpStyle.Render(" enter to confirm · esc to cancel"))
|
|
||||||
case listEditing:
|
|
||||||
b.WriteString(fmt.Sprintf("%s (editing)\n", l.title))
|
|
||||||
b.WriteString(fmt.Sprintf("> %s\n", l.input.View()))
|
|
||||||
b.WriteString(helpStyle.Render(" enter to confirm · esc to cancel"))
|
|
||||||
default:
|
|
||||||
b.WriteString(l.title + "\n")
|
|
||||||
if len(l.items) == 0 {
|
|
||||||
b.WriteString(helpStyle.Render(" (empty)"))
|
|
||||||
}
|
|
||||||
for i, item := range l.items {
|
|
||||||
cursor := " "
|
|
||||||
if i == l.cursor {
|
|
||||||
cursor = cursorStyle.Render("> ")
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf("%s%s\n", cursor, item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content := b.String()
|
|
||||||
style := boxStyle
|
|
||||||
if l.mode != listBrowse {
|
|
||||||
style = activeBoxStyle
|
|
||||||
}
|
|
||||||
return style.Render(strings.TrimRight(content, "\n"))
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func saveAndValidateCmd(path string, p *Profile) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
if err := SaveProfile(path, p); err != nil {
|
|
||||||
return saveResultMsg{err: err}
|
|
||||||
}
|
|
||||||
cmd := exec.Command("nono", "profile", "validate", "opencode-tinfoil")
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
output := strings.TrimSpace(string(out))
|
|
||||||
if err != nil {
|
|
||||||
return saveResultMsg{output: output, err: fmt.Errorf("validation failed")}
|
|
||||||
}
|
|
||||||
return saveResultMsg{output: output}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
m, _ := NewModel()
|
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
|
||||||
if _, err := p.Run(); err != nil {
|
|
||||||
fmt.Printf("Error: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-172
@@ -1,172 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mainTab int
|
|
||||||
|
|
||||||
const (
|
|
||||||
tabDashboard mainTab = iota
|
|
||||||
tabEditor
|
|
||||||
)
|
|
||||||
|
|
||||||
var mainTabNames = []string{"Dashboard", "Editor"}
|
|
||||||
|
|
||||||
type saveResultMsg struct {
|
|
||||||
output string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
tab mainTab
|
|
||||||
dashboard DashboardModel
|
|
||||||
editor EditorModel
|
|
||||||
profile *Profile
|
|
||||||
profilePath string
|
|
||||||
dirty bool
|
|
||||||
statusMsg string
|
|
||||||
errMsg string
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
quitting bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewModel() (Model, tea.Cmd) {
|
|
||||||
profilePath, _ := DefaultProfilePath()
|
|
||||||
p, err := LoadProfile(profilePath)
|
|
||||||
if err != nil {
|
|
||||||
p = &Profile{}
|
|
||||||
}
|
|
||||||
|
|
||||||
checks := RunChecks()
|
|
||||||
|
|
||||||
m := Model{
|
|
||||||
tab: tabDashboard,
|
|
||||||
dashboard: NewDashboardModel(checks),
|
|
||||||
editor: NewEditorModel(p),
|
|
||||||
profile: p,
|
|
||||||
profilePath: profilePath,
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width = msg.Width
|
|
||||||
m.height = msg.Height
|
|
||||||
m.dashboard.width = msg.Width
|
|
||||||
m.editor.width = msg.Width
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case saveResultMsg:
|
|
||||||
if msg.err != nil {
|
|
||||||
m.errMsg = fmt.Sprintf("Save failed: %v", msg.err)
|
|
||||||
} else {
|
|
||||||
m.dirty = false
|
|
||||||
m.statusMsg = "Saved & validated: " + msg.output
|
|
||||||
m.errMsg = ""
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "ctrl+c", "q":
|
|
||||||
if m.dirty {
|
|
||||||
m.statusMsg = "Unsaved changes! Press Q to force quit, or s to save."
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.quitting = true
|
|
||||||
return m, tea.Quit
|
|
||||||
case "Q":
|
|
||||||
m.quitting = true
|
|
||||||
return m, tea.Quit
|
|
||||||
case "tab":
|
|
||||||
if m.tab == tabDashboard {
|
|
||||||
m.tab = tabEditor
|
|
||||||
} else {
|
|
||||||
m.tab = tabDashboard
|
|
||||||
}
|
|
||||||
m.statusMsg = ""
|
|
||||||
m.errMsg = ""
|
|
||||||
return m, nil
|
|
||||||
case "s":
|
|
||||||
if m.tab == tabEditor {
|
|
||||||
m.editor.SyncToProfile(m.profile)
|
|
||||||
cmd := saveAndValidateCmd(m.profilePath, m.profile)
|
|
||||||
m.statusMsg = "Saving..."
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch m.tab {
|
|
||||||
case tabDashboard:
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.dashboard, cmd = m.dashboard.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
case tabEditor:
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.editor, cmd = m.editor.Update(msg)
|
|
||||||
if !m.dirty {
|
|
||||||
m.dirty = true
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View() string {
|
|
||||||
if m.quitting {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
title := "nono-tui"
|
|
||||||
if m.dirty {
|
|
||||||
title += " *"
|
|
||||||
}
|
|
||||||
b.WriteString(titleStyle.Render(" " + title + " "))
|
|
||||||
|
|
||||||
tabs := []string{}
|
|
||||||
for i, name := range mainTabNames {
|
|
||||||
if mainTab(i) == m.tab {
|
|
||||||
tabs = append(tabs, activeTabStyle.Render(name))
|
|
||||||
} else {
|
|
||||||
tabs = append(tabs, tabStyle.Render(name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(" " + strings.Join(tabs, " "))
|
|
||||||
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
switch m.tab {
|
|
||||||
case tabDashboard:
|
|
||||||
b.WriteString(m.dashboard.View())
|
|
||||||
case tabEditor:
|
|
||||||
b.WriteString(m.editor.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
if m.errMsg != "" {
|
|
||||||
b.WriteString(errorStyle.Render(" " + m.errMsg))
|
|
||||||
} else if m.statusMsg != "" {
|
|
||||||
b.WriteString(successStyle.Render(" " + m.statusMsg))
|
|
||||||
} else {
|
|
||||||
b.WriteString(helpStyle.Render(" q:quit Tab:switch tabs"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
-128
@@ -1,128 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Profile struct {
|
|
||||||
Meta ProfileMeta `json:"meta"`
|
|
||||||
Groups ProfileGroups `json:"groups"`
|
|
||||||
Commands ProfileCommands `json:"commands"`
|
|
||||||
Workdir ProfileWorkdir `json:"workdir"`
|
|
||||||
FS ProfileFS `json:"filesystem"`
|
|
||||||
Network ProfileNetwork `json:"network"`
|
|
||||||
Env ProfileEnv `json:"environment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileMeta struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileGroups struct {
|
|
||||||
Include []string `json:"include"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileCommands struct {
|
|
||||||
Allow []string `json:"allow"`
|
|
||||||
Deny []string `json:"deny"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileWorkdir struct {
|
|
||||||
Access string `json:"access"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileFS struct {
|
|
||||||
Allow []string `json:"allow"`
|
|
||||||
Read []string `json:"read"`
|
|
||||||
Write []string `json:"write"`
|
|
||||||
Deny []string `json:"deny"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileNetwork struct {
|
|
||||||
Block bool `json:"block"`
|
|
||||||
AllowDomain []string `json:"allow_domain"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileEnv struct {
|
|
||||||
SetVars map[string]string `json:"set_vars"`
|
|
||||||
DenyVars []string `json:"deny_vars"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultProfilePath() (string, error) {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".config", "nono", "profiles", "opencode-tinfoil.json"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadProfile(path string) (*Profile, error) {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
clean := stripJSONCComments(raw)
|
|
||||||
var p Profile
|
|
||||||
if err := json.Unmarshal(clean, &p); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SaveProfile(path string, p *Profile) error {
|
|
||||||
data, err := json.MarshalIndent(p, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(path, data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stripJSONCComments(data []byte) []byte {
|
|
||||||
var result []byte
|
|
||||||
inString := false
|
|
||||||
escaped := false
|
|
||||||
i := 0
|
|
||||||
for i < len(data) {
|
|
||||||
c := data[i]
|
|
||||||
if inString {
|
|
||||||
result = append(result, c)
|
|
||||||
if escaped {
|
|
||||||
escaped = false
|
|
||||||
} else if c == '\\' {
|
|
||||||
escaped = true
|
|
||||||
} else if c == '"' {
|
|
||||||
inString = false
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c == '"' {
|
|
||||||
inString = true
|
|
||||||
result = append(result, c)
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c == '/' && i+1 < len(data) {
|
|
||||||
if data[i+1] == '/' {
|
|
||||||
for i < len(data) && data[i] != '\n' {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if data[i+1] == '*' {
|
|
||||||
i += 2
|
|
||||||
for i+1 < len(data) && !(data[i] == '*' && data[i+1] == '/') {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result = append(result, c)
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "github.com/charmbracelet/lipgloss"
|
|
||||||
|
|
||||||
var (
|
|
||||||
green = lipgloss.Color("#04B575")
|
|
||||||
red = lipgloss.Color("#FF4646")
|
|
||||||
yellow = lipgloss.Color("#FFA500")
|
|
||||||
blue = lipgloss.Color("#7D56F4")
|
|
||||||
gray = lipgloss.Color("#666666")
|
|
||||||
|
|
||||||
titleStyle = lipgloss.NewStyle().
|
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.Color("#FAFAFA")).
|
|
||||||
Background(blue).
|
|
||||||
Padding(0, 2)
|
|
||||||
|
|
||||||
tabStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(gray).
|
|
||||||
Padding(0, 2)
|
|
||||||
|
|
||||||
activeTabStyle = lipgloss.NewStyle().
|
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.Color("#FAFAFA")).
|
|
||||||
Background(blue).
|
|
||||||
Padding(0, 2)
|
|
||||||
|
|
||||||
checkOKStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(green).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
checkFailStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(red).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
labelStyle = lipgloss.NewStyle().
|
|
||||||
Width(26).
|
|
||||||
Foreground(lipgloss.Color("#AAAAAA"))
|
|
||||||
|
|
||||||
detailStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(gray)
|
|
||||||
|
|
||||||
boxStyle = lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(gray).
|
|
||||||
Padding(0, 1)
|
|
||||||
|
|
||||||
activeBoxStyle = lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(blue).
|
|
||||||
Padding(0, 1)
|
|
||||||
|
|
||||||
cursorStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(yellow).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
dirtyStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(yellow).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
helpStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(gray).
|
|
||||||
Italic(true)
|
|
||||||
|
|
||||||
statusBarStyle = lipgloss.NewStyle().
|
|
||||||
Background(lipgloss.Color("#333333")).
|
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
|
||||||
Padding(0, 1)
|
|
||||||
|
|
||||||
errorStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(red).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
successStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(green).
|
|
||||||
Bold(true)
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user