diff --git a/cmd/difi/main.go b/cmd/difi/main.go index 4240baa..d60dd57 100644 --- a/cmd/difi/main.go +++ b/cmd/difi/main.go @@ -16,6 +16,7 @@ var version = "dev" func main() { showVersion := flag.Bool("version", false, "Show version") plain := flag.Bool("plain", false, "Print a plain, non-interactive summary and exit") + flat := flag.Bool("flat", false, "Start in flat file list mode") flag.Usage = func() { w := os.Stderr @@ -52,6 +53,7 @@ func main() { } cfg := config.Load() + cfg.Flat = *flat p := tea.NewProgram(ui.NewModel(cfg, target), tea.WithAltScreen()) if _, err := p.Run(); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 563cc55..2cb021c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,8 @@ package config type Config struct { - UI UIConfig + UI UIConfig + Flat bool } type UIConfig struct { diff --git a/internal/tree/tree.go b/internal/tree/tree.go index 3174123..010653a 100644 --- a/internal/tree/tree.go +++ b/internal/tree/tree.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/lipgloss" ) // FileTree holds the state of the entire file graph. @@ -32,6 +33,7 @@ type TreeItem struct { Depth int Expanded bool Icon string + Flat bool } // Implement list.Item interface @@ -104,6 +106,40 @@ func (t *FileTree) Items() []list.Item { return items } +// FlatItems returns all leaf (file) nodes as a flat list with full paths. +func (t *FileTree) FlatItems() []list.Item { + var items []list.Item + collectLeaves(t.Root, &items) + return items +} + +// collectLeaves recursively collects all file (non-directory) nodes. +func collectLeaves(node *Node, items *[]list.Item) { + children := make([]*Node, 0, len(node.Children)) + for _, child := range node.Children { + children = append(children, child) + } + + sort.Slice(children, func(i, j int) bool { + return strings.ToLower(children[i].FullPath) < strings.ToLower(children[j].FullPath) + }) + + for _, child := range children { + if child.IsDir { + collectLeaves(child, items) + } else { + *items = append(*items, TreeItem{ + Name: child.Name, + FullPath: child.FullPath, + IsDir: false, + Depth: 0, + Icon: getIcon(child.Name, false), + Flat: true, + }) + } + } +} + // flatten recursively builds the list, respecting expansion state. func flatten(node *Node, items *[]list.Item) { // Collect children to sort @@ -189,3 +225,22 @@ func getIcon(name string, isDir bool) string { return "" } } + +// ShortenPath truncates a path from the left to fit within maxWidth display columns. +// "internal/tree/tree.go" → "…nal/tree/tree.go" +func ShortenPath(path string, maxWidth int) string { + if lipgloss.Width(path) <= maxWidth { + return path + } + ellipsis := "…" + avail := maxWidth - lipgloss.Width(ellipsis) + if avail <= 0 { + return ellipsis + } + // Trim runes from the left until the remainder fits + runes := []rune(path) + for len(runes) > 0 && lipgloss.Width(string(runes)) > avail { + runes = runes[1:] + } + return ellipsis + string(runes) +} diff --git a/internal/ui/delegate.go b/internal/ui/delegate.go index de7b117..2c15a27 100644 --- a/internal/ui/delegate.go +++ b/internal/ui/delegate.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/oug-t/difi/internal/tree" ) @@ -24,7 +25,15 @@ func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite return } - title := i.Title() + var title string + if i.Flat { + iconWidth := lipgloss.Width(i.Icon) + 1 // icon + space + path := tree.ShortenPath(i.FullPath, m.Width()-2-iconWidth) + title = fmt.Sprintf("%s %s", i.Icon, path) + } else { + title = i.Title() + } + title = ansi.Truncate(title, m.Width()-2, "…") if index == m.Index() { style := lipgloss.NewStyle(). diff --git a/internal/ui/model.go b/internal/ui/model.go index 9939162..de72582 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -58,6 +58,7 @@ type Model struct { focus Focus showHelp bool + flatMode bool width, height int } @@ -68,7 +69,12 @@ func NewModel(cfg config.Config, targetBranch string) Model { files, _ := git.ListChangedFiles(targetBranch) t := tree.New(files) - items := t.Items() + var items []list.Item + if cfg.Flat { + items = t.FlatItems() + } else { + items = t.Items() + } delegate := TreeDelegate{Focused: true} l := list.New(items, delegate, 0, 0) @@ -90,6 +96,7 @@ func NewModel(cfg config.Config, targetBranch string) Model { targetBranch: targetBranch, repoName: git.GetRepoName(), showHelp: false, + flatMode: cfg.Flat, inputBuffer: "", pendingZ: false, } @@ -222,7 +229,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.inputBuffer = "" case "enter": - if m.focus == FocusTree { + if m.focus == FocusTree && !m.flatMode { if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && i.IsDir { m.treeState.ToggleExpand(i.FullPath) m.fileList.SetItems(m.treeState.Items()) @@ -255,6 +262,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, openFugitive(m.selectedPath, line) } + case "f": + m.flatMode = !m.flatMode + prevPath := m.selectedPath + if m.flatMode { + m.fileList.SetItems(m.treeState.FlatItems()) + } else { + m.fileList.SetItems(m.treeState.Items()) + } + // Restore selection to the same file if possible + restored := false + for idx, item := range m.fileList.Items() { + if ti, ok := item.(tree.TreeItem); ok && ti.FullPath == prevPath { + m.fileList.Select(idx) + restored = true + break + } + } + // In flat mode, auto-select the first file if nothing was restored + if m.flatMode && !restored { + if first, ok := m.fileList.Items()[0].(tree.TreeItem); ok { + m.fileList.Select(0) + m.selectedPath = first.FullPath + m.diffCursor = 0 + m.diffViewport.GotoTop() + m.inputBuffer = "" + return m, git.DiffCmd(m.targetBranch, m.selectedPath) + } + } + m.inputBuffer = "" + return m, nil + case "z": if m.focus == FocusDiff { m.pendingZ = true @@ -432,7 +470,7 @@ func (m *Model) updateSizes() { // 1 line Top Bar + 1 line Bottom Bar = 2 reserved reservedHeight := 2 if m.showHelp { - reservedHeight += 6 + reservedHeight += 7 } contentHeight := m.height - reservedHeight @@ -471,7 +509,7 @@ func (m Model) View() string { var mainContent string contentHeight := m.height - 2 if m.showHelp { - contentHeight -= 6 + contentHeight -= 7 } if contentHeight < 0 { contentHeight = 0 @@ -658,7 +696,7 @@ func (m Model) renderTopBar() string { } func (m Model) viewStatusBar() string { - shortcuts := StatusKeyStyle.Render("? Help q Quit Tab Switch") + shortcuts := StatusKeyStyle.Render("? Help q Quit Tab Switch f Flat") return StatusBarStyle.Width(m.width).Render(shortcuts) } @@ -678,6 +716,7 @@ func (m Model) renderHelpDrawer() string { col4 := lipgloss.JoinVertical(lipgloss.Left, HelpTextStyle.Render("H/M/L Move Cursor"), HelpTextStyle.Render("e Edit File"), + HelpTextStyle.Render("f Flat Tree"), ) return HelpDrawerStyle.Copy().