mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-12-06 02:48:28 +01:00
Compare commits
No commits in common. "e8c6e602a25a95725df5215e7bac85c856a5a8d3" and "fad2585d5e6ecccefd5ceabb9f51a824d1601b7d" have entirely different histories.
e8c6e602a2
...
fad2585d5e
426
.github/copilot-instructions.md
vendored
426
.github/copilot-instructions.md
vendored
@ -1,426 +0,0 @@
|
||||
# GitHub Copilot Instructions for Go + Bubble Tea (TUI)
|
||||
|
||||
These project-level instructions tell Copilot how to generate idiomatic, production-quality Go code using the Bubble Tea ecosystem. **Follow and prefer these rules over generic patterns.**
|
||||
|
||||
---
|
||||
|
||||
## 1) Project Scope & Goals
|
||||
|
||||
* Build terminal UIs with **[Bubble Tea](https://github.com/charmbracelet/bubbletea)** and **Bubbles** components.
|
||||
* Use **Lip Gloss** for styling and **Huh**/**Bubbles forms** for prompts where useful.
|
||||
* Favor **small, composable models** and **message-driven state**.
|
||||
* Prioritize **maintainability, testability, and clear separation** of update vs. view.
|
||||
|
||||
---
|
||||
|
||||
## 2) Go Conventions to Prefer
|
||||
|
||||
* Go version: **1.22+**.
|
||||
* Module: `go.mod` with minimal, pinned dependencies; use `go get -u` only deliberately.
|
||||
* Code style: `gofmt`, `go vet`, `staticcheck` (when available), `golangci-lint`.
|
||||
* Names: short, meaningful; exported symbols require GoDoc comments.
|
||||
* Errors: return wrapped errors with `%w` and `errors.Is/As`. No panics for flow control.
|
||||
* Concurrency: use `context.Context` and `errgroup` where applicable. Avoid goroutine leaks; cancel contexts in `Quit`/`Stop`.
|
||||
* Testing: `*_test.go`, table-driven tests, golden tests for `View()` when helpful.
|
||||
* Logging: prefer structured logs (e.g., `slog`) and keep logs separate from UI rendering.
|
||||
|
||||
---
|
||||
|
||||
## 3) Bubble Tea Architecture Rules
|
||||
|
||||
### 3.1 Model layout
|
||||
|
||||
```go
|
||||
// Model holds all state needed to render and update.
|
||||
type Model struct {
|
||||
width, height int
|
||||
ready bool
|
||||
|
||||
// Domain state
|
||||
items []Item
|
||||
cursor int
|
||||
err error
|
||||
|
||||
// Child components
|
||||
list list.Model
|
||||
spinner spinner.Model
|
||||
|
||||
// Styles
|
||||
styles Styles
|
||||
}
|
||||
```
|
||||
|
||||
**Guidelines**
|
||||
|
||||
* Keep **domain state** (data) separate from **UI components** (Bubbles models) and **styles**.
|
||||
* Add a `Styles` struct to centralize Lip Gloss styles; initialize once.
|
||||
* Track terminal size (`width`, `height`); re-calc layout on `tea.WindowSizeMsg`.
|
||||
|
||||
### 3.2 Init
|
||||
|
||||
* Return **batch** of startup commands for IO (e.g., loading data) and component inits.
|
||||
* Never block in `Init`; do IO in `tea.Cmd`s.
|
||||
|
||||
```go
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Tick, loadItemsCmd())
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Update
|
||||
|
||||
* **Pure function** style: transform `Model` + `Msg` → `(Model, Cmd)`.
|
||||
* Always handle `tea.WindowSizeMsg` to set `m.width`/`m.height` and recompute layout.
|
||||
* Use **type-switched** message handling; push side effects into `tea.Cmd`s.
|
||||
* Bubble components: call `Update(msg)` on children and **return their Cmd**.
|
||||
|
||||
```go
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
m.styles = NewStyles(m.width) // recompute if responsive
|
||||
return m, nil
|
||||
|
||||
case errMsg:
|
||||
m.err = msg
|
||||
return m, nil
|
||||
|
||||
case itemsLoaded:
|
||||
m.items = msg
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// delegate to children last
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 View
|
||||
|
||||
* **Never** mutate state in `View()`.
|
||||
* Compose layout with Lip Gloss; gracefully handle small terminals.
|
||||
* Put errors and help at the bottom.
|
||||
|
||||
```go
|
||||
func (m Model) View() string {
|
||||
if !m.ready {
|
||||
return m.styles.Loading.Render(m.spinner.View() + " Loading…")
|
||||
}
|
||||
main := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.styles.Title.Render("My App"),
|
||||
m.list.View(),
|
||||
)
|
||||
if m.err != nil {
|
||||
main += "\n" + m.styles.Error.Render(m.err.Error())
|
||||
}
|
||||
return m.styles.App.Render(main)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Messages & Commands
|
||||
|
||||
* Define **typed messages** for domain events, not raw strings.
|
||||
* Each async operation returns a **message type**; handle errors with a dedicated `errMsg`.
|
||||
|
||||
```go
|
||||
type itemsLoaded []Item
|
||||
|
||||
type errMsg error
|
||||
|
||||
func loadItemsCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := fetchItems()
|
||||
if err != nil { return errMsg(err) }
|
||||
return itemsLoaded(items)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 Keys & Help
|
||||
|
||||
* Centralize keybindings and help text. Prefer `bubbles/key` + `bubbles/help`.
|
||||
|
||||
```go
|
||||
type keyMap struct {
|
||||
Up, Down, Select, Quit key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
}
|
||||
```
|
||||
|
||||
Handle keys in `Update` using `key.Matches(msg, keys.X)` and show a `help.Model` in the footer.
|
||||
|
||||
### 3.7 Submodels (Component Composition)
|
||||
|
||||
* For complex screens, create **submodels** with their own `(Model, Init, Update, View)` and wire them into a parent.
|
||||
* Exchange messages via **custom Msg types** and/or **parent state**.
|
||||
* Keep submodels **pure**; IO still goes through parent-level `tea.Cmd`s or via submodel commands returned to parent.
|
||||
|
||||
### 3.8 Program Options
|
||||
|
||||
* Start programs with `tea.NewProgram(m, tea.WithOutput(os.Stdout), tea.WithAltScreen())` when full-screen; avoid AltScreen for simple tools.
|
||||
* Always handle **TTY absence** (e.g., piping); fall back to non-interactive.
|
||||
|
||||
---
|
||||
|
||||
## 4) Styling with Lip Gloss
|
||||
|
||||
* Maintain a single `Styles` struct with named styles.
|
||||
* Compute widths once per resize; avoid per-cell Lip Gloss allocations in tight loops.
|
||||
* Use `lipgloss.JoinHorizontal/Vertical` for layout; avoid manual spacing where possible.
|
||||
|
||||
```go
|
||||
type Styles struct {
|
||||
App, Title, Error, Loading lipgloss.Style
|
||||
}
|
||||
|
||||
func NewStyles(width int) Styles {
|
||||
return Styles{
|
||||
App: lipgloss.NewStyle().Padding(1),
|
||||
Title: lipgloss.NewStyle().Bold(true),
|
||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
|
||||
Loading: lipgloss.NewStyle().Faint(true),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) IO, Concurrency & Performance
|
||||
|
||||
* **Never** perform blocking IO in `Update` directly; always return a `tea.Cmd` that does the work.
|
||||
* Use `context.Context` inside commands; respect cancellation when program exits.
|
||||
* Be careful with **goroutine leaks**: ensure commands stop when model quits.
|
||||
* Batch commands with `tea.Batch` to keep updates snappy.
|
||||
* For large lists, prefer `bubbles/list` with virtualization; avoid generating huge strings per frame.
|
||||
* Debounce high-frequency events (typing) with timer-based commands.
|
||||
|
||||
---
|
||||
|
||||
## 6) Error Handling & UX
|
||||
|
||||
* Represent recoverable errors in the UI; do not exit on first error.
|
||||
* Use `errMsg` for async failures; show a concise, styled error line.
|
||||
* For fatal initialization errors, return `tea.Quit` with an explanatory message printed once.
|
||||
|
||||
---
|
||||
|
||||
## 7) Keys, Shortcuts, and Accessibility
|
||||
|
||||
* Provide **discoverable shortcuts** via a footer help view.
|
||||
* Offer Emacs-style alternatives where it makes sense (e.g., `ctrl+n/p`).
|
||||
* Use consistent navigation patterns across screens.
|
||||
|
||||
---
|
||||
|
||||
## 8) Testing Strategy
|
||||
|
||||
* Unit test message handling with deterministic messages.
|
||||
* Snapshot/golden-test `View()` output for known terminal sizes.
|
||||
* Fuzz-test parsers/formatters used by the UI.
|
||||
|
||||
```go
|
||||
func TestUpdate_Select(t *testing.T) {
|
||||
m := newTestModel()
|
||||
_, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if got, want := m.cursor, 1; got != want { t.Fatalf("cursor=%d want %d", got, want) }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9) Project Structure Template
|
||||
|
||||
```
|
||||
cmd/
|
||||
app/
|
||||
main.go
|
||||
internal/
|
||||
tui/
|
||||
model.go // root model, styles
|
||||
update.go // Update + messages
|
||||
view.go // View
|
||||
keys.go // keymap/help
|
||||
components/ // submodels
|
||||
domain/ // business logic, pure Go
|
||||
io/ // adapters (API, FS, net)
|
||||
|
||||
Makefile // lint, test, run targets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10) Scaffolding Snippets (Ask Copilot to use these)
|
||||
|
||||
### 10.1 Root main.go
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if !isTTY() { // optional: detect piping
|
||||
log.Println("Non-interactive mode not implemented.")
|
||||
return
|
||||
}
|
||||
|
||||
p := tea.NewProgram(NewModel(), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
log.Fatalf("error: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 NewModel()
|
||||
|
||||
```go
|
||||
func NewModel() Model {
|
||||
s := NewStyles(0)
|
||||
return Model{
|
||||
list: newList(),
|
||||
spinner: spinner.New(),
|
||||
styles: s,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 Custom messages
|
||||
|
||||
```go
|
||||
type (
|
||||
errMsg error
|
||||
itemsLoaded []Item
|
||||
)
|
||||
```
|
||||
|
||||
### 10.4 Command helper
|
||||
|
||||
```go
|
||||
func do(cmd func(context.Context) (tea.Msg, error)) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
msg, err := cmd(ctx)
|
||||
if err != nil { return errMsg(err) }
|
||||
return msg
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11) Dependencies to Prefer
|
||||
|
||||
* `github.com/charmbracelet/bubbletea`
|
||||
* `github.com/charmbracelet/bubbles`
|
||||
* `github.com/charmbracelet/lipgloss`
|
||||
* `golang.org/x/sync/errgroup` (for non-UI workloads)
|
||||
* `log/slog` (Go 1.21+) for logging
|
||||
|
||||
Pin versions in `go.mod`. Avoid extra UI deps unless justified.
|
||||
|
||||
---
|
||||
|
||||
## 12) Copilot Prompting Rules (Important)
|
||||
|
||||
* When the user writes a new TUI screen, **scaffold** `(Model, Init, Update, View)` with:
|
||||
|
||||
* Window size handling
|
||||
* Keymap/help wiring
|
||||
* Styles struct and `NewStyles(width)`
|
||||
* Commands for all IO
|
||||
* Prefer **typed messages** and return **`tea.Cmd`**; do not perform blocking work in `Update`.
|
||||
* Always update child bubble components via `child.Update(msg)` and collect cmds with `tea.Batch`.
|
||||
* Generate **tests** for key message paths.
|
||||
* Include **help footer** with keybindings.
|
||||
* Keep `View()` pure and free of side effects.
|
||||
|
||||
**Bad**
|
||||
|
||||
* Doing HTTP/FS work directly in `Update`.
|
||||
* Printing to stdout from `Update`/`View`.
|
||||
* Storing `context.Context` in the model.
|
||||
* Creating goroutines that outlive the program.
|
||||
|
||||
**Good**
|
||||
|
||||
* Commands that return typed messages.
|
||||
* Centralized keymap + help.
|
||||
* Single source of truth for styles.
|
||||
* Small submodels and composition.
|
||||
|
||||
---
|
||||
|
||||
## 13) Security & Reliability
|
||||
|
||||
* Validate all external inputs; sanitize strings rendered into the terminal.
|
||||
* Respect user locale and UTF-8; avoid slicing strings by bytes for widths (use `lipgloss.Width`).
|
||||
* Handle small terminal sizes (min-width fallbacks).
|
||||
* Ensure graceful shutdown; propagate quit via `tea.Quit` and cancel pending work.
|
||||
|
||||
---
|
||||
|
||||
## 14) Makefile Targets (suggested)
|
||||
|
||||
```
|
||||
.PHONY: run test lint fmt tidy
|
||||
run:; go run ./cmd/app
|
||||
fmt:; go fmt ./...
|
||||
lint:; golangci-lint run
|
||||
test:; go test ./...
|
||||
tidy:; go mod tidy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15) Example Key Handling Pattern
|
||||
|
||||
```go
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, keys.Quit):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, keys.Up):
|
||||
if m.cursor > 0 { m.cursor-- }
|
||||
case key.Matches(msg, keys.Down):
|
||||
if m.cursor < len(m.items)-1 { m.cursor++ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16) Documentation & Comments
|
||||
|
||||
* Exported types/functions must have a sentence GoDoc.
|
||||
* At the top of each file, include a short comment describing its responsibility.
|
||||
* For non-obvious state transitions, include a brief state diagram in comments.
|
||||
|
||||
---
|
||||
|
||||
## 17) Acceptance Criteria for Generated Code
|
||||
|
||||
* Builds with `go build ./...`
|
||||
* Passes `go vet` and `golangci-lint` (if configured)
|
||||
* Has at least one table-driven test per major update path
|
||||
* Handles window resize and quit
|
||||
* No side effects in `View()`
|
||||
* Commands wrap errors and return `errMsg`
|
||||
|
||||
---
|
||||
|
||||
*End of instructions.*
|
||||
71
README.md
71
README.md
@ -16,11 +16,7 @@
|
||||
SSHM is a beautiful command-line tool that transforms how you manage and connect to your SSH hosts. Built with Go and featuring an intuitive TUI interface, it makes SSH connection management effortless and enjoyable.
|
||||
|
||||
<p align="center">
|
||||
<a href="images/sshm.gif" target="_blank">
|
||||
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="800" />
|
||||
</a>
|
||||
<br>
|
||||
<em>🖱️ Click on the image to view in full size</em>
|
||||
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="600" />
|
||||
</p>
|
||||
|
||||
## ✨ Features
|
||||
@ -32,7 +28,6 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
||||
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization
|
||||
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
|
||||
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
|
||||
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
|
||||
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
|
||||
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
||||
|
||||
@ -90,14 +85,6 @@ sshm
|
||||
- `q` - Quit
|
||||
- `/` - Search/filter hosts
|
||||
|
||||
**Sorting & Filtering:**
|
||||
- `s` - Switch between sorting modes (name ↔ last login)
|
||||
- `n` - Sort by **name** (alphabetical)
|
||||
- `r` - Sort by **recent** (last login time)
|
||||
- `Tab` - Cycle between filtering modes
|
||||
- Filter by **name** (default) - Search through host names
|
||||
- Filter by **last login** - Sort and filter by most recently used connections
|
||||
|
||||
The interactive forms will guide you through configuration:
|
||||
- **Hostname/IP** - Server address
|
||||
- **Username** - SSH user
|
||||
@ -115,24 +102,15 @@ SSHM provides both command-line operations and an interactive TUI interface:
|
||||
# Launch interactive TUI mode for browsing and connecting to hosts
|
||||
sshm
|
||||
|
||||
# Launch TUI with custom SSH config file
|
||||
sshm -c /path/to/custom/ssh_config
|
||||
|
||||
# Add a new host using interactive form
|
||||
sshm add
|
||||
|
||||
# Add a new host with pre-filled hostname
|
||||
sshm add hostname
|
||||
|
||||
# Add a new host with custom SSH config file
|
||||
sshm add hostname -c /path/to/custom/ssh_config
|
||||
|
||||
# Edit an existing host configuration
|
||||
sshm edit my-server
|
||||
|
||||
# Edit host with custom SSH config file
|
||||
sshm edit my-server -c /path/to/custom/ssh_config
|
||||
|
||||
# Show version information
|
||||
sshm --version
|
||||
|
||||
@ -140,19 +118,6 @@ sshm --version
|
||||
sshm --help
|
||||
```
|
||||
|
||||
### Configuration File Options
|
||||
|
||||
By default, SSHM uses the standard SSH configuration file at `~/.ssh/config`. You can specify a different configuration file using the `-c` flag:
|
||||
|
||||
```bash
|
||||
# Use custom config file in TUI mode
|
||||
sshm -c /path/to/custom/ssh_config
|
||||
|
||||
# Use custom config file with commands
|
||||
sshm add hostname -c /path/to/custom/ssh_config
|
||||
sshm edit hostname -c /path/to/custom/ssh_config
|
||||
```
|
||||
|
||||
## 🏗️ Configuration
|
||||
|
||||
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
|
||||
@ -257,44 +222,24 @@ go build -o sshm .
|
||||
|
||||
```
|
||||
sshm/
|
||||
├── main.go # Application entry point
|
||||
├── cmd/ # CLI commands (Cobra)
|
||||
│ ├── root.go # Root command and interactive mode
|
||||
│ ├── add.go # Add host command
|
||||
│ ├── edit.go # Edit host command
|
||||
│ └── search.go # Search command
|
||||
│ └── edit.go # Edit host command
|
||||
├── internal/
|
||||
│ ├── config/ # SSH configuration management
|
||||
│ │ └── ssh.go # Config parsing and manipulation
|
||||
│ ├── history/ # Connection history tracking
|
||||
│ │ └── history.go # History management and last login tracking
|
||||
│ ├── ui/ # Terminal UI components (Bubble Tea)
|
||||
│ │ ├── tui.go # Main TUI interface and program setup
|
||||
│ │ ├── model.go # Core TUI model and state
|
||||
│ │ ├── update.go # Message handling and state updates
|
||||
│ │ ├── view.go # UI rendering and layout
|
||||
│ │ ├── table.go # Host list table component
|
||||
│ │ ├── add_form.go # Add host form interface
|
||||
│ │ ├── edit_form.go# Edit host form interface
|
||||
│ │ ├── styles.go # Lip Gloss styling definitions
|
||||
│ │ ├── sort.go # Sorting and filtering logic
|
||||
│ │ └── utils.go # UI utility functions
|
||||
│ ├── ui/ # Terminal UI components
|
||||
│ │ ├── tui.go # Main TUI interface
|
||||
│ │ ├── add_form.go # Add host form
|
||||
│ │ └── edit_form.go# Edit host form
|
||||
│ └── validation/ # Input validation
|
||||
│ └── ssh.go # SSH config validation
|
||||
├── images/ # Documentation assets
|
||||
│ ├── logo.png # Project logo
|
||||
│ └── sshm.gif # Demo animation
|
||||
├── install/ # Installation scripts
|
||||
│ ├── unix.sh # Unix/Linux/macOS installer
|
||||
│ └── README.md # Installation guide
|
||||
├── .github/ # GitHub configuration
|
||||
│ ├── copilot-instructions.md # Development guidelines
|
||||
│ └── workflows/ # CI/CD pipelines
|
||||
│ └── build.yml # Multi-platform builds
|
||||
├── go.mod # Go module definition
|
||||
├── go.sum # Go module checksums
|
||||
├── LICENSE # MIT license
|
||||
└── README.md # Project documentation
|
||||
└── .github/workflows/ # CI/CD pipelines
|
||||
└── build.yml # Multi-platform builds
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
@ -18,7 +18,7 @@ var addCmd = &cobra.Command{
|
||||
hostname = args[0]
|
||||
}
|
||||
|
||||
err := ui.RunAddForm(hostname, configFile)
|
||||
err := ui.RunAddForm(hostname)
|
||||
if err != nil {
|
||||
fmt.Printf("Error adding host: %v\n", err)
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ var editCmd = &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
hostname := args[0]
|
||||
|
||||
err := ui.RunEditForm(hostname, configFile)
|
||||
err := ui.RunEditForm(hostname)
|
||||
if err != nil {
|
||||
fmt.Printf("Error editing host: %v\n", err)
|
||||
}
|
||||
|
||||
59
cmd/root.go
59
cmd/root.go
@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/ui"
|
||||
@ -15,20 +14,12 @@ import (
|
||||
// version will be set at build time via -ldflags
|
||||
var version = "dev"
|
||||
|
||||
// configFile holds the path to the SSH config file
|
||||
var configFile string
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "sshm",
|
||||
Short: "SSH Manager - A modern SSH connection manager",
|
||||
Long: `SSHM is a modern SSH manager for your terminal.
|
||||
|
||||
Main usage:
|
||||
Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically.
|
||||
|
||||
You can also use sshm in CLI mode for direct operations.
|
||||
|
||||
Hosts are read from your ~/.ssh/config file by default.`,
|
||||
Long: `SSH Manager (sshm) is a modern command-line tool for managing SSH connections.
|
||||
It provides an interactive interface to browse and connect to your SSH hosts
|
||||
configured in your ~/.ssh/config file.`,
|
||||
Version: version,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// If no arguments provided, run interactive mode
|
||||
@ -45,15 +36,7 @@ Hosts are read from your ~/.ssh/config file by default.`,
|
||||
|
||||
func runInteractiveMode() {
|
||||
// Parse SSH configurations
|
||||
var hosts []config.SSHHost
|
||||
var err error
|
||||
|
||||
if configFile != "" {
|
||||
hosts, err = config.ParseSSHConfigFile(configFile)
|
||||
} else {
|
||||
hosts, err = config.ParseSSHConfig()
|
||||
}
|
||||
|
||||
hosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading SSH config file: %v", err)
|
||||
}
|
||||
@ -64,16 +47,12 @@ func runInteractiveMode() {
|
||||
var response string
|
||||
_, err := fmt.Scanln(&response)
|
||||
if err == nil && (response == "y" || response == "Y") {
|
||||
err := ui.RunAddForm("", configFile)
|
||||
err := ui.RunAddForm("")
|
||||
if err != nil {
|
||||
fmt.Printf("Error adding host: %v\n", err)
|
||||
}
|
||||
// After adding, try to reload hosts and continue if any exist
|
||||
if configFile != "" {
|
||||
hosts, err = config.ParseSSHConfigFile(configFile)
|
||||
} else {
|
||||
hosts, err = config.ParseSSHConfig()
|
||||
}
|
||||
if err != nil || len(hosts) == 0 {
|
||||
fmt.Println("No hosts available, exiting.")
|
||||
os.Exit(1)
|
||||
@ -85,22 +64,14 @@ func runInteractiveMode() {
|
||||
}
|
||||
|
||||
// Run the interactive TUI
|
||||
if err := ui.RunInteractiveMode(hosts, configFile); err != nil {
|
||||
if err := ui.RunInteractiveMode(hosts); err != nil {
|
||||
log.Fatalf("Error running interactive mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func connectToHost(hostName string) {
|
||||
// Parse SSH configurations to verify host exists
|
||||
var hosts []config.SSHHost
|
||||
var err error
|
||||
|
||||
if configFile != "" {
|
||||
hosts, err = config.ParseSSHConfigFile(configFile)
|
||||
} else {
|
||||
hosts, err = config.ParseSSHConfig()
|
||||
}
|
||||
|
||||
hosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading SSH config file: %v", err)
|
||||
}
|
||||
@ -122,18 +93,9 @@ func connectToHost(hostName string) {
|
||||
|
||||
// Connect to the host
|
||||
fmt.Printf("Connecting to %s...\n", hostName)
|
||||
|
||||
// Build the SSH command with the appropriate config file
|
||||
var sshCmd []string
|
||||
if configFile != "" {
|
||||
sshCmd = []string{"ssh", "-F", configFile, hostName}
|
||||
} else {
|
||||
sshCmd = []string{"ssh", hostName}
|
||||
}
|
||||
|
||||
// Note: In a real implementation, you'd use exec.Command here
|
||||
// For now, just print the command that would be executed
|
||||
fmt.Printf("%s\n", strings.Join(sshCmd, " "))
|
||||
fmt.Printf("ssh %s\n", hostName)
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
@ -143,8 +105,3 @@ func Execute() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Add the config file flag
|
||||
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
|
||||
}
|
||||
|
||||
244
cmd/search.go
244
cmd/search.go
@ -1,244 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
// outputFormat defines the output format (table, json, simple)
|
||||
outputFormat string
|
||||
// tagsOnly limits search to tags only
|
||||
tagsOnly bool
|
||||
// namesOnly limits search to host names only
|
||||
namesOnly bool
|
||||
)
|
||||
|
||||
var searchCmd = &cobra.Command{
|
||||
Use: "search [query]",
|
||||
Short: "Search SSH hosts by name, hostname, or tags",
|
||||
Long: `Search through your SSH hosts configuration by name, hostname, or tags.
|
||||
The search is case-insensitive and will match partial strings.
|
||||
|
||||
Examples:
|
||||
sshm search web # Search for hosts containing "web"
|
||||
sshm search --tags dev # Search only in tags for "dev"
|
||||
sshm search --names prod # Search only in host names for "prod"
|
||||
sshm search --format json server # Output results in JSON format`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: runSearch,
|
||||
}
|
||||
|
||||
func runSearch(cmd *cobra.Command, args []string) {
|
||||
// Parse SSH configurations
|
||||
var hosts []config.SSHHost
|
||||
var err error
|
||||
|
||||
if configFile != "" {
|
||||
hosts, err = config.ParseSSHConfigFile(configFile)
|
||||
} else {
|
||||
hosts, err = config.ParseSSHConfig()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading SSH config file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
fmt.Println("No SSH hosts found in your configuration file.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get search query
|
||||
var query string
|
||||
if len(args) > 0 {
|
||||
query = args[0]
|
||||
}
|
||||
|
||||
// Filter hosts based on search criteria
|
||||
filteredHosts := filterHosts(hosts, query, tagsOnly, namesOnly)
|
||||
|
||||
// Display results
|
||||
if len(filteredHosts) == 0 {
|
||||
if query == "" {
|
||||
fmt.Println("No hosts found.")
|
||||
} else {
|
||||
fmt.Printf("No hosts found matching '%s'.\n", query)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Output results in specified format
|
||||
switch outputFormat {
|
||||
case "json":
|
||||
outputJSON(filteredHosts)
|
||||
case "simple":
|
||||
outputSimple(filteredHosts)
|
||||
default:
|
||||
outputTable(filteredHosts)
|
||||
}
|
||||
}
|
||||
|
||||
// filterHosts filters hosts according to the search query and options
|
||||
func filterHosts(hosts []config.SSHHost, query string, tagsOnly, namesOnly bool) []config.SSHHost {
|
||||
var filtered []config.SSHHost
|
||||
|
||||
if query == "" {
|
||||
return hosts
|
||||
}
|
||||
|
||||
query = strings.ToLower(query)
|
||||
|
||||
for _, host := range hosts {
|
||||
matched := false
|
||||
|
||||
// Search in names if not tags-only
|
||||
if !tagsOnly {
|
||||
// Check the host name
|
||||
if strings.Contains(strings.ToLower(host.Name), query) {
|
||||
matched = true
|
||||
}
|
||||
|
||||
// Check the hostname if not names-only
|
||||
if !namesOnly && !matched && strings.Contains(strings.ToLower(host.Hostname), query) {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
// Search in tags if not names-only
|
||||
if !namesOnly && !matched {
|
||||
for _, tag := range host.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), query) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matched {
|
||||
filtered = append(filtered, host)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// outputTable displays results in a formatted table
|
||||
func outputTable(hosts []config.SSHHost) {
|
||||
if len(hosts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
nameWidth := 4 // "Name"
|
||||
hostWidth := 8 // "Hostname"
|
||||
userWidth := 4 // "User"
|
||||
tagsWidth := 4 // "Tags"
|
||||
|
||||
for _, host := range hosts {
|
||||
if len(host.Name) > nameWidth {
|
||||
nameWidth = len(host.Name)
|
||||
}
|
||||
if len(host.Hostname) > hostWidth {
|
||||
hostWidth = len(host.Hostname)
|
||||
}
|
||||
if len(host.User) > userWidth {
|
||||
userWidth = len(host.User)
|
||||
}
|
||||
tagsStr := strings.Join(host.Tags, ", ")
|
||||
if len(tagsStr) > tagsWidth {
|
||||
tagsWidth = len(tagsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Add padding
|
||||
nameWidth += 2
|
||||
hostWidth += 2
|
||||
userWidth += 2
|
||||
tagsWidth += 2
|
||||
|
||||
// Print header
|
||||
fmt.Printf("%-*s %-*s %-*s %-*s\n", nameWidth, "Name", hostWidth, "Hostname", userWidth, "User", tagsWidth, "Tags")
|
||||
fmt.Printf("%s %s %s %s\n",
|
||||
strings.Repeat("-", nameWidth),
|
||||
strings.Repeat("-", hostWidth),
|
||||
strings.Repeat("-", userWidth),
|
||||
strings.Repeat("-", tagsWidth))
|
||||
|
||||
// Print hosts
|
||||
for _, host := range hosts {
|
||||
user := host.User
|
||||
if user == "" {
|
||||
user = "-"
|
||||
}
|
||||
tags := strings.Join(host.Tags, ", ")
|
||||
if tags == "" {
|
||||
tags = "-"
|
||||
}
|
||||
fmt.Printf("%-*s %-*s %-*s %-*s\n", nameWidth, host.Name, hostWidth, host.Hostname, userWidth, user, tagsWidth, tags)
|
||||
}
|
||||
|
||||
fmt.Printf("\nFound %d host(s)\n", len(hosts))
|
||||
}
|
||||
|
||||
// outputSimple displays results in simple format (one per line)
|
||||
func outputSimple(hosts []config.SSHHost) {
|
||||
for _, host := range hosts {
|
||||
fmt.Println(host.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// outputJSON displays results in JSON format
|
||||
func outputJSON(hosts []config.SSHHost) {
|
||||
fmt.Println("[")
|
||||
for i, host := range hosts {
|
||||
fmt.Printf(" {\n")
|
||||
fmt.Printf(" \"name\": \"%s\",\n", escapeJSON(host.Name))
|
||||
fmt.Printf(" \"hostname\": \"%s\",\n", escapeJSON(host.Hostname))
|
||||
fmt.Printf(" \"user\": \"%s\",\n", escapeJSON(host.User))
|
||||
fmt.Printf(" \"port\": \"%s\",\n", escapeJSON(host.Port))
|
||||
fmt.Printf(" \"identity\": \"%s\",\n", escapeJSON(host.Identity))
|
||||
fmt.Printf(" \"proxy_jump\": \"%s\",\n", escapeJSON(host.ProxyJump))
|
||||
fmt.Printf(" \"options\": \"%s\",\n", escapeJSON(host.Options))
|
||||
fmt.Printf(" \"tags\": [")
|
||||
for j, tag := range host.Tags {
|
||||
fmt.Printf("\"%s\"", escapeJSON(tag))
|
||||
if j < len(host.Tags)-1 {
|
||||
fmt.Printf(", ")
|
||||
}
|
||||
}
|
||||
fmt.Printf("]\n")
|
||||
if i < len(hosts)-1 {
|
||||
fmt.Printf(" },\n")
|
||||
} else {
|
||||
fmt.Printf(" }\n")
|
||||
}
|
||||
}
|
||||
fmt.Println("]")
|
||||
}
|
||||
|
||||
// escapeJSON escapes special characters for JSON output
|
||||
func escapeJSON(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "\"", "\\\"")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||
s = strings.ReplaceAll(s, "\t", "\\t")
|
||||
return s
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Add search command to root
|
||||
rootCmd.AddCommand(searchCmd)
|
||||
|
||||
// Add flags
|
||||
searchCmd.Flags().StringVarP(&outputFormat, "format", "f", "table", "Output format (table, json, simple)")
|
||||
searchCmd.Flags().BoolVar(&tagsOnly, "tags", false, "Search only in tags")
|
||||
searchCmd.Flags().BoolVar(&namesOnly, "names", false, "Search only in host names")
|
||||
}
|
||||
BIN
images/sshm.gif
BIN
images/sshm.gif
Binary file not shown.
|
Before Width: | Height: | Size: 797 KiB After Width: | Height: | Size: 615 KiB |
@ -181,18 +181,15 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||
|
||||
// AddSSHHost adds a new SSH host to the config file
|
||||
func AddSSHHost(host SSHHost) error {
|
||||
configMutex.Lock()
|
||||
defer configMutex.Unlock()
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
return AddSSHHostToFile(host, configPath)
|
||||
}
|
||||
|
||||
// AddSSHHostToFile adds a new SSH host to a specific config file
|
||||
func AddSSHHostToFile(host SSHHost, configPath string) error {
|
||||
configMutex.Lock()
|
||||
defer configMutex.Unlock()
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
|
||||
// Create backup before modification if file exists
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
@ -201,8 +198,8 @@ func AddSSHHostToFile(host SSHHost, configPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if host already exists in the specified config file
|
||||
exists, err := HostExistsInFile(host.Name, configPath)
|
||||
// Check if host already exists
|
||||
exists, err := HostExists(host.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -357,21 +354,6 @@ func HostExists(hostName string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// HostExistsInFile checks if a host exists in a specific config file
|
||||
func HostExistsInFile(hostName string, configPath string) (bool, error) {
|
||||
hosts, err := ParseSSHConfigFile(configPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if host.Name == hostName {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GetSSHHost retrieves a specific host configuration by name
|
||||
func GetSSHHost(hostName string) (*SSHHost, error) {
|
||||
hosts, err := ParseSSHConfig()
|
||||
@ -387,35 +369,17 @@ func GetSSHHost(hostName string) (*SSHHost, error) {
|
||||
return nil, fmt.Errorf("host '%s' not found", hostName)
|
||||
}
|
||||
|
||||
// GetSSHHostFromFile retrieves a specific host configuration by name from a specific config file
|
||||
func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
|
||||
hosts, err := ParseSSHConfigFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if host.Name == hostName {
|
||||
return &host, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("host '%s' not found", hostName)
|
||||
}
|
||||
|
||||
// UpdateSSHHost updates an existing SSH host configuration
|
||||
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
||||
configMutex.Lock()
|
||||
defer configMutex.Unlock()
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
||||
}
|
||||
|
||||
// UpdateSSHHostInFile updates an existing SSH host configuration in a specific file
|
||||
func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) error {
|
||||
configMutex.Lock()
|
||||
defer configMutex.Unlock()
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
|
||||
// Create backup before modification
|
||||
if err := backupConfig(configPath); err != nil {
|
||||
@ -564,18 +528,15 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
|
||||
|
||||
// DeleteSSHHost removes an SSH host configuration from the config file
|
||||
func DeleteSSHHost(hostName string) error {
|
||||
configMutex.Lock()
|
||||
defer configMutex.Unlock()
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
return DeleteSSHHostFromFile(hostName, configPath)
|
||||
}
|
||||
|
||||
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
|
||||
func DeleteSSHHostFromFile(hostName, configPath string) error {
|
||||
configMutex.Lock()
|
||||
defer configMutex.Unlock()
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
|
||||
// Create backup before modification
|
||||
if err := backupConfig(configPath); err != nil {
|
||||
|
||||
@ -1,208 +0,0 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
)
|
||||
|
||||
// ConnectionHistory represents the history of SSH connections
|
||||
type ConnectionHistory struct {
|
||||
Connections map[string]ConnectionInfo `json:"connections"`
|
||||
}
|
||||
|
||||
// ConnectionInfo stores information about a specific connection
|
||||
type ConnectionInfo struct {
|
||||
HostName string `json:"host_name"`
|
||||
LastConnect time.Time `json:"last_connect"`
|
||||
ConnectCount int `json:"connect_count"`
|
||||
}
|
||||
|
||||
// HistoryManager manages the connection history
|
||||
type HistoryManager struct {
|
||||
historyPath string
|
||||
history *ConnectionHistory
|
||||
}
|
||||
|
||||
// NewHistoryManager creates a new history manager
|
||||
func NewHistoryManager() (*HistoryManager, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
historyPath := filepath.Join(homeDir, ".ssh", "sshm_history.json")
|
||||
|
||||
hm := &HistoryManager{
|
||||
historyPath: historyPath,
|
||||
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
|
||||
}
|
||||
|
||||
// Load existing history if it exists
|
||||
err = hm.loadHistory()
|
||||
if err != nil {
|
||||
// If file doesn't exist, that's okay - we'll create it when needed
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return hm, nil
|
||||
}
|
||||
|
||||
// loadHistory loads the connection history from the JSON file
|
||||
func (hm *HistoryManager) loadHistory() error {
|
||||
data, err := os.ReadFile(hm.historyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, hm.history)
|
||||
}
|
||||
|
||||
// saveHistory saves the connection history to the JSON file
|
||||
func (hm *HistoryManager) saveHistory() error {
|
||||
// Ensure the directory exists
|
||||
dir := filepath.Dir(hm.historyPath)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(hm.history, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(hm.historyPath, data, 0600)
|
||||
}
|
||||
|
||||
// RecordConnection records a new connection for the specified host
|
||||
func (hm *HistoryManager) RecordConnection(hostName string) error {
|
||||
now := time.Now()
|
||||
|
||||
if conn, exists := hm.history.Connections[hostName]; exists {
|
||||
// Update existing connection
|
||||
conn.LastConnect = now
|
||||
conn.ConnectCount++
|
||||
hm.history.Connections[hostName] = conn
|
||||
} else {
|
||||
// Create new connection record
|
||||
hm.history.Connections[hostName] = ConnectionInfo{
|
||||
HostName: hostName,
|
||||
LastConnect: now,
|
||||
ConnectCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
return hm.saveHistory()
|
||||
}
|
||||
|
||||
// GetLastConnectionTime returns the last connection time for a host
|
||||
func (hm *HistoryManager) GetLastConnectionTime(hostName string) (time.Time, bool) {
|
||||
if conn, exists := hm.history.Connections[hostName]; exists {
|
||||
return conn.LastConnect, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// GetConnectionCount returns the total number of connections for a host
|
||||
func (hm *HistoryManager) GetConnectionCount(hostName string) int {
|
||||
if conn, exists := hm.history.Connections[hostName]; exists {
|
||||
return conn.ConnectCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SortHostsByLastUsed sorts hosts by their last connection time (most recent first)
|
||||
func (hm *HistoryManager) SortHostsByLastUsed(hosts []config.SSHHost) []config.SSHHost {
|
||||
sorted := make([]config.SSHHost, len(hosts))
|
||||
copy(sorted, hosts)
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name)
|
||||
timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name)
|
||||
|
||||
// If both have history, sort by most recent first
|
||||
if existsI && existsJ {
|
||||
return timeI.After(timeJ)
|
||||
}
|
||||
|
||||
// Hosts with history come before hosts without history
|
||||
if existsI && !existsJ {
|
||||
return true
|
||||
}
|
||||
if !existsI && existsJ {
|
||||
return false
|
||||
}
|
||||
|
||||
// If neither has history, sort alphabetically
|
||||
return sorted[i].Name < sorted[j].Name
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
// SortHostsByMostUsed sorts hosts by their connection count (most used first)
|
||||
func (hm *HistoryManager) SortHostsByMostUsed(hosts []config.SSHHost) []config.SSHHost {
|
||||
sorted := make([]config.SSHHost, len(hosts))
|
||||
copy(sorted, hosts)
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
countI := hm.GetConnectionCount(sorted[i].Name)
|
||||
countJ := hm.GetConnectionCount(sorted[j].Name)
|
||||
|
||||
// If counts are different, sort by count (highest first)
|
||||
if countI != countJ {
|
||||
return countI > countJ
|
||||
}
|
||||
|
||||
// If counts are equal, sort by most recent
|
||||
timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name)
|
||||
timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name)
|
||||
|
||||
if existsI && existsJ {
|
||||
return timeI.After(timeJ)
|
||||
}
|
||||
|
||||
// If neither has history, sort alphabetically
|
||||
return sorted[i].Name < sorted[j].Name
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
// CleanupOldEntries removes connection history for hosts that no longer exist
|
||||
func (hm *HistoryManager) CleanupOldEntries(currentHosts []config.SSHHost) error {
|
||||
// Create a set of current host names
|
||||
currentHostNames := make(map[string]bool)
|
||||
for _, host := range currentHosts {
|
||||
currentHostNames[host.Name] = true
|
||||
}
|
||||
|
||||
// Remove entries for hosts that no longer exist
|
||||
for hostName := range hm.history.Connections {
|
||||
if !currentHostNames[hostName] {
|
||||
delete(hm.history.Connections, hostName)
|
||||
}
|
||||
}
|
||||
|
||||
return hm.saveHistory()
|
||||
}
|
||||
|
||||
// GetAllConnectionsInfo returns all connection information sorted by last connection time
|
||||
func (hm *HistoryManager) GetAllConnectionsInfo() []ConnectionInfo {
|
||||
var connections []ConnectionInfo
|
||||
for _, conn := range hm.history.Connections {
|
||||
connections = append(connections, conn)
|
||||
}
|
||||
|
||||
sort.Slice(connections, func(i, j int) bool {
|
||||
return connections[i].LastConnect.After(connections[j].LastConnect)
|
||||
})
|
||||
|
||||
return connections
|
||||
}
|
||||
@ -10,21 +10,44 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFDF5")).
|
||||
Background(lipgloss.Color("#25A065")).
|
||||
Padding(0, 1)
|
||||
|
||||
fieldStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#04B575"))
|
||||
|
||||
errorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF0000"))
|
||||
|
||||
helpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
)
|
||||
|
||||
type addFormModel struct {
|
||||
inputs []textinput.Model
|
||||
focused int
|
||||
err string
|
||||
styles Styles
|
||||
success bool
|
||||
width int
|
||||
height int
|
||||
configFile string
|
||||
}
|
||||
|
||||
// NewAddForm creates a new add form model
|
||||
func NewAddForm(hostname string, styles Styles, width, height int, configFile string) *addFormModel {
|
||||
const (
|
||||
nameInput = iota
|
||||
hostnameInput
|
||||
userInput
|
||||
portInput
|
||||
identityInput
|
||||
proxyJumpInput
|
||||
optionsInput
|
||||
tagsInput
|
||||
)
|
||||
|
||||
func RunAddForm(hostname string) error {
|
||||
// Get current user for default
|
||||
currentUser, _ := user.Current()
|
||||
defaultUser := "root"
|
||||
@ -100,53 +123,28 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
|
||||
inputs[tagsInput].CharLimit = 200
|
||||
inputs[tagsInput].Width = 50
|
||||
|
||||
return &addFormModel{
|
||||
m := addFormModel{
|
||||
inputs: inputs,
|
||||
focused: nameInput,
|
||||
styles: styles,
|
||||
width: width,
|
||||
height: height,
|
||||
configFile: configFile,
|
||||
}
|
||||
|
||||
p := tea.NewProgram(&m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
const (
|
||||
nameInput = iota
|
||||
hostnameInput
|
||||
userInput
|
||||
portInput
|
||||
identityInput
|
||||
proxyJumpInput
|
||||
optionsInput
|
||||
tagsInput
|
||||
)
|
||||
|
||||
// Messages for communication with parent model
|
||||
type addFormSubmitMsg struct {
|
||||
hostname string
|
||||
err error
|
||||
}
|
||||
|
||||
type addFormCancelMsg struct{}
|
||||
|
||||
func (m *addFormModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
|
||||
func (m *addFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.styles = NewStyles(m.width)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg { return addFormCancelMsg{} }
|
||||
return m, tea.Quit
|
||||
|
||||
case "ctrl+enter":
|
||||
// Allow submission from any field with Ctrl+Enter
|
||||
@ -184,15 +182,14 @@ func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
case addFormSubmitMsg:
|
||||
case submitResult:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
} else {
|
||||
m.success = true
|
||||
m.err = ""
|
||||
// Don't quit here, let parent handle the success
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Update inputs
|
||||
@ -212,7 +209,7 @@ func (m *addFormModel) View() string {
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration"))
|
||||
b.WriteString(titleStyle.Render("Add SSH Host Configuration"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
fields := []string{
|
||||
@ -227,57 +224,27 @@ func (m *addFormModel) View() string {
|
||||
}
|
||||
|
||||
for i, field := range fields {
|
||||
b.WriteString(m.styles.FormField.Render(field))
|
||||
b.WriteString(fieldStyle.Render(field))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if m.err != "" {
|
||||
b.WriteString(m.styles.Error.Render("Error: " + m.err))
|
||||
b.WriteString(errorStyle.Render("Error: " + m.err))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||
b.WriteString(helpStyle.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
|
||||
b.WriteString(helpStyle.Render("* Required fields"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Standalone wrapper for add form
|
||||
type standaloneAddForm struct {
|
||||
*addFormModel
|
||||
}
|
||||
|
||||
func (m standaloneAddForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case addFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
m.addFormModel.err = msg.err.Error()
|
||||
} else {
|
||||
m.addFormModel.success = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case addFormCancelMsg:
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
newForm, cmd := m.addFormModel.Update(msg)
|
||||
m.addFormModel = newForm
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// RunAddForm provides backward compatibility for standalone add form
|
||||
func RunAddForm(hostname string, configFile string) error {
|
||||
styles := NewStyles(80)
|
||||
addForm := NewAddForm(hostname, styles, 80, 24, configFile)
|
||||
m := standaloneAddForm{addForm}
|
||||
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
type submitResult struct {
|
||||
hostname string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *addFormModel) submitForm() tea.Cmd {
|
||||
@ -302,7 +269,7 @@ func (m *addFormModel) submitForm() tea.Cmd {
|
||||
|
||||
// Validate all fields
|
||||
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
||||
return addFormSubmitMsg{err: err}
|
||||
return submitResult{err: err}
|
||||
}
|
||||
|
||||
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
|
||||
@ -329,12 +296,7 @@ func (m *addFormModel) submitForm() tea.Cmd {
|
||||
}
|
||||
|
||||
// Add to config
|
||||
var err error
|
||||
if m.configFile != "" {
|
||||
err = config.AddSSHHostToFile(host, m.configFile)
|
||||
} else {
|
||||
err = config.AddSSHHost(host)
|
||||
}
|
||||
return addFormSubmitMsg{hostname: name, err: err}
|
||||
err := config.AddSSHHost(host)
|
||||
return submitResult{hostname: name, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,23 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFDF5")).
|
||||
Background(lipgloss.Color("#25A065")).
|
||||
Padding(0, 1)
|
||||
|
||||
fieldStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#04B575"))
|
||||
|
||||
errorStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF0000"))
|
||||
|
||||
helpStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
)
|
||||
|
||||
type editFormModel struct {
|
||||
@ -14,27 +31,14 @@ type editFormModel struct {
|
||||
focused int
|
||||
err string
|
||||
success bool
|
||||
styles Styles
|
||||
originalName string
|
||||
width int
|
||||
height int
|
||||
configFile string
|
||||
}
|
||||
|
||||
// NewEditForm creates a new edit form model
|
||||
func NewEditForm(hostName string, styles Styles, width, height int, configFile string) (*editFormModel, error) {
|
||||
func RunEditForm(hostName string) error {
|
||||
// Get the existing host configuration
|
||||
var host *config.SSHHost
|
||||
var err error
|
||||
|
||||
if configFile != "" {
|
||||
host, err = config.GetSSHHostFromFile(hostName, configFile)
|
||||
} else {
|
||||
host, err = config.GetSSHHost(hostName)
|
||||
}
|
||||
|
||||
host, err := config.GetSSHHost(hostName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
inputs := make([]textinput.Model, 8)
|
||||
@ -98,43 +102,30 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
|
||||
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
|
||||
}
|
||||
|
||||
return &editFormModel{
|
||||
m := editFormModel{
|
||||
inputs: inputs,
|
||||
focused: nameInput,
|
||||
originalName: hostName,
|
||||
configFile: configFile,
|
||||
styles: styles,
|
||||
width: width,
|
||||
height: height,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Messages for communication with parent model
|
||||
type editFormSubmitMsg struct {
|
||||
hostname string
|
||||
err error
|
||||
// Open in separate window like add form
|
||||
p := tea.NewProgram(&m, tea.WithAltScreen())
|
||||
_, err = p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
type editFormCancelMsg struct{}
|
||||
|
||||
func (m *editFormModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m *editFormModel) Update(msg tea.Msg) (*editFormModel, tea.Cmd) {
|
||||
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.styles = NewStyles(m.width)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg { return editFormCancelMsg{} }
|
||||
return m, tea.Quit
|
||||
|
||||
case "ctrl+enter":
|
||||
// Allow submission from any field with Ctrl+Enter
|
||||
@ -172,15 +163,14 @@ func (m *editFormModel) Update(msg tea.Msg) (*editFormModel, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
case editFormSubmitMsg:
|
||||
case editResult:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
} else {
|
||||
m.success = true
|
||||
m.err = ""
|
||||
// Don't quit here, let parent handle the success
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Update inputs
|
||||
@ -200,7 +190,7 @@ func (m *editFormModel) View() string {
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
|
||||
b.WriteString(titleStyleEdit.Render("Edit SSH Host Configuration"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
fields := []string{
|
||||
@ -215,60 +205,27 @@ func (m *editFormModel) View() string {
|
||||
}
|
||||
|
||||
for i, field := range fields {
|
||||
b.WriteString(m.styles.FormField.Render(field))
|
||||
b.WriteString(fieldStyleEdit.Render(field))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if m.err != "" {
|
||||
b.WriteString(m.styles.Error.Render("Error: " + m.err))
|
||||
b.WriteString(errorStyleEdit.Render("Error: " + m.err))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||
b.WriteString(helpStyleEdit.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
|
||||
b.WriteString(helpStyleEdit.Render("* Required fields"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Standalone wrapper for edit form
|
||||
type standaloneEditForm struct {
|
||||
*editFormModel
|
||||
}
|
||||
|
||||
func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case editFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
m.editFormModel.err = msg.err.Error()
|
||||
} else {
|
||||
m.editFormModel.success = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case editFormCancelMsg:
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
newForm, cmd := m.editFormModel.Update(msg)
|
||||
m.editFormModel = newForm
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// RunEditForm provides backward compatibility for standalone edit form
|
||||
func RunEditForm(hostName string, configFile string) error {
|
||||
styles := NewStyles(80)
|
||||
editForm, err := NewEditForm(hostName, styles, 80, 24, configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := standaloneEditForm{editForm}
|
||||
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err = p.Run()
|
||||
return err
|
||||
type editResult struct {
|
||||
hostname string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *editFormModel) submitEditForm() tea.Cmd {
|
||||
@ -290,7 +247,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
|
||||
|
||||
// Validate all fields
|
||||
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
||||
return editFormSubmitMsg{err: err}
|
||||
return editResult{err: err}
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
@ -318,12 +275,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
|
||||
}
|
||||
|
||||
// Update the configuration
|
||||
var err error
|
||||
if m.configFile != "" {
|
||||
err = config.UpdateSSHHostInFile(m.originalName, host, m.configFile)
|
||||
} else {
|
||||
err = config.UpdateSSHHost(m.originalName, host)
|
||||
}
|
||||
return editFormSubmitMsg{hostname: name, err: err}
|
||||
err := config.UpdateSSHHost(m.originalName, host)
|
||||
return editResult{hostname: name, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// SortMode defines the available sorting modes
|
||||
type SortMode int
|
||||
|
||||
const (
|
||||
SortByName SortMode = iota
|
||||
SortByLastUsed
|
||||
)
|
||||
|
||||
func (s SortMode) String() string {
|
||||
switch s {
|
||||
case SortByName:
|
||||
return "Name (A-Z)"
|
||||
case SortByLastUsed:
|
||||
return "Last Login"
|
||||
default:
|
||||
return "Name (A-Z)"
|
||||
}
|
||||
}
|
||||
|
||||
// ViewMode defines the current view state
|
||||
type ViewMode int
|
||||
|
||||
const (
|
||||
ViewList ViewMode = iota
|
||||
ViewAdd
|
||||
ViewEdit
|
||||
)
|
||||
|
||||
// Model represents the state of the user interface
|
||||
type Model struct {
|
||||
table table.Model
|
||||
searchInput textinput.Model
|
||||
hosts []config.SSHHost
|
||||
filteredHosts []config.SSHHost
|
||||
searchMode bool
|
||||
deleteMode bool
|
||||
deleteHost string
|
||||
historyManager *history.HistoryManager
|
||||
sortMode SortMode
|
||||
configFile string // Path to the SSH config file
|
||||
|
||||
// View management
|
||||
viewMode ViewMode
|
||||
addForm *addFormModel
|
||||
editForm *editFormModel
|
||||
|
||||
// Terminal size and styles
|
||||
width int
|
||||
height int
|
||||
styles Styles
|
||||
ready bool
|
||||
}
|
||||
|
||||
// updateTableStyles updates the table header border color based on focus state
|
||||
func (m *Model) updateTableStyles() {
|
||||
s := table.DefaultStyles()
|
||||
s.Selected = m.styles.Selected
|
||||
|
||||
if m.searchMode {
|
||||
// When in search mode, use secondary color for table header
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(SecondaryColor)).
|
||||
BorderBottom(true).
|
||||
Bold(false)
|
||||
} else {
|
||||
// When table is focused, use primary color for table header
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(PrimaryColor)).
|
||||
BorderBottom(true).
|
||||
Bold(false)
|
||||
}
|
||||
|
||||
m.table.SetStyles(s)
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
)
|
||||
|
||||
// sortHosts sorts hosts according to the current sort mode
|
||||
func (m Model) sortHosts(hosts []config.SSHHost) []config.SSHHost {
|
||||
if m.historyManager == nil {
|
||||
return sortHostsByName(hosts)
|
||||
}
|
||||
|
||||
switch m.sortMode {
|
||||
case SortByLastUsed:
|
||||
return m.historyManager.SortHostsByLastUsed(hosts)
|
||||
case SortByName:
|
||||
fallthrough
|
||||
default:
|
||||
return sortHostsByName(hosts)
|
||||
}
|
||||
}
|
||||
|
||||
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
|
||||
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
|
||||
sorted := make([]config.SSHHost, len(hosts))
|
||||
copy(sorted, hosts)
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
// filterHosts filters hosts according to the search query (name or tags)
|
||||
func (m Model) filterHosts(query string) []config.SSHHost {
|
||||
var filtered []config.SSHHost
|
||||
|
||||
if query == "" {
|
||||
filtered = m.hosts
|
||||
} else {
|
||||
query = strings.ToLower(query)
|
||||
|
||||
for _, host := range m.hosts {
|
||||
// Check the hostname
|
||||
if strings.Contains(strings.ToLower(host.Name), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check the hostname
|
||||
if strings.Contains(strings.ToLower(host.Hostname), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check the tags
|
||||
for _, tag := range host.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), query) {
|
||||
filtered = append(filtered, host)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m.sortHosts(filtered)
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Theme colors
|
||||
var (
|
||||
// Primary interface color - easily modifiable
|
||||
PrimaryColor = "#00ADD8" // Official Go logo blue color
|
||||
|
||||
// Secondary colors
|
||||
SecondaryColor = "240" // Gray
|
||||
ErrorColor = "1" // Red
|
||||
SuccessColor = "36" // Green (for reference if needed)
|
||||
)
|
||||
|
||||
// Styles struct centralizes all lipgloss styles
|
||||
type Styles struct {
|
||||
// Layout
|
||||
App lipgloss.Style
|
||||
Header lipgloss.Style
|
||||
|
||||
// Search styles
|
||||
SearchFocused lipgloss.Style
|
||||
SearchUnfocused lipgloss.Style
|
||||
|
||||
// Table styles
|
||||
TableFocused lipgloss.Style
|
||||
TableUnfocused lipgloss.Style
|
||||
Selected lipgloss.Style
|
||||
|
||||
// Info and help styles
|
||||
SortInfo lipgloss.Style
|
||||
HelpText lipgloss.Style
|
||||
|
||||
// Error and confirmation styles
|
||||
Error lipgloss.Style
|
||||
|
||||
// Form styles (for add/edit forms)
|
||||
FormTitle lipgloss.Style
|
||||
FormField lipgloss.Style
|
||||
FormHelp lipgloss.Style
|
||||
}
|
||||
|
||||
// NewStyles creates a new Styles struct with the given terminal width
|
||||
func NewStyles(width int) Styles {
|
||||
return Styles{
|
||||
// Main app container
|
||||
App: lipgloss.NewStyle().
|
||||
Padding(1),
|
||||
|
||||
// Header style
|
||||
Header: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(PrimaryColor)).
|
||||
Bold(true).
|
||||
Align(lipgloss.Center),
|
||||
|
||||
// Search styles
|
||||
SearchFocused: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(PrimaryColor)).
|
||||
Padding(0, 1),
|
||||
|
||||
SearchUnfocused: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(SecondaryColor)).
|
||||
Padding(0, 1),
|
||||
|
||||
// Table styles
|
||||
TableFocused: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(PrimaryColor)),
|
||||
|
||||
TableUnfocused: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
// Style for selected items
|
||||
Selected: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color(PrimaryColor)).
|
||||
Bold(false),
|
||||
|
||||
// Info styles
|
||||
SortInfo: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
HelpText: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(SecondaryColor)).
|
||||
MarginTop(1),
|
||||
|
||||
// Error style
|
||||
Error: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(ErrorColor)).
|
||||
Padding(1, 2),
|
||||
|
||||
// Form styles
|
||||
FormTitle: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFDF5")).
|
||||
Background(lipgloss.Color(PrimaryColor)).
|
||||
Padding(0, 1),
|
||||
|
||||
FormField: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(PrimaryColor)),
|
||||
|
||||
FormHelp: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262")),
|
||||
}
|
||||
}
|
||||
|
||||
// Application ASCII title
|
||||
const asciiTitle = `
|
||||
_____ _____ __ __ _____
|
||||
| __| __| | | |
|
||||
|__ |__ | | | | |
|
||||
|_____|_____|__|__|_|_|_|
|
||||
`
|
||||
@ -1,248 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
)
|
||||
|
||||
// calculateNameColumnWidth calculates the optimal width for the Name column
|
||||
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
|
||||
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
||||
maxLength := 8 // Minimum width to accommodate the "Name" header
|
||||
|
||||
for _, host := range hosts {
|
||||
if len(host.Name) > maxLength {
|
||||
maxLength = len(host.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Limit the maximum width to avoid extremely large columns
|
||||
if maxLength > 40 {
|
||||
maxLength = 40
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// calculateTagsColumnWidth calculates the optimal width for the Tags column
|
||||
// based on the longest tag string, with a minimum of 8 and maximum of 40 characters
|
||||
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
|
||||
maxLength := 8 // Minimum width to accommodate the "Tags" header
|
||||
|
||||
for _, host := range hosts {
|
||||
// Format tags exactly as they appear in the table
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add the # prefix to each tag and join them with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
if len(tagsStr) > maxLength {
|
||||
maxLength = len(tagsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Limit the maximum width to avoid extremely large columns
|
||||
if maxLength > 40 {
|
||||
maxLength = 40
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// calculateLastLoginColumnWidth calculates the optimal width for the Last Login column
|
||||
// based on the longest time format, with a minimum of 12 and maximum of 20 characters
|
||||
func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *history.HistoryManager) int {
|
||||
maxLength := 12 // Minimum width to accommodate the "Last Login" header
|
||||
|
||||
if historyManager != nil {
|
||||
for _, host := range hosts {
|
||||
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
|
||||
timeStr := formatTimeAgo(lastConnect)
|
||||
if len(timeStr) > maxLength {
|
||||
maxLength = len(timeStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Limit the maximum width to avoid extremely large columns
|
||||
if maxLength > 20 {
|
||||
maxLength = 20
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// updateTableRows updates the table with filtered hosts
|
||||
func (m *Model) updateTableRows() {
|
||||
var rows []table.Row
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
for _, host := range hostsToShow {
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add the # prefix to each tag and join them with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
// Format last login information
|
||||
var lastLoginStr string
|
||||
if m.historyManager != nil {
|
||||
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
||||
lastLoginStr = formatTimeAgo(lastConnect)
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
tagsStr,
|
||||
lastLoginStr,
|
||||
})
|
||||
}
|
||||
|
||||
m.table.SetRows(rows)
|
||||
|
||||
// Update table height and columns based on current terminal size
|
||||
m.updateTableHeight()
|
||||
m.updateTableColumns()
|
||||
}
|
||||
|
||||
// updateTableHeight dynamically adjusts table height based on terminal size
|
||||
func (m *Model) updateTableHeight() {
|
||||
if !m.ready {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate dynamic table height based on terminal size
|
||||
// Layout breakdown:
|
||||
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||
// - Search bar: 1 line
|
||||
// - Sort info: 1 line
|
||||
// - Help text: 2 lines (multi-line text)
|
||||
// - App margins/spacing: 2 lines
|
||||
// Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace
|
||||
reservedHeight := 7 // Réduction agressive pour tester
|
||||
availableHeight := m.height - reservedHeight
|
||||
hostCount := len(m.table.Rows())
|
||||
|
||||
// Minimum height should be at least 5 rows for usability
|
||||
minTableHeight := 6 // 1 header + 5 data rows
|
||||
maxTableHeight := availableHeight
|
||||
if maxTableHeight < minTableHeight {
|
||||
maxTableHeight = minTableHeight
|
||||
}
|
||||
|
||||
tableHeight := 1 // header
|
||||
dataRowsNeeded := hostCount
|
||||
maxDataRows := maxTableHeight - 1 // subtract 1 for header
|
||||
|
||||
if dataRowsNeeded <= maxDataRows {
|
||||
// We have enough space for all hosts
|
||||
tableHeight += dataRowsNeeded
|
||||
} else {
|
||||
// We need to limit to available space
|
||||
tableHeight += maxDataRows
|
||||
}
|
||||
|
||||
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
|
||||
tableHeight += 1
|
||||
|
||||
// Update table height
|
||||
m.table.SetHeight(tableHeight)
|
||||
}
|
||||
|
||||
// updateTableColumns dynamically adjusts table column widths based on terminal size
|
||||
func (m *Model) updateTableColumns() {
|
||||
if !m.ready {
|
||||
return
|
||||
}
|
||||
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
// Calculate base column widths
|
||||
nameWidth := calculateNameColumnWidth(hostsToShow)
|
||||
tagsWidth := calculateTagsColumnWidth(hostsToShow)
|
||||
lastLoginWidth := calculateLastLoginColumnWidth(hostsToShow, m.historyManager)
|
||||
|
||||
// Fixed column widths
|
||||
hostnameWidth := 25
|
||||
userWidth := 12
|
||||
portWidth := 6
|
||||
|
||||
// Calculate total width needed for all columns
|
||||
totalFixedWidth := hostnameWidth + userWidth + portWidth
|
||||
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
|
||||
totalWidth := totalFixedWidth + totalVariableWidth
|
||||
|
||||
// Available width (accounting for table borders and padding)
|
||||
availableWidth := m.width - 4 // 4 chars for borders and padding
|
||||
|
||||
// If the table is too wide, scale down the variable columns proportionally
|
||||
if totalWidth > availableWidth {
|
||||
excessWidth := totalWidth - availableWidth
|
||||
variableColumnsWidth := totalVariableWidth
|
||||
|
||||
if variableColumnsWidth > 0 {
|
||||
// Reduce variable columns proportionally
|
||||
nameReduction := (excessWidth * nameWidth) / variableColumnsWidth
|
||||
tagsReduction := (excessWidth * tagsWidth) / variableColumnsWidth
|
||||
lastLoginReduction := excessWidth - nameReduction - tagsReduction
|
||||
|
||||
nameWidth = max(8, nameWidth-nameReduction)
|
||||
tagsWidth = max(8, tagsWidth-tagsReduction)
|
||||
lastLoginWidth = max(10, lastLoginWidth-lastLoginReduction)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new columns with updated widths
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: nameWidth},
|
||||
{Title: "Hostname", Width: hostnameWidth},
|
||||
{Title: "User", Width: userWidth},
|
||||
{Title: "Port", Width: portWidth},
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
{Title: "Last Login", Width: lastLoginWidth},
|
||||
}
|
||||
|
||||
m.table.SetColumns(columns)
|
||||
}
|
||||
|
||||
// max returns the maximum of two integers
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@ -2,10 +2,11 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
@ -13,36 +14,258 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// NewModel creates a new TUI model with the given SSH hosts
|
||||
func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
// Initialize the history manager
|
||||
historyManager, err := history.NewHistoryManager()
|
||||
var baseStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240"))
|
||||
|
||||
var searchStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("36")).
|
||||
Padding(0, 1)
|
||||
|
||||
type Model struct {
|
||||
table table.Model
|
||||
searchInput textinput.Model
|
||||
hosts []config.SSHHost
|
||||
filteredHosts []config.SSHHost
|
||||
searchMode bool
|
||||
deleteMode bool
|
||||
deleteHost string
|
||||
exitAction string
|
||||
exitHostName string
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// Handle key messages
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+c":
|
||||
if m.searchMode {
|
||||
// Exit search mode
|
||||
m.searchMode = false
|
||||
m.searchInput.Blur()
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
if m.deleteMode {
|
||||
// Exit delete mode
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "q":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
return m, tea.Quit
|
||||
}
|
||||
case "/", "ctrl+f":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Enter search mode
|
||||
m.searchMode = true
|
||||
m.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "enter":
|
||||
if m.searchMode {
|
||||
// Exit search mode and focus table
|
||||
m.searchMode = false
|
||||
m.searchInput.Blur()
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else if m.deleteMode {
|
||||
// Confirm deletion
|
||||
err := config.DeleteSSHHost(m.deleteHost)
|
||||
if err != nil {
|
||||
// Log the error but continue without the history functionality
|
||||
fmt.Printf("Warning: Could not initialize history manager: %v\n", err)
|
||||
historyManager = nil
|
||||
// Could show error message here
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
// Refresh the host list
|
||||
hosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
// Could show error message here
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
m.hosts = sortHostsByName(hosts)
|
||||
m.filteredHosts = m.hosts
|
||||
m.updateTableRows()
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else {
|
||||
// Connect to selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // Host name is in the first column
|
||||
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
|
||||
return tea.Quit()
|
||||
})
|
||||
}
|
||||
}
|
||||
case "e":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Edit selected host using dedicated edit form
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // Host name is in the first column
|
||||
// Store the edit action and exit
|
||||
m.exitAction = "edit"
|
||||
m.exitHostName = hostName
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
case "a":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Add new host using dedicated add form
|
||||
m.exitAction = "add"
|
||||
return m, tea.Quit
|
||||
}
|
||||
case "d":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Delete selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // Host name is in the first column
|
||||
m.deleteMode = true
|
||||
m.deleteHost = hostName
|
||||
m.table.Blur()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial styles (will be updated on first WindowSizeMsg)
|
||||
styles := NewStyles(80) // Default width
|
||||
|
||||
// Create the model with default sorting by name
|
||||
m := Model{
|
||||
hosts: hosts,
|
||||
historyManager: historyManager,
|
||||
sortMode: SortByName,
|
||||
configFile: configFile,
|
||||
styles: styles,
|
||||
width: 80,
|
||||
height: 24,
|
||||
ready: false,
|
||||
viewMode: ViewList,
|
||||
// Update components based on mode
|
||||
if m.searchMode {
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
// Filter hosts when search input changes
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.hosts
|
||||
}
|
||||
m.updateTableRows()
|
||||
} else {
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
}
|
||||
|
||||
// Sort hosts according to the default sort mode
|
||||
sortedHosts := m.sortHosts(hosts)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Create the search input
|
||||
func (m Model) View() string {
|
||||
if m.deleteMode {
|
||||
return m.renderDeleteConfirmation()
|
||||
}
|
||||
|
||||
var view strings.Builder
|
||||
|
||||
// Add search bar
|
||||
searchPrompt := "Search (/ to search, ESC to exit search): "
|
||||
if m.searchMode {
|
||||
view.WriteString(searchStyle.Render(searchPrompt+m.searchInput.View()) + "\n\n")
|
||||
}
|
||||
|
||||
// Add table
|
||||
view.WriteString(baseStyle.Render(m.table.View()))
|
||||
|
||||
// Add help text
|
||||
if !m.searchMode {
|
||||
view.WriteString("\nUse ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • (q)uit")
|
||||
} else {
|
||||
view.WriteString("\nType to filter hosts by name or tag • Enter to select • ESC to exit search")
|
||||
}
|
||||
|
||||
return view.String()
|
||||
}
|
||||
|
||||
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
|
||||
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
|
||||
sorted := make([]config.SSHHost, len(hosts))
|
||||
copy(sorted, hosts)
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
// calculateNameColumnWidth calculates the optimal width for the Name column
|
||||
// based on the longest host name, with a minimum of 8 and maximum of 40 characters
|
||||
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
||||
maxLength := 8 // Minimum width to accommodate the "Name" header
|
||||
|
||||
for _, host := range hosts {
|
||||
if len(host.Name) > maxLength {
|
||||
maxLength = len(host.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Cap the maximum width to avoid extremely wide columns
|
||||
if maxLength > 40 {
|
||||
maxLength = 40
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// calculateTagsColumnWidth calculates the optimal width for the Tags column
|
||||
// based on the longest tags string, with a minimum of 8 and maximum of 50 characters
|
||||
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
|
||||
maxLength := 8 // Minimum width to accommodate the "Tags" header
|
||||
|
||||
for _, host := range hosts {
|
||||
// Format tags exactly the same way they appear in the table
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add # prefix to each tag and join with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
if len(tagsStr) > maxLength {
|
||||
maxLength = len(tagsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Cap the maximum width to avoid extremely wide columns
|
||||
if maxLength > 50 {
|
||||
maxLength = 50
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// NewModel creates a new TUI model with the given SSH hosts
|
||||
func NewModel(hosts []config.SSHHost) Model {
|
||||
// Sort hosts alphabetically by name
|
||||
sortedHosts := sortHostsByName(hosts)
|
||||
|
||||
// Create search input
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search hosts or tags..."
|
||||
ti.CharLimit = 50
|
||||
@ -54,9 +277,6 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
// Calculate optimal width for the Tags column
|
||||
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
||||
|
||||
// Calculate optimal width for the Last Login column
|
||||
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
|
||||
|
||||
// Create table columns
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: nameWidth},
|
||||
@ -64,7 +284,6 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
{Title: "User", Width: 12},
|
||||
{Title: "Port", Width: 6},
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
{Title: "Last Login", Width: lastLoginWidth},
|
||||
}
|
||||
|
||||
// Convert hosts to table rows
|
||||
@ -73,7 +292,7 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add the # prefix to each tag and join them with spaces
|
||||
// Add # prefix to each tag and join with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
@ -81,67 +300,197 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
// Format last login information
|
||||
var lastLoginStr string
|
||||
if historyManager != nil {
|
||||
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
|
||||
lastLoginStr = formatTimeAgo(lastConnect)
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
tagsStr,
|
||||
lastLoginStr,
|
||||
})
|
||||
}
|
||||
|
||||
// Create the table with initial height (will be updated on first WindowSizeMsg)
|
||||
// Déterminer la hauteur du tableau : 1 (header) + nombre de hosts (max 10)
|
||||
hostCount := len(rows)
|
||||
tableHeight := 1 // header
|
||||
if hostCount < 10 {
|
||||
tableHeight += hostCount
|
||||
} else {
|
||||
tableHeight += 10
|
||||
}
|
||||
|
||||
// Create table
|
||||
t := table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithRows(rows),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(10), // Initial height, will be recalculated dynamically
|
||||
table.WithHeight(tableHeight),
|
||||
)
|
||||
|
||||
// Style the table
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(SecondaryColor)).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderBottom(true).
|
||||
Bold(false)
|
||||
s.Selected = m.styles.Selected
|
||||
|
||||
s.Selected = s.Selected.
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(false)
|
||||
t.SetStyles(s)
|
||||
|
||||
// Update the model with the table and other properties
|
||||
m.table = t
|
||||
m.searchInput = ti
|
||||
m.filteredHosts = sortedHosts
|
||||
|
||||
// Initialize table styles based on initial focus state
|
||||
m.updateTableStyles()
|
||||
|
||||
// The table height will be properly set on the first WindowSizeMsg
|
||||
// when m.ready becomes true and actual terminal dimensions are known
|
||||
|
||||
return m
|
||||
return Model{
|
||||
table: t,
|
||||
searchInput: ti,
|
||||
hosts: sortedHosts,
|
||||
filteredHosts: sortedHosts,
|
||||
searchMode: false,
|
||||
}
|
||||
}
|
||||
|
||||
// RunInteractiveMode starts the interactive TUI interface
|
||||
func RunInteractiveMode(hosts []config.SSHHost, configFile string) error {
|
||||
m := NewModel(hosts, configFile)
|
||||
// RunInteractiveMode starts the interactive TUI
|
||||
func RunInteractiveMode(hosts []config.SSHHost) error {
|
||||
for {
|
||||
m := NewModel(hosts)
|
||||
|
||||
// Start the application in alt screen mode for clean output
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
// Start the application in terminal (without alt screen)
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error running TUI: %w", err)
|
||||
}
|
||||
|
||||
// Check if the final model indicates an action
|
||||
if model, ok := finalModel.(Model); ok {
|
||||
if model.exitAction == "edit" && model.exitHostName != "" {
|
||||
// Launch the dedicated edit form (opens in separate window)
|
||||
if err := RunEditForm(model.exitHostName); err != nil {
|
||||
fmt.Printf("Error editing host: %v\n", err)
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear screen before returning to TUI
|
||||
fmt.Print("\033[2J\033[H")
|
||||
|
||||
// Refresh the hosts list after editing
|
||||
refreshedHosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error refreshing hosts after edit: %w", err)
|
||||
}
|
||||
hosts = refreshedHosts
|
||||
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
} else if model.exitAction == "add" {
|
||||
// Launch the dedicated add form (opens in separate window)
|
||||
if err := RunAddForm(""); err != nil {
|
||||
fmt.Printf("Error adding host: %v\n", err)
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear screen before returning to TUI
|
||||
fmt.Print("\033[2J\033[H")
|
||||
|
||||
// Refresh the hosts list after adding
|
||||
refreshedHosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error refreshing hosts after add: %w", err)
|
||||
}
|
||||
hosts = refreshedHosts
|
||||
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If no special command, exit normally
|
||||
break
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterHosts filters hosts based on search query (name or tags)
|
||||
func (m Model) filterHosts(query string) []config.SSHHost {
|
||||
if query == "" {
|
||||
return sortHostsByName(m.hosts)
|
||||
}
|
||||
|
||||
query = strings.ToLower(query)
|
||||
var filtered []config.SSHHost
|
||||
|
||||
for _, host := range m.hosts {
|
||||
// Check host name
|
||||
if strings.Contains(strings.ToLower(host.Name), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check hostname
|
||||
if strings.Contains(strings.ToLower(host.Hostname), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check tags
|
||||
for _, tag := range host.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), query) {
|
||||
filtered = append(filtered, host)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortHostsByName(filtered)
|
||||
}
|
||||
|
||||
// updateTableRows updates the table with filtered hosts
|
||||
func (m *Model) updateTableRows() {
|
||||
var rows []table.Row
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
// Sort hosts alphabetically by name
|
||||
sortedHosts := sortHostsByName(hostsToShow)
|
||||
|
||||
for _, host := range sortedHosts {
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add # prefix to each tag and join with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
tagsStr,
|
||||
})
|
||||
}
|
||||
|
||||
m.table.SetRows(rows)
|
||||
}
|
||||
|
||||
// enterEditMode initializes edit mode for a specific host
|
||||
// renderDeleteConfirmation renders the delete confirmation dialog
|
||||
func (m Model) renderDeleteConfirmation() string {
|
||||
var view strings.Builder
|
||||
|
||||
view.WriteString(lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("1")). // Red border
|
||||
Padding(1, 2).
|
||||
Render(fmt.Sprintf("⚠️ Delete SSH Host\n\nAre you sure you want to delete host '%s'?\n\nThis action cannot be undone.\n\nPress Enter to confirm or Esc to cancel", m.deleteHost)))
|
||||
|
||||
return view.String()
|
||||
}
|
||||
|
||||
@ -1,386 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"sshm/internal/config"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Init initializes the model
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
textinput.Blink,
|
||||
// Ajoute ici d'autres tea.Cmd si tu veux charger des données, démarrer un spinner, etc.
|
||||
)
|
||||
}
|
||||
|
||||
// Update handles model updates
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// Handle different message types
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
// Update terminal size and recalculate styles
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.styles = NewStyles(m.width)
|
||||
m.ready = true
|
||||
|
||||
// Update table height and columns based on new window size
|
||||
m.updateTableHeight()
|
||||
m.updateTableColumns()
|
||||
|
||||
// Update sub-forms if they exist
|
||||
if m.addForm != nil {
|
||||
m.addForm.width = m.width
|
||||
m.addForm.height = m.height
|
||||
m.addForm.styles = m.styles
|
||||
}
|
||||
if m.editForm != nil {
|
||||
m.editForm.width = m.width
|
||||
m.editForm.height = m.height
|
||||
m.editForm.styles = m.styles
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case addFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
// Show error in form
|
||||
if m.addForm != nil {
|
||||
m.addForm.err = msg.err.Error()
|
||||
}
|
||||
return m, nil
|
||||
} else {
|
||||
// Success: refresh hosts and return to list view
|
||||
var hosts []config.SSHHost
|
||||
var err error
|
||||
|
||||
if m.configFile != "" {
|
||||
hosts, err = config.ParseSSHConfigFile(m.configFile)
|
||||
} else {
|
||||
hosts, err = config.ParseSSHConfig()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.hosts = m.sortHosts(hosts)
|
||||
|
||||
// Reapply search filter if there is one active
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.hosts
|
||||
}
|
||||
|
||||
m.updateTableRows()
|
||||
m.viewMode = ViewList
|
||||
m.addForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case addFormCancelMsg:
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.addForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case editFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
// Show error in form
|
||||
if m.editForm != nil {
|
||||
m.editForm.err = msg.err.Error()
|
||||
}
|
||||
return m, nil
|
||||
} else {
|
||||
// Success: refresh hosts and return to list view
|
||||
var hosts []config.SSHHost
|
||||
var err error
|
||||
|
||||
if m.configFile != "" {
|
||||
hosts, err = config.ParseSSHConfigFile(m.configFile)
|
||||
} else {
|
||||
hosts, err = config.ParseSSHConfig()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.hosts = m.sortHosts(hosts)
|
||||
|
||||
// Reapply search filter if there is one active
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.hosts
|
||||
}
|
||||
|
||||
m.updateTableRows()
|
||||
m.viewMode = ViewList
|
||||
m.editForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case editFormCancelMsg:
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.editForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Handle view-specific key presses
|
||||
switch m.viewMode {
|
||||
case ViewAdd:
|
||||
if m.addForm != nil {
|
||||
var newForm *addFormModel
|
||||
newForm, cmd = m.addForm.Update(msg)
|
||||
m.addForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewEdit:
|
||||
if m.editForm != nil {
|
||||
var newForm *editFormModel
|
||||
newForm, cmd = m.editForm.Update(msg)
|
||||
m.editForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewList:
|
||||
// Handle list view keys
|
||||
return m.handleListViewKeys(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+c":
|
||||
if m.deleteMode {
|
||||
// Exit delete mode
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "q":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
return m, tea.Quit
|
||||
}
|
||||
case "/", "ctrl+f":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Enter search mode
|
||||
m.searchMode = true
|
||||
m.updateTableStyles()
|
||||
m.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "tab":
|
||||
if !m.deleteMode {
|
||||
// Switch focus between search input and table
|
||||
if m.searchMode {
|
||||
// Switch from search to table
|
||||
m.searchMode = false
|
||||
m.updateTableStyles()
|
||||
m.searchInput.Blur()
|
||||
m.table.Focus()
|
||||
} else {
|
||||
// Switch from table to search
|
||||
m.searchMode = true
|
||||
m.updateTableStyles()
|
||||
m.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case "enter":
|
||||
if m.searchMode {
|
||||
// Validate search and return to table mode to allow commands
|
||||
m.searchMode = false
|
||||
m.updateTableStyles()
|
||||
m.searchInput.Blur()
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else if m.deleteMode {
|
||||
// Confirm deletion
|
||||
var err error
|
||||
if m.configFile != "" {
|
||||
err = config.DeleteSSHHostFromFile(m.deleteHost, m.configFile)
|
||||
} else {
|
||||
err = config.DeleteSSHHost(m.deleteHost)
|
||||
}
|
||||
if err != nil {
|
||||
// Could display an error message here
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
// Refresh the hosts list
|
||||
var hosts []config.SSHHost
|
||||
var parseErr error
|
||||
|
||||
if m.configFile != "" {
|
||||
hosts, parseErr = config.ParseSSHConfigFile(m.configFile)
|
||||
} else {
|
||||
hosts, parseErr = config.ParseSSHConfig()
|
||||
}
|
||||
|
||||
if parseErr != nil {
|
||||
// Could display an error message here
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
m.hosts = m.sortHosts(hosts)
|
||||
|
||||
// Reapply search filter if there is one active
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.hosts
|
||||
}
|
||||
|
||||
m.updateTableRows()
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else {
|
||||
// Connect to the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
|
||||
// Record the connection in history
|
||||
if m.historyManager != nil {
|
||||
err := m.historyManager.RecordConnection(hostName)
|
||||
if err != nil {
|
||||
// Log the error but don't prevent the connection
|
||||
fmt.Printf("Warning: Could not record connection history: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the SSH command with the appropriate config file
|
||||
var sshCmd *exec.Cmd
|
||||
if m.configFile != "" {
|
||||
sshCmd = exec.Command("ssh", "-F", m.configFile, hostName)
|
||||
} else {
|
||||
sshCmd = exec.Command("ssh", hostName)
|
||||
}
|
||||
|
||||
return m, tea.ExecProcess(sshCmd, func(err error) tea.Msg {
|
||||
return tea.Quit()
|
||||
})
|
||||
}
|
||||
}
|
||||
case "e":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Edit the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Handle error - could show in UI
|
||||
return m, nil
|
||||
}
|
||||
m.editForm = editForm
|
||||
m.viewMode = ViewEdit
|
||||
return m, textinput.Blink
|
||||
}
|
||||
}
|
||||
case "a":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Add a new host
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||
m.viewMode = ViewAdd
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "d":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Delete the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
m.deleteMode = true
|
||||
m.deleteHost = hostName
|
||||
m.table.Blur()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
case "s":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Cycle through sort modes (only 2 modes now)
|
||||
m.sortMode = (m.sortMode + 1) % 2
|
||||
// Re-apply the current filter with the new sort mode
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.sortHosts(m.hosts)
|
||||
}
|
||||
m.updateTableRows()
|
||||
return m, nil
|
||||
}
|
||||
case "r":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Switch to sort by recent (last used)
|
||||
m.sortMode = SortByLastUsed
|
||||
// Re-apply the current filter with the new sort mode
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.sortHosts(m.hosts)
|
||||
}
|
||||
m.updateTableRows()
|
||||
return m, nil
|
||||
}
|
||||
case "n":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Switch to sort by name
|
||||
m.sortMode = SortByName
|
||||
// Re-apply the current filter with the new sort mode
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.sortHosts(m.hosts)
|
||||
}
|
||||
m.updateTableRows()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update the appropriate component based on mode
|
||||
if m.searchMode {
|
||||
oldValue := m.searchInput.Value()
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
// Update filtered hosts only if the search value has changed
|
||||
if m.searchInput.Value() != oldValue {
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.sortHosts(m.hosts)
|
||||
}
|
||||
m.updateTableRows()
|
||||
}
|
||||
} else {
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// formatTimeAgo formats a time into a readable "X time ago" string
|
||||
func formatTimeAgo(t time.Time) string {
|
||||
now := time.Now()
|
||||
duration := now.Sub(t)
|
||||
|
||||
switch {
|
||||
case duration < time.Minute:
|
||||
seconds := int(duration.Seconds())
|
||||
if seconds <= 1 {
|
||||
return "1 second ago"
|
||||
}
|
||||
return fmt.Sprintf("%d seconds ago", seconds)
|
||||
case duration < time.Hour:
|
||||
minutes := int(duration.Minutes())
|
||||
if minutes == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", minutes)
|
||||
case duration < 24*time.Hour:
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
case duration < 7*24*time.Hour:
|
||||
days := int(duration.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "1 day ago"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
case duration < 30*24*time.Hour:
|
||||
weeks := int(duration.Hours() / (24 * 7))
|
||||
if weeks == 1 {
|
||||
return "1 week ago"
|
||||
}
|
||||
return fmt.Sprintf("%d weeks ago", weeks)
|
||||
case duration < 365*24*time.Hour:
|
||||
months := int(duration.Hours() / (24 * 30))
|
||||
if months == 1 {
|
||||
return "1 month ago"
|
||||
}
|
||||
return fmt.Sprintf("%d months ago", months)
|
||||
default:
|
||||
years := int(duration.Hours() / (24 * 365))
|
||||
if years == 1 {
|
||||
return "1 year ago"
|
||||
}
|
||||
return fmt.Sprintf("%d years ago", years)
|
||||
}
|
||||
}
|
||||
@ -1,147 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// View renders the complete user interface
|
||||
func (m Model) View() string {
|
||||
if !m.ready {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
// Handle different view modes
|
||||
switch m.viewMode {
|
||||
case ViewAdd:
|
||||
if m.addForm != nil {
|
||||
return m.addForm.View()
|
||||
}
|
||||
case ViewEdit:
|
||||
if m.editForm != nil {
|
||||
return m.editForm.View()
|
||||
}
|
||||
case ViewList:
|
||||
return m.renderListView()
|
||||
}
|
||||
|
||||
return m.renderListView()
|
||||
}
|
||||
|
||||
// renderListView renders the main list interface
|
||||
func (m Model) renderListView() string {
|
||||
// Build the interface components
|
||||
components := []string{}
|
||||
|
||||
// Add the ASCII title
|
||||
components = append(components, m.styles.Header.Render(asciiTitle))
|
||||
|
||||
// Add the search bar with the appropriate style based on focus
|
||||
searchPrompt := "Search (/ to focus): "
|
||||
if m.searchMode {
|
||||
components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View()))
|
||||
} else {
|
||||
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
|
||||
}
|
||||
|
||||
// Add the sort mode indicator
|
||||
sortInfo := fmt.Sprintf(" Sort: %s", m.sortMode.String())
|
||||
components = append(components, m.styles.SortInfo.Render(sortInfo))
|
||||
|
||||
// Add the table with the appropriate style based on focus
|
||||
if m.searchMode {
|
||||
// The table is not focused, use the unfocused style
|
||||
components = append(components, m.styles.TableUnfocused.Render(m.table.View()))
|
||||
} else {
|
||||
// The table is focused, use the focused style with the primary color
|
||||
components = append(components, m.styles.TableFocused.Render(m.table.View()))
|
||||
}
|
||||
|
||||
// Add the help text
|
||||
var helpText string
|
||||
if !m.searchMode {
|
||||
helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
|
||||
} else {
|
||||
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
|
||||
}
|
||||
components = append(components, m.styles.HelpText.Render(helpText))
|
||||
|
||||
// Join all components vertically with appropriate spacing
|
||||
mainView := m.styles.App.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
components...,
|
||||
),
|
||||
)
|
||||
|
||||
// If in delete mode, overlay the confirmation dialog
|
||||
if m.deleteMode {
|
||||
// Combine the main view with the confirmation dialog overlay
|
||||
confirmation := m.renderDeleteConfirmation()
|
||||
|
||||
// Center the confirmation dialog on the screen
|
||||
centeredConfirmation := lipgloss.Place(
|
||||
m.width,
|
||||
m.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
confirmation,
|
||||
)
|
||||
|
||||
return centeredConfirmation
|
||||
}
|
||||
|
||||
return mainView
|
||||
}
|
||||
|
||||
// renderDeleteConfirmation renders a clean delete confirmation dialog
|
||||
func (m Model) renderDeleteConfirmation() string {
|
||||
// Remove emojis (uncertain width depending on terminal) to stabilize the frame
|
||||
title := "DELETE SSH HOST"
|
||||
question := fmt.Sprintf("Are you sure you want to delete host '%s'?", m.deleteHost)
|
||||
action := "This action cannot be undone."
|
||||
help := "Enter: confirm • Esc: cancel"
|
||||
|
||||
// Individual styles (do not affect width via internal centering)
|
||||
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196"))
|
||||
questionStyle := lipgloss.NewStyle()
|
||||
actionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
|
||||
lines := []string{
|
||||
titleStyle.Render(title),
|
||||
"",
|
||||
questionStyle.Render(question),
|
||||
"",
|
||||
actionStyle.Render(action),
|
||||
"",
|
||||
helpStyle.Render(help),
|
||||
}
|
||||
|
||||
// Compute the real maximum width (ANSI-safe via lipgloss.Width)
|
||||
maxw := 0
|
||||
for _, ln := range lines {
|
||||
w := lipgloss.Width(ln)
|
||||
if w > maxw {
|
||||
maxw = w
|
||||
}
|
||||
}
|
||||
// Minimal width for aesthetics
|
||||
if maxw < 40 {
|
||||
maxw = 40
|
||||
}
|
||||
|
||||
// Build the raw text block (without centering) then apply the container style
|
||||
raw := strings.Join(lines, "\n")
|
||||
|
||||
// Container style: wider horizontal padding, stable border
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("196")).
|
||||
PaddingTop(1).PaddingBottom(1).PaddingLeft(2).PaddingRight(2).
|
||||
Width(maxw + 4) // +4 = internal margin (2 spaces of left/right padding)
|
||||
|
||||
return box.Render(raw)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user