Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion internal/commands/campfire.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func runCampfireList(cmd *cobra.Command, app *appctx.App, project string, all bo
}

// Return as array for consistency
result := []any{campfire}
result := []*basecamp.Campfire{campfire}
summary := fmt.Sprintf("Campfire: %s", title)
Comment thread
jeremy marked this conversation as resolved.

return app.OK(result,
Expand Down Expand Up @@ -228,6 +228,7 @@ func runCampfireMessages(cmd *cobra.Command, app *appctx.App, campfireID, projec

return app.OK(lines,
output.WithSummary(summary),
output.WithEntity("campfire_line"),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "post",
Expand Down Expand Up @@ -360,6 +361,7 @@ func runCampfirePost(cmd *cobra.Command, app *appctx.App, campfireID, project, c

return app.OK(line,
output.WithSummary(summary),
output.WithEntity("campfire_line"),
output.WithBreadcrumbs(breadcrumbs...),
)
}
Expand Down Expand Up @@ -439,6 +441,7 @@ You can pass either a line ID or a Basecamp line URL:

return app.OK(line,
output.WithSummary(summary),
output.WithEntity("campfire_line"),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "delete",
Expand Down
60 changes: 60 additions & 0 deletions internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2127,6 +2127,66 @@ func TestRenderDataStripsEscapesFromTopLevelStrings(t *testing.T) {
}
}

func TestWithEntityCampfireLineStyledOutput(t *testing.T) {
var buf bytes.Buffer
w := New(Options{
Format: FormatStyled,
Writer: &buf,
})

data := []map[string]any{
{
"id": float64(42),
"content": "Hello\nworld",
"creator": map[string]any{"name": "Alice"},
"created_at": "2026-01-15T10:00:00Z",
},
}
err := w.OK(data,
WithEntity("campfire_line"),
WithSummary("1 messages"),
)
require.NoError(t, err)

output := buf.String()
assert.Contains(t, output, "Hello world",
"campfire_line list should collapse multiline content")
assert.Contains(t, output, "Alice")
assert.NotContains(t, output, "title",
"campfire_line must not show a title column")
Comment thread
jeremy marked this conversation as resolved.
assert.NotContains(t, output, "Title",
"campfire_line must not show a Title column")
}

func TestWithEntityCampfireLineMarkdownOutput(t *testing.T) {
var buf bytes.Buffer
w := New(Options{
Format: FormatMarkdown,
Writer: &buf,
})

data := []map[string]any{
{
"id": float64(42),
"content": "Hello\nworld",
"creator": map[string]any{"name": "Alice"},
"created_at": "2026-01-15T10:00:00Z",
},
}
err := w.OK(data,
WithEntity("campfire_line"),
WithSummary("1 messages"),
)
require.NoError(t, err)

output := buf.String()
assert.NotContains(t, output, "\x1b",
"markdown output must not contain ANSI codes")
assert.Contains(t, output, "Hello world",
"campfire_line markdown should collapse multiline content")
assert.Contains(t, output, "Alice")
}

func TestRenderDataStripsOSCFromTopLevelString(t *testing.T) {
var buf bytes.Buffer
w := New(Options{Format: FormatMarkdown, Writer: &buf})
Expand Down
3 changes: 3 additions & 0 deletions internal/output/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,9 @@ func formatCell(val any) string {
return ""
case string:
v = ansi.Strip(v)
if strings.ContainsAny(v, "\n\r") {
v = strings.Join(strings.Fields(v), " ")
}
// Truncate long strings (rune-safe for multi-byte UTF-8)
if utf8.RuneCountInString(v) > 40 {
runes := []rune(v)
Expand Down
13 changes: 9 additions & 4 deletions internal/presenter/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,17 +141,22 @@ func formatPerson(val any) string {
return ""
}

// singleLine returns the first non-empty line from s, trimmed.
// singleLine collapses multiline text into a single line by joining all
// non-empty lines with spaces. Leading/trailing whitespace is trimmed.
func singleLine(s string) string {
if strings.IndexByte(s, '\n') == -1 {
if !strings.ContainsAny(s, "\n\r") {
return strings.TrimSpace(s)
}
// Normalize \r\n and bare \r to \n before splitting.
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
var parts []string
for _, line := range strings.Split(s, "\n") {
if trimmed := strings.TrimSpace(line); trimmed != "" {
return trimmed
parts = append(parts, trimmed)
}
}
return ""
return strings.Join(parts, " ")
}

// formatText converts any value to a string representation.
Expand Down
67 changes: 62 additions & 5 deletions internal/presenter/presenter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/basecamp-cli/internal/tui"
)
Expand Down Expand Up @@ -1646,10 +1647,10 @@ func TestFormatTextBcAttachmentOnly(t *testing.T) {
}
}

func TestSingleLineSkipsLeadingBlanks(t *testing.T) {
func TestSingleLineCollapsesMultiline(t *testing.T) {
got := singleLine("\n\nfirst\nsecond")
if got != "first" {
t.Errorf("singleLine = %q, want %q", got, "first")
if got != "first second" {
t.Errorf("singleLine = %q, want %q", got, "first second")
}
}

Expand All @@ -1660,6 +1661,25 @@ func TestSingleLinePlainText(t *testing.T) {
}
}

func TestSingleLineCollapsesAllLines(t *testing.T) {
got := singleLine("line one\nline two\nline three")
if got != "line one line two line three" {
t.Errorf("singleLine = %q, want %q", got, "line one line two line three")
}
}

func TestSingleLineCollapsesBareCarriageReturn(t *testing.T) {
got := singleLine("a\rb")
if got != "a b" {
t.Errorf("singleLine = %q, want %q", got, "a b")
}

got = singleLine("x\r\ny\rz")
if got != "x y z" {
t.Errorf("singleLine(mixed) = %q, want %q", got, "x y z")
}
}

func TestRenderHeadlineHTMLContent(t *testing.T) {
schema := &EntitySchema{
Identity: Identity{Label: "content"},
Expand All @@ -1671,8 +1691,8 @@ func TestRenderHeadlineHTMLContent(t *testing.T) {
if strings.Contains(got, "<") {
t.Errorf("RenderHeadline should strip HTML tags, got: %q", got)
}
if got != "Title" {
t.Errorf("RenderHeadline = %q, want %q", got, "Title")
if got != "Title subtitle" {
t.Errorf("RenderHeadline = %q, want %q", got, "Title subtitle")
}
}

Expand Down Expand Up @@ -1935,3 +1955,40 @@ func TestRenderDetailHeadlineHyperlink(t *testing.T) {
t.Errorf("Styled detail headline should contain OSC 8 hyperlink, got:\n%q", out)
}
}

func TestCampfireLineSchemaLoads(t *testing.T) {
schema := LookupByName("campfire_line")
require.NotNil(t, schema, "campfire_line schema must be registered")
assert.Equal(t, "campfire_line", schema.Entity)
assert.Equal(t, "recording", schema.Kind)
assert.Empty(t, schema.TypeKey, "type_key must be empty — lines have multiple API types")

// List columns must exclude title (avoids duplication with content)
assert.Equal(t, []string{"id", "content", "creator", "created_at"}, schema.Views.List.Columns)
assert.NotContains(t, schema.Views.List.Columns, "title")
}

func TestCampfireLineRenderListCollapsesMultiline(t *testing.T) {
schema := LookupByName("campfire_line")
require.NotNil(t, schema)

data := []map[string]any{
{
"id": float64(42),
"content": "first line\nsecond line\nthird line",
"creator": map[string]any{"name": "Alice"},
"created_at": "2026-01-15T10:00:00Z",
},
}

styles := NewStyles(tui.NoColorTheme(), false)

var buf strings.Builder
err := RenderList(&buf, schema, data, styles, enUS)
require.NoError(t, err)

out := buf.String()
assert.Contains(t, out, "first line second line third line",
"multiline content must be collapsed, not truncated to first line")
assert.Contains(t, out, "Alice")
}
36 changes: 36 additions & 0 deletions internal/presenter/schemas/campfire_line.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
entity: campfire_line
kind: recording

identity:
id: id

headline:
default:
template: ""

fields:
content:
role: body
format: text

creator:
role: detail
format: person

created_at:
role: meta
emphasis: muted
format: relative_time

id:
role: meta
emphasis: muted

views:
list:
columns: [id, content, creator, created_at]
detail:
sections:
- fields: [content]
- heading: Metadata
fields: [id, created_at, creator]
Loading