Skip to content

Commit 9fcab14

Browse files
authored
Merge pull request #39 from satococoa/feature/list-width-control
feat: add width controls for list command output
2 parents 6a8895d + b8bb125 commit 9fcab14

File tree

2 files changed

+563
-89
lines changed

2 files changed

+563
-89
lines changed

cmd/wtp/list.go

Lines changed: 202 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"os"
88
"path/filepath"
9+
"strconv"
910
"strings"
1011

1112
"github.com/urfave/cli/v3"
@@ -25,6 +26,14 @@ const (
2526
detachedKeyword = "detached"
2627
)
2728

29+
const (
30+
defaultMaxPathWidth = 56
31+
superWideThreshold = 160
32+
pathPadding = 2
33+
minPathWidth = 20
34+
columnSpacing = 3
35+
)
36+
2837
// GitRepository interface for mocking
2938
type GitRepository interface {
3039
GetWorktrees() ([]git.Worktree, error)
@@ -55,6 +64,16 @@ func NewListCommand() *cli.Command {
5564
Description: "Shows all worktrees with their paths, branches, and HEAD commits.",
5665
ShellComplete: completeList,
5766
Flags: []cli.Flag{
67+
&cli.BoolFlag{
68+
Name: "compact",
69+
Aliases: []string{"c"},
70+
Usage: "Minimize column widths for narrow or redirected output",
71+
},
72+
&cli.IntFlag{
73+
Name: "max-path-width",
74+
Usage: fmt.Sprintf("Maximum width for PATH column (default %d)", defaultMaxPathWidth),
75+
Value: defaultMaxPathWidth,
76+
},
5877
&cli.BoolFlag{
5978
Name: "quiet",
6079
Aliases: []string{"q"},
@@ -93,16 +112,20 @@ func listCommand(_ context.Context, cmd *cli.Command) error {
93112
// Load config to get base_dir
94113
cfg, _ := config.LoadConfig(mainRepoPath)
95114

115+
// Resolve display options
116+
opts := resolveListDisplayOptions(cmd, w)
117+
96118
// Get quiet flag
97119
quiet := cmd.Bool("quiet")
98120

99121
// Use CommandExecutor-based implementation
100122
executor := listNewExecutor()
101-
return listCommandWithCommandExecutor(cmd, w, executor, cfg, mainRepoPath, quiet)
123+
return listCommandWithCommandExecutor(cmd, w, executor, cfg, mainRepoPath, quiet, opts)
102124
}
103125

104126
func listCommandWithCommandExecutor(
105127
_ *cli.Command, w io.Writer, executor command.Executor, cfg *config.Config, mainRepoPath string, quiet bool,
128+
opts listDisplayOptions,
106129
) error {
107130
// Get current working directory
108131
cwd, err := listGetwd()
@@ -131,7 +154,18 @@ func listCommandWithCommandExecutor(
131154
if quiet {
132155
displayWorktreesQuiet(w, worktrees, cfg, mainRepoPath)
133156
} else {
134-
displayWorktreesRelative(w, worktrees, cwd, cfg, mainRepoPath)
157+
termWidth := getTerminalWidth()
158+
if opts.MaxPathWidth <= 0 {
159+
opts.MaxPathWidth = defaultMaxPathWidth
160+
}
161+
if !opts.Compact {
162+
if !opts.OutputIsTTY {
163+
opts.Compact = true
164+
} else if termWidth >= superWideThreshold {
165+
opts.Compact = true
166+
}
167+
}
168+
displayWorktreesRelative(w, worktrees, cwd, cfg, mainRepoPath, termWidth, opts)
135169
}
136170
return nil
137171
}
@@ -243,113 +277,207 @@ func displayWorktreesQuiet(w io.Writer, worktrees []git.Worktree, cfg *config.Co
243277
// displayWorktreesRelative formats and displays worktree information with relative paths
244278
func displayWorktreesRelative(
245279
w io.Writer, worktrees []git.Worktree, currentPath string, cfg *config.Config, mainRepoPath string,
280+
termWidth int, opts listDisplayOptions,
246281
) {
247-
termWidth := getTerminalWidth()
282+
if termWidth <= 0 {
283+
termWidth = 80
284+
}
285+
286+
items, metrics := collectListDisplayData(worktrees, currentPath, cfg, mainRepoPath)
287+
if len(items) == 0 {
288+
return
289+
}
290+
291+
pathWidth, branchWidth, statusWidth := computeListColumnWidths(metrics, termWidth, opts)
292+
293+
fmt.Fprintf(w, "%-*s %-*s %-*s %s\n", pathWidth, "PATH", branchWidth, "BRANCH", statusWidth, "STATUS", "HEAD")
294+
fmt.Fprintf(w, "%-*s %-*s %-*s %s\n",
295+
pathWidth, strings.Repeat("-", pathHeaderDashes),
296+
branchWidth, strings.Repeat("-", branchHeaderDashes),
297+
statusWidth, strings.Repeat("-", len("STATUS")),
298+
"----")
248299

249-
// Minimum widths for columns
250-
const minPathWidth = 20
251-
const headWidth = headDisplayLength
252-
const spacing = 3 // Spaces between columns
300+
for _, item := range items {
301+
headShort := item.head
302+
if len(headShort) > headDisplayLength {
303+
headShort = headShort[:headDisplayLength]
304+
}
253305

254-
// Calculate initial column widths
255-
maxBranchLen := 6 // "BRANCH"
256-
maxPathLen := 4 // "PATH"
257-
maxStatusLen := 6 // "STATUS"
306+
fmt.Fprintf(w, "%-*s %-*s %-*s %s\n",
307+
pathWidth, truncatePath(item.path, pathWidth),
308+
branchWidth, truncatePath(item.branch, branchWidth),
309+
statusWidth, truncatePath(item.status, statusWidth),
310+
headShort)
311+
}
312+
}
258313

259-
// Find main worktree path is no longer needed since we pass it from the caller
314+
type listDisplayData struct {
315+
path string
316+
branch string
317+
head string
318+
status string
319+
}
260320

261-
// First pass: calculate max widths and prepare display data
262-
type displayData struct {
263-
path string
264-
branch string
265-
head string
266-
status string
267-
isCurrent bool
321+
type listColumnMetrics struct {
322+
maxPathLen int
323+
maxBranchLen int
324+
maxStatusLen int
325+
}
326+
327+
func collectListDisplayData(
328+
worktrees []git.Worktree, currentPath string, cfg *config.Config, mainRepoPath string,
329+
) ([]listDisplayData, listColumnMetrics) {
330+
metrics := listColumnMetrics{
331+
maxPathLen: len("PATH"),
332+
maxBranchLen: len("BRANCH"),
333+
maxStatusLen: len("STATUS"),
268334
}
269335

270-
var displayItems []displayData
336+
items := make([]listDisplayData, 0, len(worktrees))
271337

272338
for _, wt := range worktrees {
273-
var isCurrent bool
274-
275-
// Get worktree display name
276339
pathDisplay := getWorktreeDisplayName(wt, cfg, mainRepoPath)
277-
278-
// Check if this is the current worktree
279340
if wt.Path == currentPath {
280-
isCurrent = true
281341
pathDisplay += "*"
282342
}
283343

284344
branchDisplay := formatBranchDisplay(wt.Branch)
285345

286-
// Determine management status
287-
var statusDisplay string
346+
statusDisplay := "unmanaged"
288347
if isWorktreeManagedList(wt.Path, cfg, mainRepoPath, wt.IsMain) {
289348
statusDisplay = "managed"
290-
} else {
291-
statusDisplay = "unmanaged"
292349
}
293350

294-
if len(pathDisplay) > maxPathLen {
295-
maxPathLen = len(pathDisplay)
351+
if len(pathDisplay) > metrics.maxPathLen {
352+
metrics.maxPathLen = len(pathDisplay)
296353
}
297-
if len(branchDisplay) > maxBranchLen {
298-
maxBranchLen = len(branchDisplay)
354+
if len(branchDisplay) > metrics.maxBranchLen {
355+
metrics.maxBranchLen = len(branchDisplay)
299356
}
300-
if len(statusDisplay) > maxStatusLen {
301-
maxStatusLen = len(statusDisplay)
357+
if len(statusDisplay) > metrics.maxStatusLen {
358+
metrics.maxStatusLen = len(statusDisplay)
302359
}
303360

304-
displayItems = append(displayItems, displayData{
305-
path: pathDisplay,
306-
branch: branchDisplay,
307-
head: wt.HEAD,
308-
status: statusDisplay,
309-
isCurrent: isCurrent,
361+
items = append(items, listDisplayData{
362+
path: pathDisplay,
363+
branch: branchDisplay,
364+
head: wt.HEAD,
365+
status: statusDisplay,
310366
})
311367
}
312368

313-
// Calculate available width for path column
314-
// Total = path + spacing + branch + spacing + status + spacing + head
315-
availableForPath := termWidth - spacing - maxBranchLen - spacing - maxStatusLen - spacing - headWidth
369+
return items, metrics
370+
}
371+
372+
func computeListColumnWidths(
373+
metrics listColumnMetrics,
374+
termWidth int,
375+
opts listDisplayOptions,
376+
) (pathWidth, branchWidth, statusWidth int) {
377+
branchWidth, statusWidth = clampBranchAndStatusWidths(metrics.maxBranchLen, metrics.maxStatusLen, termWidth)
378+
pathWidth = derivePathWidth(metrics.maxPathLen, branchWidth, statusWidth, termWidth, opts)
379+
return pathWidth, branchWidth, statusWidth
380+
}
381+
382+
func clampBranchAndStatusWidths(
383+
maxBranchLen, maxStatusLen, termWidth int,
384+
) (branchWidth, statusWidth int) {
385+
const (
386+
minPathWidth = 20
387+
headWidth = headDisplayLength
388+
spacing = 3
389+
)
390+
391+
branchWidth = maxBranchLen
392+
statusWidth = maxStatusLen
393+
394+
branchHeaderWidth := len("BRANCH")
395+
statusHeaderWidth := len("STATUS")
396+
397+
if branchWidth < branchHeaderWidth {
398+
branchWidth = branchHeaderWidth
399+
}
400+
if statusWidth < statusHeaderWidth {
401+
statusWidth = statusHeaderWidth
402+
}
403+
404+
maxAvailableForBranch := termWidth - minPathWidth - spacing - statusWidth - spacing - spacing - headWidth
405+
if branchWidth > maxAvailableForBranch {
406+
branchWidth = maxAvailableForBranch
407+
if branchWidth < branchHeaderWidth {
408+
branchWidth = branchHeaderWidth
409+
}
410+
}
316411

317-
// If branch column is too wide, limit it as well
318-
maxAvailableForBranch := termWidth - minPathWidth - spacing - maxStatusLen - spacing - spacing - headWidth
319-
if maxBranchLen > maxAvailableForBranch {
320-
maxBranchLen = maxAvailableForBranch
321-
// Recalculate path width with truncated branch width
322-
availableForPath = termWidth - spacing - maxBranchLen - spacing - maxStatusLen - spacing - headWidth
412+
return branchWidth, statusWidth
413+
}
414+
415+
func derivePathWidth(maxPathLen, branchWidth, statusWidth, termWidth int, opts listDisplayOptions) int {
416+
pathHeaderWidth := len("PATH")
417+
availableForPath := termWidth - columnSpacing - branchWidth - columnSpacing - statusWidth -
418+
columnSpacing - headDisplayLength
419+
availableForPath = max(availableForPath, pathHeaderWidth)
420+
421+
pathWidth := availableForPath
422+
423+
if opts.Compact {
424+
pathWidth = clampCompactPathWidth(pathWidth, maxPathLen)
425+
} else {
426+
pathWidth = clampExpandedPathWidth(pathWidth, maxPathLen)
323427
}
324428

325-
// Ensure minimum path width
326-
if availableForPath < minPathWidth {
327-
availableForPath = minPathWidth
429+
if opts.MaxPathWidth > 0 {
430+
pathWidth = min(pathWidth, opts.MaxPathWidth)
328431
}
329432

330-
// Print header
331-
fmt.Fprintf(w, "%-*s %-*s %-*s %s\n", availableForPath, "PATH", maxBranchLen, "BRANCH", maxStatusLen, "STATUS", "HEAD")
332-
fmt.Fprintf(w, "%-*s %-*s %-*s %s\n",
333-
availableForPath, strings.Repeat("-", pathHeaderDashes),
334-
maxBranchLen, strings.Repeat("-", branchHeaderDashes),
335-
maxStatusLen, strings.Repeat("-", len("STATUS")),
336-
"----")
433+
pathWidth = max(min(pathWidth, availableForPath), pathHeaderWidth)
434+
pathWidth = max(pathWidth, 1)
337435

338-
// Print worktrees
339-
for _, item := range displayItems {
340-
headShort := item.head
341-
if len(headShort) > headDisplayLength {
342-
headShort = headShort[:headDisplayLength]
436+
return pathWidth
437+
}
438+
439+
func clampCompactPathWidth(currentWidth, maxPathLen int) int {
440+
pathHeaderWidth := len("PATH")
441+
minCompactWidth := max(maxPathLen, pathHeaderWidth)
442+
return max(min(currentWidth, minCompactWidth), pathHeaderWidth)
443+
}
444+
445+
func clampExpandedPathWidth(currentWidth, maxPathLen int) int {
446+
pathHeaderWidth := len("PATH")
447+
448+
desiredWidth := max(maxPathLen+pathPadding, minPathWidth, pathHeaderWidth)
449+
return max(min(currentWidth, desiredWidth), minPathWidth)
450+
}
451+
452+
type listDisplayOptions struct {
453+
Compact bool
454+
MaxPathWidth int
455+
OutputIsTTY bool
456+
}
457+
458+
func resolveListDisplayOptions(cmd *cli.Command, w io.Writer) listDisplayOptions {
459+
maxPathWidth := cmd.Int("max-path-width")
460+
if maxPathWidth == defaultMaxPathWidth && !cmd.IsSet("max-path-width") {
461+
if envValue := os.Getenv("WTP_LIST_MAX_PATH"); envValue != "" {
462+
if parsed, err := strconv.Atoi(envValue); err == nil && parsed > 0 {
463+
maxPathWidth = parsed
464+
}
343465
}
466+
}
467+
if maxPathWidth <= 0 {
468+
maxPathWidth = defaultMaxPathWidth
469+
}
344470

345-
pathDisplay := truncatePath(item.path, availableForPath)
346-
branchDisplayTrunc := truncatePath(item.branch, maxBranchLen)
347-
statusDisplayTrunc := truncatePath(item.status, maxStatusLen)
471+
compact := cmd.Bool("compact")
348472

349-
fmt.Fprintf(w, "%-*s %-*s %-*s %s\n",
350-
availableForPath, pathDisplay,
351-
maxBranchLen, branchDisplayTrunc,
352-
maxStatusLen, statusDisplayTrunc,
353-
headShort)
473+
outputIsTTY := false
474+
if file, ok := w.(*os.File); ok {
475+
outputIsTTY = term.IsTerminal(int(file.Fd()))
476+
}
477+
478+
return listDisplayOptions{
479+
Compact: compact,
480+
MaxPathWidth: maxPathWidth,
481+
OutputIsTTY: outputIsTTY,
354482
}
355483
}

0 commit comments

Comments
 (0)