diff --git a/cli/azd/cmd/config.go b/cli/azd/cmd/config.go index e26d222417e..55cf9637108 100644 --- a/cli/azd/cmd/config.go +++ b/cli/azd/cmd/config.go @@ -639,39 +639,50 @@ func (a *configOptionsAction) Run(ctx context.Context) (*actions.ActionResult, e }) } - columns := []output.Column{ + columns := []output.PrettyColumn{ { - Heading: "Key", - ValueTemplate: "{{.Key}}", + Column: output.Column{Heading: "KEY", ValueTemplate: "{{.Key}}"}, + Priority: 1, }, { - Heading: "Description", - ValueTemplate: "{{.Description}}", + Column: output.Column{Heading: "DESCRIPTION", ValueTemplate: "{{.Description}}"}, + Priority: 1, }, { - Heading: "Type", - ValueTemplate: "{{.Type}}", + Column: output.Column{Heading: "TYPE", ValueTemplate: "{{.Type}}"}, + Priority: 2, }, { - Heading: "Current Value", - ValueTemplate: "{{.CurrentValue}}", + Column: output.Column{ + Heading: "CURRENT VALUE", + ValueTemplate: "{{.CurrentValue}}", + }, + Priority: 2, }, { - Heading: "Allowed Values", - ValueTemplate: "{{.AllowedValues}}", + Column: output.Column{ + Heading: "ALLOWED VALUES", + ValueTemplate: "{{.AllowedValues}}", + }, + Priority: 3, }, { - Heading: "Environment Variable", - ValueTemplate: "{{.EnvVar}}", + Column: output.Column{ + Heading: "ENVIRONMENT VARIABLE", + ValueTemplate: "{{.EnvVar}}", + }, + Priority: 3, }, { - Heading: "Example", - ValueTemplate: "{{.Example}}", + Column: output.Column{Heading: "EXAMPLE", ValueTemplate: "{{.Example}}"}, + Priority: 3, }, } - err = a.formatter.Format(rows, a.writer, output.TableFormatterOptions{ - Columns: columns, + prettyFormatter := &output.PrettyTableFormatter{} + err = prettyFormatter.Format(rows, a.writer, output.PrettyTableFormatterOptions{ + Columns: columns, + CardGroupColumn: "KEY", }) if err != nil { return nil, fmt.Errorf("failed formatting config options: %w", err) diff --git a/cli/azd/cmd/config_options_test.go b/cli/azd/cmd/config_options_test.go index 266e6692687..c254664139c 100644 --- a/cli/azd/cmd/config_options_test.go +++ b/cli/azd/cmd/config_options_test.go @@ -118,8 +118,8 @@ func TestConfigOptionsAction_Table(t *testing.T) { require.NoError(t, err) outputStr := buf.String() - require.Contains(t, outputStr, "Key") - require.Contains(t, outputStr, "Description") + require.Contains(t, outputStr, "KEY") + require.Contains(t, outputStr, "DESCRIPTION") require.Contains(t, outputStr, "defaults.subscription") require.Contains(t, outputStr, "alpha.all") require.Contains(t, outputStr, "auth.useAzCliAuth") diff --git a/cli/azd/cmd/copilot.go b/cli/azd/cmd/copilot.go index 3e4979b8316..4fccc91cce1 100644 --- a/cli/azd/cmd/copilot.go +++ b/cli/azd/cmd/copilot.go @@ -288,23 +288,34 @@ func (a *copilotConsentListAction) Run(ctx context.Context) (*actions.ActionResu // Convert rules to display format type ruleDisplay struct { - Target string `json:"target"` - Context string `json:"context"` - Action string `json:"action"` - Permission string `json:"permission"` - Scope string `json:"scope"` - GrantedAt string `json:"grantedAt"` + Target string `json:"target"` + Context string `json:"context"` + Action string `json:"action"` + Permission string `json:"permission"` + PermissionSymbol string `json:"-"` + Scope string `json:"scope"` + GrantedAt string `json:"grantedAt"` } var displayRules []ruleDisplay for _, rule := range rules { + perm := string(rule.Permission) + symbol := "?" + switch perm { + case "allow": + symbol = "✓" + case "deny": + symbol = "✗" + } + displayRules = append(displayRules, ruleDisplay{ - Target: string(rule.Target), - Context: string(rule.Operation), - Action: string(rule.Action), - Permission: string(rule.Permission), - Scope: string(rule.Scope), - GrantedAt: rule.GrantedAt.Format("2006-01-02 15:04:05"), + Target: string(rule.Target), + Context: string(rule.Operation), + Action: string(rule.Action), + Permission: perm, + PermissionSymbol: symbol, + Scope: string(rule.Scope), + GrantedAt: rule.GrantedAt.Format("2006-01-02 15:04:05"), }) } @@ -314,35 +325,39 @@ func (a *copilotConsentListAction) Run(ctx context.Context) (*actions.ActionResu // Use table formatter for better output if a.formatter.Kind() == output.TableFormat { - columns := []output.Column{ + prettyFormatter := &output.PrettyTableFormatter{} + columns := []output.PrettyColumn{ { - Heading: "Target", - ValueTemplate: "{{.Target}}", + Column: output.Column{Heading: "TARGET", ValueTemplate: "{{.Target}}"}, + Priority: 1, }, { - Heading: "Context", - ValueTemplate: "{{.Context}}", + Column: output.Column{Heading: "PERMISSION", ValueTemplate: "{{.Permission}}"}, + Priority: 1, + ShortValueTemplate: "{{.PermissionSymbol}}", + ColorFunc: consentPermissionColor, }, { - Heading: "Action", - ValueTemplate: "{{.Action}}", + Column: output.Column{Heading: "SCOPE", ValueTemplate: "{{.Scope}}"}, + Priority: 2, }, { - Heading: "Permission", - ValueTemplate: "{{.Permission}}", + Column: output.Column{Heading: "CONTEXT", ValueTemplate: "{{.Context}}"}, + Priority: 2, }, { - Heading: "Scope", - ValueTemplate: "{{.Scope}}", + Column: output.Column{Heading: "ACTION", ValueTemplate: "{{.Action}}"}, + Priority: 3, }, { - Heading: "Granted At", - ValueTemplate: "{{.GrantedAt}}", + Column: output.Column{Heading: "GRANTED AT", ValueTemplate: "{{.GrantedAt}}"}, + Priority: 3, }, } - return nil, a.formatter.Format(displayRules, a.writer, output.TableFormatterOptions{ - Columns: columns, + return nil, prettyFormatter.Format(displayRules, a.writer, output.PrettyTableFormatterOptions{ + Columns: columns, + CardGroupColumn: "SCOPE", }) } @@ -630,3 +645,17 @@ func formatConsentRuleDescription(rule consent.ConsentRule) string { string(rule.Permission), ) } + +// consentPermissionColor applies color formatting based on permission value. +func consentPermissionColor(s string) string { + switch s { + case "allow", "✓": + return output.WithSuccessFormat(s) + case "deny", "✗": + return output.WithErrorFormat(s) + case "prompt", "?": + return output.WithWarningFormat(s) + default: + return output.WithGrayFormat(s) + } +} diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 4c63250a9fc..035a25112c9 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -1705,23 +1705,25 @@ func (a *extensionSourceListAction) Run(ctx context.Context) (*actions.ActionRes } if a.formatter.Kind() == output.TableFormat { - columns := []output.Column{ + prettyFormatter := &output.PrettyTableFormatter{} + columns := []output.PrettyColumn{ { - Heading: "Name", - ValueTemplate: "{{.Name}}", + Column: output.Column{Heading: "NAME", ValueTemplate: "{{.Name}}"}, + Priority: 1, }, { - Heading: "Type", - ValueTemplate: "{{.Type}}", + Column: output.Column{Heading: "TYPE", ValueTemplate: "{{.Type}}"}, + Priority: 1, }, { - Heading: "Location", - ValueTemplate: "{{.Location}}", + Column: output.Column{Heading: "LOCATION", ValueTemplate: "{{.Location}}"}, + Priority: 2, }, } - err = a.formatter.Format(sourceConfigs, a.writer, output.TableFormatterOptions{ - Columns: columns, + err = prettyFormatter.Format(sourceConfigs, a.writer, output.PrettyTableFormatterOptions{ + Columns: columns, + CardGroupColumn: "TYPE", }) } else { err = a.formatter.Format(sourceConfigs, a.writer, nil) diff --git a/cli/azd/cmd/templates.go b/cli/azd/cmd/templates.go index 5a728a5549e..2cf8208b50f 100644 --- a/cli/azd/cmd/templates.go +++ b/cli/azd/cmd/templates.go @@ -81,6 +81,19 @@ func newTemplateListCmd() *cobra.Command { } } +// templateListItem is the display struct for template list table rendering. +// DisplayTags is pre-computed from Tags since Go text/template has no join function. +type templateListItem struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Source string `json:"-"` + RepoSource string `json:"source,omitempty"` + Description string `json:"description,omitempty"` + RepositoryPath string `json:"repositoryPath"` + Tags []string `json:"tags"` + DisplayTags string `json:"-"` +} + type templateListAction struct { flags *templateListFlags formatter output.Formatter @@ -113,24 +126,55 @@ func (tl *templateListAction) Run(ctx context.Context) (*actions.ActionResult, e } if tl.formatter.Kind() == output.TableFormat { - columns := []output.Column{ + rows := make([]templateListItem, 0, len(listedTemplates)) + for _, t := range listedTemplates { + rows = append(rows, templateListItem{ + Name: t.Name, + Title: t.Title, + Source: t.Source, + RepoSource: t.RepoSource, + Description: t.Description, + RepositoryPath: t.RepositoryPath, + Tags: t.Tags, + DisplayTags: strings.Join(t.Tags, ", "), + }) + } + + prettyFormatter := &output.PrettyTableFormatter{} + columns := []output.PrettyColumn{ + { + Column: output.Column{ + Heading: "NAME", + ValueTemplate: `{{if ne .Name ""}}{{.Name}}{{else}}{{.Title}}{{end}}`, + }, + Priority: 1, + }, + { + Column: output.Column{Heading: "SOURCE", ValueTemplate: "{{.Source}}"}, + Priority: 1, + }, { - Heading: "Name", - ValueTemplate: `{{if ne .Name ""}}{{.Name}}{{else}}{{.Title}}{{end}}`, + Column: output.Column{Heading: "TAGS", ValueTemplate: "{{.DisplayTags}}"}, + Priority: 2, }, { - Heading: "Source", - ValueTemplate: "{{.Source}}", + Column: output.Column{Heading: "DESCRIPTION", ValueTemplate: "{{.Description}}"}, + Priority: 3, }, { - Heading: "Repository Path", - ValueTemplate: `{{if ne .RepositoryPath ""}}{{.RepositoryPath}}{{else}}{{.RepoSource}}{{end}}`, - Transformer: templates.Hyperlink, + Column: output.Column{ + Heading: "REPOSITORY PATH", + ValueTemplate: `{{if ne .RepositoryPath ""}}` + + `{{.RepositoryPath}}{{else}}{{.RepoSource}}{{end}}`, + Transformer: templates.Hyperlink, + }, + Priority: 3, }, } - err = tl.formatter.Format(listedTemplates, tl.writer, output.TableFormatterOptions{ - Columns: columns, + err = prettyFormatter.Format(rows, tl.writer, output.PrettyTableFormatterOptions{ + Columns: columns, + CardGroupColumn: "SOURCE", }) if err == nil { @@ -327,27 +371,29 @@ func (a *templateSourceListAction) Run(ctx context.Context) (*actions.ActionResu } if a.formatter.Kind() == output.TableFormat { - columns := []output.Column{ + prettyFormatter := &output.PrettyTableFormatter{} + columns := []output.PrettyColumn{ { - Heading: "Key", - ValueTemplate: "{{.Key}}", + Column: output.Column{Heading: "KEY", ValueTemplate: "{{.Key}}"}, + Priority: 1, }, { - Heading: "Name", - ValueTemplate: "{{.Name}}", + Column: output.Column{Heading: "TYPE", ValueTemplate: "{{.Type}}"}, + Priority: 1, }, { - Heading: "Type", - ValueTemplate: "{{.Type}}", + Column: output.Column{Heading: "NAME", ValueTemplate: "{{.Name}}"}, + Priority: 2, }, { - Heading: "Location", - ValueTemplate: "{{.Location}}", + Column: output.Column{Heading: "LOCATION", ValueTemplate: "{{.Location}}"}, + Priority: 3, }, } - err = a.formatter.Format(sourceConfigs, a.writer, output.TableFormatterOptions{ - Columns: columns, + err = prettyFormatter.Format(sourceConfigs, a.writer, output.PrettyTableFormatterOptions{ + Columns: columns, + CardGroupColumn: "TYPE", }) } else { err = a.formatter.Format(sourceConfigs, a.writer, nil) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 2bdac8042ab..9320f925f5c 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -252,12 +252,13 @@ func (a *toolAction) Run(ctx context.Context) (*actions.ActionResult, error) { // --------------------------------------------------------------------------- type toolListItem struct { - Id string `json:"id"` - Name string `json:"name"` - Category string `json:"category"` - Priority string `json:"priority"` - Status string `json:"status"` - Version string `json:"version"` + Id string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Priority string `json:"priority"` + Status string `json:"status"` + StatusSymbol string `json:"-"` + Version string `json:"version"` } type toolListAction struct { @@ -306,19 +307,22 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) rows := make([]toolListItem, 0, len(statuses)) for _, s := range statuses { status := "Not Installed" + statusSymbol := "✗" version := "" if s.Installed { status = "Installed" + statusSymbol = "✓" version = s.InstalledVersion } rows = append(rows, toolListItem{ - Id: s.Tool.Id, - Name: s.Tool.Name, - Category: string(s.Tool.Category), - Priority: string(s.Tool.Priority), - Status: status, - Version: version, + Id: s.Tool.Id, + Name: s.Tool.Name, + Category: string(s.Tool.Category), + Priority: string(s.Tool.Priority), + Status: status, + StatusSymbol: statusSymbol, + Version: version, }) } @@ -330,18 +334,40 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) var formatErr error if a.formatter.Kind() == output.TableFormat { - columns := []output.Column{ - {Heading: "Id", ValueTemplate: "{{.Id}}"}, - {Heading: "Name", ValueTemplate: "{{.Name}}"}, - {Heading: "Category", ValueTemplate: "{{.Category}}"}, - {Heading: "Priority", ValueTemplate: "{{.Priority}}"}, - {Heading: "Status", ValueTemplate: "{{.Status}}"}, - {Heading: "Version", ValueTemplate: "{{.Version}}"}, + prettyFormatter := &output.PrettyTableFormatter{} + columns := []output.PrettyColumn{ + { + Column: output.Column{Heading: "NAME", ValueTemplate: "{{.Name}}"}, + Priority: 1, + }, + { + Column: output.Column{Heading: "STATUS", ValueTemplate: "{{.Status}}"}, + Priority: 1, + ShortValueTemplate: "{{.StatusSymbol}}", + ColorFunc: toolStatusColor, + }, + { + Column: output.Column{Heading: "VERSION", ValueTemplate: "{{.Version}}"}, + Priority: 2, + }, + { + Column: output.Column{Heading: "CATEGORY", ValueTemplate: "{{.Category}}"}, + Priority: 2, + }, + { + Column: output.Column{Heading: "ID", ValueTemplate: "{{.Id}}"}, + Priority: 3, + }, + { + Column: output.Column{Heading: "PRIORITY", ValueTemplate: "{{.Priority}}"}, + Priority: 3, + }, } - formatErr = a.formatter.Format( - rows, a.writer, output.TableFormatterOptions{Columns: columns}, - ) + formatErr = prettyFormatter.Format(rows, a.writer, output.PrettyTableFormatterOptions{ + Columns: columns, + CardGroupColumn: "CATEGORY", + }) } else { formatErr = a.formatter.Format(rows, a.writer, nil) } @@ -816,19 +842,43 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error var formatErr error if a.formatter.Kind() == output.TableFormat { - columns := []output.Column{ - {Heading: "Id", ValueTemplate: "{{.Id}}"}, - {Heading: "Name", ValueTemplate: "{{.Name}}"}, - {Heading: "Installed Version", ValueTemplate: "{{.InstalledVersion}}"}, - {Heading: "Latest Version", ValueTemplate: "{{.LatestVersion}}"}, + prettyFormatter := &output.PrettyTableFormatter{} + columns := []output.PrettyColumn{ { - Heading: "Update Available", - ValueTemplate: `{{if .UpdateAvailable}}Yes{{else}}No{{end}}`, + Column: output.Column{Heading: "NAME", ValueTemplate: "{{.Name}}"}, + Priority: 1, + }, + { + Column: output.Column{ + Heading: "UPDATE AVAILABLE", + ValueTemplate: `{{if .UpdateAvailable}}Yes{{else}}No{{end}}`, + }, + Priority: 1, + ShortValueTemplate: `{{if .UpdateAvailable}}⬆{{else}}✓{{end}}`, + ColorFunc: updateAvailableColor, + }, + { + Column: output.Column{ + Heading: "INSTALLED VERSION", + ValueTemplate: "{{.InstalledVersion}}", + }, + Priority: 2, + }, + { + Column: output.Column{ + Heading: "LATEST VERSION", + ValueTemplate: "{{.LatestVersion}}", + }, + Priority: 2, + }, + { + Column: output.Column{Heading: "ID", ValueTemplate: "{{.Id}}"}, + Priority: 3, }, } - formatErr = a.formatter.Format( - rows, a.writer, output.TableFormatterOptions{Columns: columns}, + formatErr = prettyFormatter.Format( + rows, a.writer, output.PrettyTableFormatterOptions{Columns: columns, CardGroupColumn: "NAME"}, ) if formatErr == nil { @@ -1291,3 +1341,27 @@ func writeDryRunTable(w io.Writer, rows []toolDryRunItem) error { return tw.Flush() } + +// toolStatusColor applies color formatting based on install status text. +func toolStatusColor(s string) string { + switch s { + case "Installed", "✓": + return output.WithSuccessFormat(s) + case "Not Installed", "✗": + return output.WithWarningFormat(s) + default: + return output.WithGrayFormat(s) + } +} + +// updateAvailableColor applies color formatting based on update availability text. +func updateAvailableColor(s string) string { + switch s { + case "No", "✓": + return output.WithSuccessFormat(s) + case "Yes", "⬆": + return output.WithWarningFormat(s) + default: + return output.WithGrayFormat(s) + } +} diff --git a/cli/azd/pkg/output/pretty_table.go b/cli/azd/pkg/output/pretty_table.go index 1537f51e026..dbb726558e7 100644 --- a/cli/azd/pkg/output/pretty_table.go +++ b/cli/azd/pkg/output/pretty_table.go @@ -464,10 +464,15 @@ func sumWidths(widths []int) int { // ansiRegex matches ANSI escape codes used for terminal coloring. var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) +// osc8Regex matches OSC-8 hyperlink escape sequences. +// Format: \x1b]8;params;URI\x1b\ (ST terminator) or \x1b]8;params;URI\a (BEL terminator) +var osc8Regex = regexp.MustCompile(`\x1b\]8;[^;]*;[^\x1b\a]*(?:\x1b\\|\a)`) + // displayWidth returns the visible column width of s, ignoring ANSI escape -// codes and accounting for wide Unicode characters (e.g. CJK). +// codes, OSC-8 hyperlink sequences, and accounting for wide Unicode characters (e.g. CJK). func displayWidth(s string) int { - stripped := ansiRegex.ReplaceAllString(s, "") + stripped := osc8Regex.ReplaceAllString(s, "") + stripped = ansiRegex.ReplaceAllString(stripped, "") return runewidth.StringWidth(stripped) } diff --git a/cli/azd/pkg/output/pretty_table_test.go b/cli/azd/pkg/output/pretty_table_test.go index 35186634048..2b1dbaab633 100644 --- a/cli/azd/pkg/output/pretty_table_test.go +++ b/cli/azd/pkg/output/pretty_table_test.go @@ -698,6 +698,10 @@ func TestDisplayWidth(t *testing.T) { {"ansi gray", "\033[90m-\033[0m", 1}, {"no ansi unicode", "日本語", 6}, {"ansi with unicode", "\033[31m日本語\033[0m", 6}, + {"osc8 hyperlink ST", "\x1b]8;;https://example.com\x1b\\Click Here\x1b]8;;\x1b\\", 10}, + {"osc8 hyperlink BEL", "\x1b]8;;https://example.com\aClick Here\x1b]8;;\a", 10}, + {"osc8 with ansi color", "\033[36m\x1b]8;;https://example.com\x1b\\Click Here\x1b]8;;\x1b\\\033[0m", 10}, + {"plain text unchanged", "no escapes here", 15}, } for _, tt := range tests {