From 2bfa8d5b63a5bb50d46c5af97c72f4512cdd9e55 Mon Sep 17 00:00:00 2001 From: Tommy Guo Date: Wed, 28 Jan 2026 12:13:29 -0500 Subject: [PATCH] chore: scaffold project structure for mvp --- README.md | 0 cmd/difi/main.go | 17 ++ go.mod | 2 +- internal/git/client.go | 48 ++++++ internal/tree/builder.go | 118 +++++++++++++ internal/ui/model.go | 134 +++++++++++++++ internal/ui/styles.go | 25 +++ main.go | 346 --------------------------------------- 8 files changed, 343 insertions(+), 347 deletions(-) create mode 100644 README.md create mode 100644 cmd/difi/main.go create mode 100644 internal/git/client.go create mode 100644 internal/tree/builder.go create mode 100644 internal/ui/model.go create mode 100644 internal/ui/styles.go delete mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/cmd/difi/main.go b/cmd/difi/main.go new file mode 100644 index 0000000..2c581fe --- /dev/null +++ b/cmd/difi/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/oug-t/difi/internal/ui" +) + +func main() { + p := tea.NewProgram(ui.NewModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 1776d45..9e26151 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/oug-t/stagi +module github.com/oug-t/difi go 1.25.6 diff --git a/internal/git/client.go b/internal/git/client.go new file mode 100644 index 0000000..cc00c8a --- /dev/null +++ b/internal/git/client.go @@ -0,0 +1,48 @@ +package git + +import ( + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +func ListChangedFiles(targetBranch string) ([]string, error) { + cmd := exec.Command("git", "diff", "--name-only", targetBranch) + out, err := cmd.Output() + if err != nil { + return nil, err + } + + files := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(files) == 1 && files[0] == "" { + return []string{}, nil + } + return files, nil +} + +func DiffCmd(targetBranch, path string) tea.Cmd { + return func() tea.Msg { + out, err := exec.Command("git", "diff", "--color=always", targetBranch, "--", path).Output() + if err != nil { + return DiffMsg{Content: "Error fetching diff: " + err.Error()} + } + return DiffMsg{Content: string(out)} + } +} + +func OpenEditorCmd(path string) tea.Cmd { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vim" + } + c := exec.Command(editor, path) + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr + return tea.ExecProcess(c, func(err error) tea.Msg { + return EditorFinishedMsg{Err: err} + }) +} + +type DiffMsg struct{ Content string } +type EditorFinishedMsg struct{ Err error } diff --git a/internal/tree/builder.go b/internal/tree/builder.go new file mode 100644 index 0000000..34cda02 --- /dev/null +++ b/internal/tree/builder.go @@ -0,0 +1,118 @@ +package tree + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/list" +) + +type TreeItem struct { + Path string + FullPath string + IsDir bool + Depth int +} + +func (i TreeItem) FilterValue() string { return i.FullPath } +func (i TreeItem) Description() string { return "" } +func (i TreeItem) Title() string { + indent := strings.Repeat(" ", i.Depth) + icon := getIcon(i.Path, i.IsDir) + return fmt.Sprintf("%s%s %s", indent, icon, i.Path) +} + +func Build(paths []string) []list.Item { + root := &node{children: make(map[string]*node)} + + for _, path := range paths { + parts := strings.Split(path, "/") + current := root + for i, part := range parts { + if _, exists := current.children[part]; !exists { + isDir := i < len(parts)-1 + fullPath := strings.Join(parts[:i+1], "/") + current.children[part] = &node{ + name: part, + fullPath: fullPath, + children: make(map[string]*node), + isDir: isDir, + } + } + current = current.children[part] + } + } + + var items []list.Item + flatten(root, 0, &items) + return items +} + +type node struct { + name string + fullPath string + children map[string]*node + isDir bool +} + +func flatten(n *node, depth int, items *[]list.Item) { + keys := make([]string, 0, len(n.children)) + for k := range n.children { + keys = append(keys, k) + } + + sort.Slice(keys, func(i, j int) bool { + a, b := n.children[keys[i]], n.children[keys[j]] + if a.isDir && !b.isDir { + return true + } + if !a.isDir && b.isDir { + return false + } + return a.name < b.name + }) + + for _, k := range keys { + child := n.children[k] + *items = append(*items, TreeItem{ + Path: child.name, + FullPath: child.fullPath, + IsDir: child.isDir, + Depth: depth, + }) + if child.isDir { + flatten(child, depth+1, items) + } + } +} + +func getIcon(name string, isDir bool) string { + if isDir { + return "" + } + ext := filepath.Ext(name) + switch strings.ToLower(ext) { + case ".go": + return "" + case ".js", ".ts": + return "" + case ".md": + return "" + case ".json": + return "" + case ".yml", ".yaml": + return "" + case ".html": + return "" + case ".css": + return "" + case ".git": + return "" + case ".dockerfile": + return "" + default: + return "" + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..61fd093 --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,134 @@ +package ui + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/oug-t/difi/internal/git" + "github.com/oug-t/difi/internal/tree" +) + +const TargetBranch = "main" + +type Model struct { + fileTree list.Model + diffViewport viewport.Model + selectedPath string + width, height int +} + +func NewModel() Model { + files, _ := git.ListChangedFiles(TargetBranch) + items := tree.Build(files) + + l := list.New(items, listDelegate{}, 0, 0) + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + + m := Model{ + fileTree: l, + diffViewport: viewport.New(0, 0), + } + + if len(items) > 0 { + if first, ok := items[0].(tree.TreeItem); ok { + m.selectedPath = first.FullPath + } + } + return m +} + +func (m Model) Init() tea.Cmd { + if m.selectedPath != "" { + return git.DiffCmd(TargetBranch, m.selectedPath) + } + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + treeWidth := int(float64(m.width) * 0.25) + m.fileTree.SetSize(treeWidth, m.height) + m.diffViewport.Width = m.width - treeWidth - 2 + m.diffViewport.Height = m.height + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "up", "k", "down", "j": + m.fileTree, cmd = m.fileTree.Update(msg) + cmds = append(cmds, cmd) + if item, ok := m.fileTree.SelectedItem().(tree.TreeItem); ok && !item.IsDir { + if item.FullPath != m.selectedPath { + m.selectedPath = item.FullPath + cmds = append(cmds, git.DiffCmd(TargetBranch, m.selectedPath)) + } + } + case "e": + if m.selectedPath != "" { + return m, git.OpenEditorCmd(m.selectedPath) + } + } + + case git.DiffMsg: + m.diffViewport.SetContent(msg.Content) + + case git.EditorFinishedMsg: + if msg.Err != nil { + } + return m, git.DiffCmd(TargetBranch, m.selectedPath) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if m.width == 0 { + return "Loading..." + } + + treeView := PaneStyle.Copy(). + Width(m.fileTree.Width()). + Height(m.fileTree.Height()). + Render(m.fileTree.View()) + + diffView := DiffStyle.Copy(). + Width(m.diffViewport.Width). + Height(m.diffViewport.Height). + Render(m.diffViewport.View()) + + return lipgloss.JoinHorizontal(lipgloss.Top, treeView, diffView) +} + +type listDelegate struct{} + +func (d listDelegate) Height() int { return 1 } +func (d listDelegate) Spacing() int { return 0 } +func (d listDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d listDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + i, ok := item.(tree.TreeItem) + if !ok { + return + } + + str := i.Title() + if index == m.Index() { + fmt.Fprint(w, SelectedItemStyle.Render("│ "+str)) + } else { + fmt.Fprint(w, ItemStyle.Render(str)) + } +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..fb5faf6 --- /dev/null +++ b/internal/ui/styles.go @@ -0,0 +1,25 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +var ( + ColorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + ColorSelected = lipgloss.AdaptiveColor{Light: "#1F1F1F", Dark: "#F8F8F2"} + ColorInactive = lipgloss.AdaptiveColor{Light: "#A8A8A8", Dark: "#626262"} + ColorAccent = lipgloss.AdaptiveColor{Light: "#00BCF0", Dark: "#00BCF0"} // Cyan + + PaneStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, true, false, false). + BorderForeground(ColorBorder). + MarginRight(1) + + DiffStyle = lipgloss.NewStyle(). + Padding(0, 1) + + ItemStyle = lipgloss.NewStyle().PaddingLeft(1) + + SelectedItemStyle = lipgloss.NewStyle(). + PaddingLeft(0). + Foreground(ColorSelected). + Bold(true) +) diff --git a/main.go b/main.go deleted file mode 100644 index 619cb16..0000000 --- a/main.go +++ /dev/null @@ -1,346 +0,0 @@ -package main - -import ( - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -const targetBranch = "main" - -// --- STYLES --- - -var ( - // Modern, Clean Theme - colorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} - colorSelected = lipgloss.AdaptiveColor{Light: "#1F1F1F", Dark: "#F8F8F2"} - colorInactive = lipgloss.AdaptiveColor{Light: "#A8A8A8", Dark: "#626262"} - colorAccent = lipgloss.AdaptiveColor{Light: "#00BCF0", Dark: "#00BCF0"} // Dash-like Cyan - - // Panes - treeStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, true, false, false). // Right border only - BorderForeground(colorBorder). - MarginRight(1) - - diffStyle = lipgloss.NewStyle(). - Padding(0, 1) - - // Tree Items - itemStyle = lipgloss.NewStyle().PaddingLeft(1) - selectedItemStyle = lipgloss.NewStyle(). - PaddingLeft(0). - Foreground(colorSelected). - Bold(true) -) - -// --- DATA STRUCTURES --- - -type node struct { - name string - fullPath string - children map[string]*node - isDir bool -} - -type treeItem struct { - path string - fullPath string - isDir bool - depth int -} - -func (i treeItem) FilterValue() string { return i.fullPath } -func (i treeItem) Description() string { return "" } - -// Title renders the Nerd Font icon + filename -func (i treeItem) Title() string { - indent := strings.Repeat(" ", i.depth) - icon := getIcon(i.path, i.isDir) - return fmt.Sprintf("%s%s %s", indent, icon, i.path) -} - -// --- NERD FONT ICON MAPPING --- - -func getIcon(name string, isDir bool) string { - if isDir { - return "" // Folder icon - } - - ext := filepath.Ext(name) - switch strings.ToLower(ext) { - case ".go": - return "" // Go Gopher - case ".js", ".ts": - return "" // JS/Node - case ".md": - return "" // Markdown - case ".json": - return "" // JSON - case ".yml", ".yaml": - return "" // Settings/Config - case ".html": - return "" - case ".css": - return "" - case ".git": - return "" // Git - case ".dockerfile", "dockerfile": - return "" // Docker - default: - return "" // Default File - } -} - -// --- DELEGATE (CUSTOM RENDERER) --- - -type delegate struct{} - -func (d delegate) Height() int { return 1 } -func (d delegate) Spacing() int { return 0 } -func (d delegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -func (d delegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - i, ok := item.(treeItem) - if !ok { - return - } - - str := i.Title() - - // If selected, add a border or indicator - if index == m.Index() { - fmt.Fprint(w, selectedItemStyle.Render("│ "+str)) - } else { - // Render unselected items with subtle gray - fmt.Fprint(w, itemStyle.Render(str)) - } -} - -// --- MODEL --- - -type model struct { - fileTree list.Model - diffViewport viewport.Model - selectedPath string - width, height int -} - -func initialModel() model { - // 1. Fetch changed files - // Compares HEAD against targetBranch - cmd := exec.Command("git", "diff", "--name-only", targetBranch) - out, _ := cmd.Output() - filePaths := strings.Split(strings.TrimSpace(string(out)), "\n") - - // Handle empty diff case - if len(filePaths) == 1 && filePaths[0] == "" { - filePaths = []string{} - } - - // 2. Build Tree - items := buildTree(filePaths) - - // 3. Configure List - l := list.New(items, delegate{}, 0, 0) - l.SetShowTitle(false) - l.SetShowHelp(false) - l.SetShowStatusBar(false) - l.SetFilteringEnabled(false) - - vp := viewport.New(0, 0) - - m := model{ - fileTree: l, - diffViewport: vp, - } - - // Select first file if available - if len(items) > 0 { - if first, ok := items[0].(treeItem); ok { - m.selectedPath = first.fullPath - } - } - - return m -} - -func (m model) Init() tea.Cmd { - if m.selectedPath != "" { - return fetchDiff(m.selectedPath) - } - return nil -} - -// --- UPDATE --- - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - - // Layout: 25% Tree (Fixed Left), Rest Diff - treeWidth := int(float64(m.width) * 0.25) - diffWidth := m.width - treeWidth - 2 - - m.fileTree.SetSize(treeWidth, m.height) - m.diffViewport.Width = diffWidth - m.diffViewport.Height = m.height - - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - - case "up", "k", "down", "j": - m.fileTree, cmd = m.fileTree.Update(msg) - cmds = append(cmds, cmd) - - // Fetch diff only if actual file selected - if item, ok := m.fileTree.SelectedItem().(treeItem); ok && !item.isDir { - if item.fullPath != m.selectedPath { - m.selectedPath = item.fullPath - cmds = append(cmds, fetchDiff(m.selectedPath)) - } - } - - case "e": - // Edit in NVIM - if m.selectedPath != "" { - return m, openEditor(m.selectedPath) - } - } - - case diffMsg: - m.diffViewport.SetContent(string(msg)) - - case editorFinishedMsg: - return m, fetchDiff(m.selectedPath) - } - - return m, tea.Batch(cmds...) -} - -// --- VIEW --- - -func (m model) View() string { - if m.width == 0 { - return "Loading..." - } - - treeView := treeStyle.Copy(). - Width(m.fileTree.Width()). - Height(m.fileTree.Height()). - Render(m.fileTree.View()) - - diffView := diffStyle.Copy(). - Width(m.diffViewport.Width). - Height(m.diffViewport.Height). - Render(m.diffViewport.View()) - - return lipgloss.JoinHorizontal(lipgloss.Top, treeView, diffView) -} - -// --- COMMANDS --- - -type diffMsg string -type editorFinishedMsg struct{ err error } - -func fetchDiff(path string) tea.Cmd { - return func() tea.Msg { - // Use --color=always to let git handle syntax highlighting - out, _ := exec.Command("git", "diff", "--color=always", targetBranch, "--", path).Output() - return diffMsg(out) - } -} - -func openEditor(path string) tea.Cmd { - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "vim" - } - c := exec.Command(editor, path) - c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr - return tea.ExecProcess(c, func(err error) tea.Msg { - return editorFinishedMsg{err} - }) -} - -// --- TREE ALGORITHMS --- - -func buildTree(paths []string) []list.Item { - root := &node{children: make(map[string]*node)} - - for _, path := range paths { - parts := strings.Split(path, "/") - current := root - for i, part := range parts { - if _, exists := current.children[part]; !exists { - isDir := i < len(parts)-1 - fullPath := strings.Join(parts[:i+1], "/") - current.children[part] = &node{ - name: part, - fullPath: fullPath, - children: make(map[string]*node), - isDir: isDir, - } - } - current = current.children[part] - } - } - - var items []list.Item - flatten(root, 0, &items) - return items -} - -func flatten(n *node, depth int, items *[]list.Item) { - keys := make([]string, 0, len(n.children)) - for k := range n.children { - keys = append(keys, k) - } - - sort.Slice(keys, func(i, j int) bool { - a, b := n.children[keys[i]], n.children[keys[j]] - if a.isDir && !b.isDir { - return true - } - if !a.isDir && b.isDir { - return false - } - return a.name < b.name - }) - - for _, k := range keys { - child := n.children[k] - *items = append(*items, treeItem{ - path: child.name, - fullPath: child.fullPath, - isDir: child.isDir, - depth: depth, - }) - if child.isDir { - flatten(child, depth+1, items) - } - } -} - -func main() { - p := tea.NewProgram(initialModel(), tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - fmt.Println(err) - os.Exit(1) - } -}