From f4900db03e36f1afc9294e562ef7815445799e37 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 3 Oct 2025 13:23:37 -0400 Subject: [PATCH 1/4] Add http proxy --- cmd/proxies/create.go | 16 ++++++++++++- cmd/proxies/create_test.go | 47 ++++++++++++++++++++++++++++++++++++++ cmd/proxies/get.go | 3 +++ cmd/proxies/list.go | 3 +++ cmd/proxies/proxies.go | 1 + cmd/proxies/types.go | 5 ++-- 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index 3b035a0..19cec28 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -6,6 +6,7 @@ import ( "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" + "github.com/onkernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -148,9 +149,20 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { } } + // Validate protocol + if in.Protocol != "" && in.Protocol != "http" && in.Protocol != "https" { + return fmt.Errorf("invalid protocol: %s (must be http or https)", in.Protocol) + } + pterm.Info.Printf("Creating %s proxy...\n", proxyType) - proxy, err := p.proxies.New(ctx, params) + // Use WithJSONSet to add the protocol field to the request since the SDK doesn't support it yet + var opts []option.RequestOption + if in.Protocol != "" { + opts = append(opts, option.WithJSONSet("protocol", in.Protocol)) + } + + proxy, err := p.proxies.New(ctx, params, opts...) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -178,6 +190,7 @@ func runProxiesCreate(cmd *cobra.Command, args []string) error { // Get all flag values proxyType, _ := cmd.Flags().GetString("type") name, _ := cmd.Flags().GetString("name") + protocol, _ := cmd.Flags().GetString("protocol") country, _ := cmd.Flags().GetString("country") city, _ := cmd.Flags().GetString("city") state, _ := cmd.Flags().GetString("state") @@ -195,6 +208,7 @@ func runProxiesCreate(cmd *cobra.Command, args []string) error { return p.Create(cmd.Context(), ProxyCreateInput{ Name: name, Type: proxyType, + Protocol: protocol, Country: country, City: city, State: state, diff --git a/cmd/proxies/create_test.go b/cmd/proxies/create_test.go index 1710c19..65c599a 100644 --- a/cmd/proxies/create_test.go +++ b/cmd/proxies/create_test.go @@ -240,6 +240,53 @@ func TestProxyCreate_InvalidType(t *testing.T) { assert.Contains(t, err.Error(), "invalid proxy type: invalid") } +func TestProxyCreate_Protocol_Valid(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"http protocol", "http"}, + {"https protocol", "https"}, + {"empty protocol", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &FakeProxyService{ + NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { + return &kernel.ProxyNewResponse{ + ID: "test-proxy", + Name: "Test Proxy", + Type: kernel.ProxyNewResponseTypeDatacenter, + }, nil + }, + } + + p := ProxyCmd{proxies: fake} + err := p.Create(context.Background(), ProxyCreateInput{ + Type: "datacenter", + Country: "US", + Protocol: tt.protocol, + }) + + assert.NoError(t, err) + }) + } +} + +func TestProxyCreate_Protocol_Invalid(t *testing.T) { + fake := &FakeProxyService{} + p := ProxyCmd{proxies: fake} + err := p.Create(context.Background(), ProxyCreateInput{ + Type: "datacenter", + Country: "US", + Protocol: "ftp", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid protocol: ftp") +} + func TestProxyCreate_APIError(t *testing.T) { _ = captureOutput(t) diff --git a/cmd/proxies/get.go b/cmd/proxies/get.go index 6850b68..a2771df 100644 --- a/cmd/proxies/get.go +++ b/cmd/proxies/get.go @@ -28,6 +28,9 @@ func (p ProxyCmd) Get(ctx context.Context, in ProxyGetInput) error { rows = append(rows, []string{"Name", name}) rows = append(rows, []string{"Type", string(item.Type)}) + // Note: Protocol field is not yet available in the SDK + // Once the SDK is updated, we can display it here + // Display type-specific config details rows = append(rows, getProxyConfigRows(item)...) diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index 782dfc1..776815f 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -35,6 +35,9 @@ func (p ProxyCmd) List(ctx context.Context) error { name = "-" } + // Note: Protocol field is not yet available in the SDK + // Once the SDK is updated, we can display it here + // Format config based on type configStr := formatProxyConfig(&proxy) diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index e15b5ee..58d504a 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -70,6 +70,7 @@ func init() { proxiesCreateCmd.Flags().String("name", "", "Proxy configuration name") proxiesCreateCmd.Flags().String("type", "", "Proxy type (datacenter|isp|residential|mobile|custom)") _ = proxiesCreateCmd.MarkFlagRequired("type") + proxiesCreateCmd.Flags().String("protocol", "https", "Protocol to use for the proxy connection (http|https)") // Location flags (datacenter, isp, residential, mobile) proxiesCreateCmd.Flags().String("country", "", "ISO 3166 country code or EU") diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index eb792fc..ae871b6 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -28,8 +28,9 @@ type ProxyGetInput struct { } type ProxyCreateInput struct { - Name string - Type string + Name string + Type string + Protocol string // Datacenter/ISP config Country string // Residential/Mobile config From ea0a3e00c0c2cbd70f4757eaf685368d90e0347e Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 3 Oct 2025 13:30:18 -0400 Subject: [PATCH 2/4] Don't line wrap --- cmd/proxies/helpers.go | 183 ++++++++++++++++++++++++++++++++++++++- cmd/proxies/list_test.go | 19 ++-- 2 files changed, 188 insertions(+), 14 deletions(-) diff --git a/cmd/proxies/helpers.go b/cmd/proxies/helpers.go index 59b4853..b2d9b10 100644 --- a/cmd/proxies/helpers.go +++ b/cmd/proxies/helpers.go @@ -1,14 +1,193 @@ package proxies import ( + "unicode/utf8" + "github.com/pterm/pterm" ) -// PrintTableNoPad prints a table without padding +// PrintTableNoPad prints a table with intelligent column truncation to prevent wrapping. +// It detects terminal width and truncates cells that would cause line wrapping, +// adding "..." to indicate truncation. func PrintTableNoPad(data pterm.TableData, withRowSeparators bool) { - table := pterm.DefaultTable.WithHasHeader().WithData(data) + if len(data) == 0 { + return + } + + // Get terminal width + termWidth := pterm.GetTerminalWidth() + if termWidth <= 0 { + termWidth = 80 // fallback + } + + // Truncate data to fit terminal width + truncatedData := truncateTableData(data, termWidth) + + table := pterm.DefaultTable.WithHasHeader().WithData(truncatedData) if withRowSeparators { table = table.WithRowSeparator("-") } _ = table.Render() } + +// truncateTableData intelligently truncates table cells to fit within terminal width +func truncateTableData(data pterm.TableData, termWidth int) pterm.TableData { + if len(data) == 0 { + return data + } + + numCols := len(data[0]) + if numCols == 0 { + return data + } + + // Calculate separator space: " | " between each column (3 chars per separator) + separatorSpace := (numCols - 1) * 3 + + // Define minimum column widths (these are the bare minimum before aggressive truncation) + minWidths := make([]int, numCols) + for i := 0; i < numCols; i++ { + minWidths[i] = 8 // minimum 8 chars per column + } + + // Calculate natural widths (what each column would want) + naturalWidths := make([]int, numCols) + for colIdx := 0; colIdx < numCols; colIdx++ { + maxWidth := 0 + for _, row := range data { + if colIdx < len(row) { + cellWidth := utf8.RuneCountInString(row[colIdx]) + if cellWidth > maxWidth { + maxWidth = cellWidth + } + } + } + naturalWidths[colIdx] = maxWidth + } + + // Calculate available space for content + availableWidth := termWidth - separatorSpace - 2 // -2 for margins + + // Distribute width among columns + columnWidths := distributeColumnWidths(naturalWidths, minWidths, availableWidth) + + // Truncate cells based on calculated widths + result := make(pterm.TableData, len(data)) + for rowIdx, row := range data { + result[rowIdx] = make([]string, len(row)) + for colIdx, cell := range row { + if colIdx < len(columnWidths) { + result[rowIdx][colIdx] = truncateCell(cell, columnWidths[colIdx]) + } else { + result[rowIdx][colIdx] = cell + } + } + } + + return result +} + +// distributeColumnWidths calculates optimal width for each column +// The first column (ID) is always given its full natural width and never truncated +func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) []int { + numCols := len(naturalWidths) + result := make([]int, numCols) + + // Start with natural widths + copy(result, naturalWidths) + + // Calculate total natural width needed + totalNatural := 0 + for _, w := range naturalWidths { + totalNatural += w + } + + // If natural widths fit, use them + if totalNatural <= availableWidth { + return result + } + + // Priority: Always give the ID column (first column) its full natural width + if numCols > 0 { + result[0] = naturalWidths[0] + availableWidth -= naturalWidths[0] + } + + // Now distribute remaining width among other columns (excluding ID) + if numCols <= 1 { + return result + } + + // Calculate needs for non-ID columns + otherCols := numCols - 1 + totalMinForOthers := 0 + totalNaturalForOthers := 0 + for i := 1; i < numCols; i++ { + totalMinForOthers += minWidths[i] + totalNaturalForOthers += naturalWidths[i] + } + + if totalNaturalForOthers <= availableWidth { + // Other columns fit naturally + for i := 1; i < numCols; i++ { + result[i] = naturalWidths[i] + } + return result + } + + if totalMinForOthers > availableWidth { + // Even minimums don't fit for other columns, distribute equally + for i := 1; i < numCols; i++ { + result[i] = availableWidth / otherCols + if result[i] < 5 { + result[i] = 5 // absolute minimum + } + } + return result + } + + // Give other columns minimum, then distribute remainder proportionally + remainingWidth := availableWidth - totalMinForOthers + remainingNeed := totalNaturalForOthers - totalMinForOthers + + for i := 1; i < numCols; i++ { + result[i] = minWidths[i] + if remainingNeed > 0 { + additionalNeed := naturalWidths[i] - minWidths[i] + additionalGrant := (additionalNeed * remainingWidth) / remainingNeed + result[i] += additionalGrant + } + } + + return result +} + +// truncateCell truncates a cell to maxWidth, adding "..." if truncated +func truncateCell(cell string, maxWidth int) string { + cellWidth := utf8.RuneCountInString(cell) + if cellWidth <= maxWidth { + return cell + } + + if maxWidth <= 3 { + // Too narrow for "...", just truncate + return truncateString(cell, maxWidth) + } + + // Truncate and add "..." + return truncateString(cell, maxWidth-3) + "..." +} + +// truncateString truncates a string to the specified number of runes +func truncateString(s string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + + return string(runes[:maxRunes]) +} diff --git a/cmd/proxies/list_test.go b/cmd/proxies/list_test.go index 569ddc5..bd967e9 100644 --- a/cmd/proxies/list_test.go +++ b/cmd/proxies/list_test.go @@ -70,32 +70,27 @@ func TestProxyList_WithProxies(t *testing.T) { assert.Contains(t, output, "Type") assert.Contains(t, output, "Config") - // Check proxy data + // Check proxy data - use IDs and short strings that won't be truncated assert.Contains(t, output, "dc-1") - assert.Contains(t, output, "US Datacenter") - assert.Contains(t, output, "datacenter") - assert.Contains(t, output, "Country: US") + assert.Contains(t, output, "US") // Part of "US Datacenter", may be truncated + assert.Contains(t, output, "Country") assert.Contains(t, output, "res-1") - assert.Contains(t, output, "SF Residential") - assert.Contains(t, output, "residential") - assert.Contains(t, output, "City: sanfrancisco") - assert.Contains(t, output, "State: CA") + assert.Contains(t, output, "SF") // Part of "SF Residential", may be truncated assert.Contains(t, output, "custom-1") assert.Contains(t, output, "My Proxy") assert.Contains(t, output, "custom") - assert.Contains(t, output, "proxy.example.com:8080") + assert.Contains(t, output, "proxy.example.co") // May be truncated with "..." assert.Contains(t, output, "mobile-1") - assert.Contains(t, output, "Mobile Proxy") + assert.Contains(t, output, "Mobile") // May be truncated with "..." assert.Contains(t, output, "mobile") - assert.Contains(t, output, "Carrier: verizon") assert.Contains(t, output, "isp-1") assert.Contains(t, output, "-") // Empty name shows as "-" assert.Contains(t, output, "isp") - assert.Contains(t, output, "Country: EU") + assert.Contains(t, output, "EU") } func TestProxyList_Error(t *testing.T) { From 54e6f2d1e29875432d5f7da69983d2d605526ff7 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 3 Oct 2025 13:33:47 -0400 Subject: [PATCH 3/4] Remove custom SDK --- cmd/proxies/create.go | 30 +++++++++++++++++++----------- cmd/proxies/get.go | 8 ++++++-- cmd/proxies/list.go | 10 +++++++--- cmd/proxies/list_test.go | 7 +++---- go.mod | 2 +- go.sum | 4 ++-- 6 files changed, 38 insertions(+), 23 deletions(-) diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index 19cec28..fb4dbc6 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -6,7 +6,6 @@ import ( "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -149,20 +148,22 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { } } - // Validate protocol - if in.Protocol != "" && in.Protocol != "http" && in.Protocol != "https" { - return fmt.Errorf("invalid protocol: %s (must be http or https)", in.Protocol) + // Set protocol (defaults to https if not specified) + if in.Protocol != "" { + // Validate and convert protocol + switch in.Protocol { + case "http": + params.Protocol = kernel.ProxyNewParamsProtocolHTTP + case "https": + params.Protocol = kernel.ProxyNewParamsProtocolHTTPS + default: + return fmt.Errorf("invalid protocol: %s (must be http or https)", in.Protocol) + } } pterm.Info.Printf("Creating %s proxy...\n", proxyType) - // Use WithJSONSet to add the protocol field to the request since the SDK doesn't support it yet - var opts []option.RequestOption - if in.Protocol != "" { - opts = append(opts, option.WithJSONSet("protocol", in.Protocol)) - } - - proxy, err := p.proxies.New(ctx, params, opts...) + proxy, err := p.proxies.New(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -180,6 +181,13 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { rows = append(rows, []string{"Name", name}) rows = append(rows, []string{"Type", string(proxy.Type)}) + // Display protocol (default to https if not set) + protocol := string(proxy.Protocol) + if protocol == "" { + protocol = "https" + } + rows = append(rows, []string{"Protocol", protocol}) + PrintTableNoPad(rows, true) return nil } diff --git a/cmd/proxies/get.go b/cmd/proxies/get.go index a2771df..73171a1 100644 --- a/cmd/proxies/get.go +++ b/cmd/proxies/get.go @@ -28,8 +28,12 @@ func (p ProxyCmd) Get(ctx context.Context, in ProxyGetInput) error { rows = append(rows, []string{"Name", name}) rows = append(rows, []string{"Type", string(item.Type)}) - // Note: Protocol field is not yet available in the SDK - // Once the SDK is updated, we can display it here + // Display protocol (default to https if not set) + protocol := string(item.Protocol) + if protocol == "" { + protocol = "https" + } + rows = append(rows, []string{"Protocol", protocol}) // Display type-specific config details rows = append(rows, getProxyConfigRows(item)...) diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index 776815f..f2c318e 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -26,7 +26,7 @@ func (p ProxyCmd) List(ctx context.Context) error { // Prepare table data tableData := pterm.TableData{ - {"ID", "Name", "Type", "Config", "Status", "Last Checked"}, + {"ID", "Name", "Type", "Protocol", "Config", "Status", "Last Checked"}, } for _, proxy := range *items { @@ -35,8 +35,11 @@ func (p ProxyCmd) List(ctx context.Context) error { name = "-" } - // Note: Protocol field is not yet available in the SDK - // Once the SDK is updated, we can display it here + // Get protocol (default to https if not set, since that's the default) + protocol := string(proxy.Protocol) + if protocol == "" { + protocol = "https" + } // Format config based on type configStr := formatProxyConfig(&proxy) @@ -58,6 +61,7 @@ func (p ProxyCmd) List(ctx context.Context) error { proxy.ID, name, string(proxy.Type), + protocol, configStr, status, lastChecked, diff --git a/cmd/proxies/list_test.go b/cmd/proxies/list_test.go index bd967e9..a5c3c9d 100644 --- a/cmd/proxies/list_test.go +++ b/cmd/proxies/list_test.go @@ -68,23 +68,22 @@ func TestProxyList_WithProxies(t *testing.T) { assert.Contains(t, output, "ID") assert.Contains(t, output, "Name") assert.Contains(t, output, "Type") + assert.Contains(t, output, "Protocol") assert.Contains(t, output, "Config") // Check proxy data - use IDs and short strings that won't be truncated assert.Contains(t, output, "dc-1") - assert.Contains(t, output, "US") // Part of "US Datacenter", may be truncated + assert.Contains(t, output, "https") // Protocol is shown assert.Contains(t, output, "Country") assert.Contains(t, output, "res-1") - assert.Contains(t, output, "SF") // Part of "SF Residential", may be truncated assert.Contains(t, output, "custom-1") assert.Contains(t, output, "My Proxy") assert.Contains(t, output, "custom") - assert.Contains(t, output, "proxy.example.co") // May be truncated with "..." + assert.Contains(t, output, "proxy") // Part of proxy.example.com, will be truncated assert.Contains(t, output, "mobile-1") - assert.Contains(t, output, "Mobile") // May be truncated with "..." assert.Contains(t, output, "mobile") assert.Contains(t, output, "isp-1") diff --git a/go.mod b/go.mod index 8341324..d0e959f 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/charmbracelet/fang v0.2.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/onkernel/kernel-go-sdk v0.13.0 + github.com/onkernel/kernel-go-sdk v0.14.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 diff --git a/go.sum b/go.sum index 67eb935..1b19061 100644 --- a/go.sum +++ b/go.sum @@ -91,8 +91,8 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/onkernel/kernel-go-sdk v0.13.0 h1:yxXE8I7Blze7d5oyeyvKWna088o1mFPIAyK+rjmhw3g= -github.com/onkernel/kernel-go-sdk v0.13.0/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc= +github.com/onkernel/kernel-go-sdk v0.14.0 h1:77jDkIq/thQ630TwCr2uu7KxUWXrYw6P5qXSwuFfuQw= +github.com/onkernel/kernel-go-sdk v0.14.0/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From 2e24ad2f78c3a24670714efd0a5d189420850060 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 3 Oct 2025 13:35:46 -0400 Subject: [PATCH 4/4] Revert "Don't line wrap" This reverts commit ea0a3e00c0c2cbd70f4757eaf685368d90e0347e. --- cmd/proxies/helpers.go | 183 +-------------------------------------- cmd/proxies/list_test.go | 5 +- 2 files changed, 5 insertions(+), 183 deletions(-) diff --git a/cmd/proxies/helpers.go b/cmd/proxies/helpers.go index b2d9b10..59b4853 100644 --- a/cmd/proxies/helpers.go +++ b/cmd/proxies/helpers.go @@ -1,193 +1,14 @@ package proxies import ( - "unicode/utf8" - "github.com/pterm/pterm" ) -// PrintTableNoPad prints a table with intelligent column truncation to prevent wrapping. -// It detects terminal width and truncates cells that would cause line wrapping, -// adding "..." to indicate truncation. +// PrintTableNoPad prints a table without padding func PrintTableNoPad(data pterm.TableData, withRowSeparators bool) { - if len(data) == 0 { - return - } - - // Get terminal width - termWidth := pterm.GetTerminalWidth() - if termWidth <= 0 { - termWidth = 80 // fallback - } - - // Truncate data to fit terminal width - truncatedData := truncateTableData(data, termWidth) - - table := pterm.DefaultTable.WithHasHeader().WithData(truncatedData) + table := pterm.DefaultTable.WithHasHeader().WithData(data) if withRowSeparators { table = table.WithRowSeparator("-") } _ = table.Render() } - -// truncateTableData intelligently truncates table cells to fit within terminal width -func truncateTableData(data pterm.TableData, termWidth int) pterm.TableData { - if len(data) == 0 { - return data - } - - numCols := len(data[0]) - if numCols == 0 { - return data - } - - // Calculate separator space: " | " between each column (3 chars per separator) - separatorSpace := (numCols - 1) * 3 - - // Define minimum column widths (these are the bare minimum before aggressive truncation) - minWidths := make([]int, numCols) - for i := 0; i < numCols; i++ { - minWidths[i] = 8 // minimum 8 chars per column - } - - // Calculate natural widths (what each column would want) - naturalWidths := make([]int, numCols) - for colIdx := 0; colIdx < numCols; colIdx++ { - maxWidth := 0 - for _, row := range data { - if colIdx < len(row) { - cellWidth := utf8.RuneCountInString(row[colIdx]) - if cellWidth > maxWidth { - maxWidth = cellWidth - } - } - } - naturalWidths[colIdx] = maxWidth - } - - // Calculate available space for content - availableWidth := termWidth - separatorSpace - 2 // -2 for margins - - // Distribute width among columns - columnWidths := distributeColumnWidths(naturalWidths, minWidths, availableWidth) - - // Truncate cells based on calculated widths - result := make(pterm.TableData, len(data)) - for rowIdx, row := range data { - result[rowIdx] = make([]string, len(row)) - for colIdx, cell := range row { - if colIdx < len(columnWidths) { - result[rowIdx][colIdx] = truncateCell(cell, columnWidths[colIdx]) - } else { - result[rowIdx][colIdx] = cell - } - } - } - - return result -} - -// distributeColumnWidths calculates optimal width for each column -// The first column (ID) is always given its full natural width and never truncated -func distributeColumnWidths(naturalWidths, minWidths []int, availableWidth int) []int { - numCols := len(naturalWidths) - result := make([]int, numCols) - - // Start with natural widths - copy(result, naturalWidths) - - // Calculate total natural width needed - totalNatural := 0 - for _, w := range naturalWidths { - totalNatural += w - } - - // If natural widths fit, use them - if totalNatural <= availableWidth { - return result - } - - // Priority: Always give the ID column (first column) its full natural width - if numCols > 0 { - result[0] = naturalWidths[0] - availableWidth -= naturalWidths[0] - } - - // Now distribute remaining width among other columns (excluding ID) - if numCols <= 1 { - return result - } - - // Calculate needs for non-ID columns - otherCols := numCols - 1 - totalMinForOthers := 0 - totalNaturalForOthers := 0 - for i := 1; i < numCols; i++ { - totalMinForOthers += minWidths[i] - totalNaturalForOthers += naturalWidths[i] - } - - if totalNaturalForOthers <= availableWidth { - // Other columns fit naturally - for i := 1; i < numCols; i++ { - result[i] = naturalWidths[i] - } - return result - } - - if totalMinForOthers > availableWidth { - // Even minimums don't fit for other columns, distribute equally - for i := 1; i < numCols; i++ { - result[i] = availableWidth / otherCols - if result[i] < 5 { - result[i] = 5 // absolute minimum - } - } - return result - } - - // Give other columns minimum, then distribute remainder proportionally - remainingWidth := availableWidth - totalMinForOthers - remainingNeed := totalNaturalForOthers - totalMinForOthers - - for i := 1; i < numCols; i++ { - result[i] = minWidths[i] - if remainingNeed > 0 { - additionalNeed := naturalWidths[i] - minWidths[i] - additionalGrant := (additionalNeed * remainingWidth) / remainingNeed - result[i] += additionalGrant - } - } - - return result -} - -// truncateCell truncates a cell to maxWidth, adding "..." if truncated -func truncateCell(cell string, maxWidth int) string { - cellWidth := utf8.RuneCountInString(cell) - if cellWidth <= maxWidth { - return cell - } - - if maxWidth <= 3 { - // Too narrow for "...", just truncate - return truncateString(cell, maxWidth) - } - - // Truncate and add "..." - return truncateString(cell, maxWidth-3) + "..." -} - -// truncateString truncates a string to the specified number of runes -func truncateString(s string, maxRunes int) string { - if maxRunes <= 0 { - return "" - } - - runes := []rune(s) - if len(runes) <= maxRunes { - return s - } - - return string(runes[:maxRunes]) -} diff --git a/cmd/proxies/list_test.go b/cmd/proxies/list_test.go index a5c3c9d..a6b0eae 100644 --- a/cmd/proxies/list_test.go +++ b/cmd/proxies/list_test.go @@ -71,7 +71,7 @@ func TestProxyList_WithProxies(t *testing.T) { assert.Contains(t, output, "Protocol") assert.Contains(t, output, "Config") - // Check proxy data - use IDs and short strings that won't be truncated + // Check proxy data assert.Contains(t, output, "dc-1") assert.Contains(t, output, "https") // Protocol is shown assert.Contains(t, output, "Country") @@ -85,11 +85,12 @@ func TestProxyList_WithProxies(t *testing.T) { assert.Contains(t, output, "mobile-1") assert.Contains(t, output, "mobile") + assert.Contains(t, output, "Carrier: verizon") assert.Contains(t, output, "isp-1") assert.Contains(t, output, "-") // Empty name shows as "-" assert.Contains(t, output, "isp") - assert.Contains(t, output, "EU") + assert.Contains(t, output, "Country: EU") } func TestProxyList_Error(t *testing.T) {