Compare commits

...

4 Commits

7 changed files with 221 additions and 27 deletions

View File

@ -16,7 +16,11 @@
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">
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="600" />
<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>
</p>
## ✨ Features
@ -28,6 +32,7 @@ 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
@ -85,6 +90,14 @@ 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
@ -102,15 +115,24 @@ 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
@ -118,6 +140,19 @@ 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.
@ -222,24 +257,44 @@ go build -o sshm .
```
sshm/
├── cmd/ # CLI commands (Cobra)
├── 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
│ ├── edit.go # Edit host command
│ └── search.go # Search command
├── internal/
│ ├── config/ # SSH configuration management
│ │ └── ssh.go # Config parsing and manipulation
│ ├── ui/ # Terminal UI components
│ │ ├── tui.go # Main TUI interface
│ │ ├── add_form.go # Add host form
│ │ └── edit_form.go# Edit host form
│ ├── 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
│ └── 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/workflows/ # CI/CD pipelines
└── build.yml # Multi-platform builds
├── .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
```
### Dependencies

View File

@ -21,9 +21,14 @@ var configFile string
var rootCmd = &cobra.Command{
Use: "sshm",
Short: "SSH Manager - A modern SSH connection manager",
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.`,
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.`,
Version: version,
Run: func(cmd *cobra.Command, args []string) {
// If no arguments provided, run interactive mode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 797 KiB

View File

@ -130,4 +130,119 @@ func (m *Model) updateTableRows() {
}
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
}

View File

@ -99,21 +99,12 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
})
}
// Determine table height: 1 (header) + number of hosts (max 10)
hostCount := len(rows)
tableHeight := 1 // header
if hostCount < 10 {
tableHeight += hostCount
} else {
tableHeight += 10
}
// Create the table
// Create the table with initial height (will be updated on first WindowSizeMsg)
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
table.WithHeight(10), // Initial height, will be recalculated dynamically
)
// Style the table
@ -135,6 +126,9 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
// 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
}

View File

@ -31,6 +31,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@ -66,7 +70,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
m.hosts = m.sortHosts(hosts)
m.filteredHosts = m.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
@ -103,7 +114,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
m.hosts = m.sortHosts(hosts)
m.filteredHosts = m.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
@ -230,7 +248,14 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
m.hosts = m.sortHosts(hosts)
m.filteredHosts = m.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 = ""

View File

@ -39,7 +39,7 @@ func (m Model) renderListView() string {
components = append(components, m.styles.Header.Render(asciiTitle))
// Add the search bar with the appropriate style based on focus
searchPrompt := "Search (/ to focus, Tab to switch): "
searchPrompt := "Search (/ to focus): "
if m.searchMode {
components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View()))
} else {