diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..2f05af9 --- /dev/null +++ b/.envrc @@ -0,0 +1,9 @@ +#!/bin/bash + +# Automatically sets up your devbox environment whenever you cd into this +# directory via our direnv integration: + +eval "$(devbox generate direnv --print-envrc)" + +# check out https://www.jetpack.io/devbox/docs/ide_configuration/direnv/ +# for more details diff --git a/.gh-dash.yml b/.gh-dash.yml new file mode 100644 index 0000000..8c62e1e --- /dev/null +++ b/.gh-dash.yml @@ -0,0 +1,86 @@ +# yaml-language-server: $schema=https://dlvhdr.github.io/diffnav/configuration/gh-dash/schema.json +prSections: + - title: Mine + filters: is:open author:@me repo:dlvhdr/diffnav updated:>={{ nowModify "-3w" }} sort:updated-desc + layout: + author: + hidden: true + - title: Review + filters: repo:dlvhdr/diffnav -author:@me is:open updated:>={{ nowModify "-2.5w" }} + - title: All + filters: repo:dlvhdr/diffnav +issuesSections: + - title: Open + filters: author:@me repo:dlvhdr/diffnav is:open -author:@me sort:reactions + - title: Creator + filters: author:@me repo:dlvhdr/diffnav is:open + - title: All + filters: repo:dlvhdr/diffnav sort:reactions + +pager: + diff: diffnav +defaults: + view: prs + refetchIntervalMinutes: 5 + layout: + prs: + repoName: + grow: true, + width: 10 + hidden: false + base: + hidden: true + + preview: + open: true + width: 84 + prsLimit: 20 + issuesLimit: 20 +repoPaths: + dlvhdr/*: ~/code/personal/* + +keybindings: + universal: + - key: g + name: lazygit + command: > + cd {{.RepoPath}} && lazygit + prs: + - key: O + builtin: checkout + - key: m + command: gh pr merge --admin --repo {{.RepoName}} {{.PrNumber}} + - key: C + name: code review + command: > + tmux new-window -c {{.RepoPath}} ' + nvim -c ":silent Octo pr edit {{.PrNumber}}" + ' + - key: a + name: lazygit add + command: > + cd {{.RepoPath}} && git add -A && lazygit + - key: v + name: approve + command: > + gh pr review --repo {{.RepoName}} --approve --body "$(gum input --prompt='Approval Comment: ')" {{.PrNumber}} + +theme: + ui: + sectionsShowCount: true + table: + compact: false + colors: + text: + primary: "#E2E1ED" + secondary: "#666CA6" + inverted: "#242347" + faint: "#B0B3BF" + warning: "#E0AF68" + success: "#3DF294" + background: + selected: "#1B1B33" + border: + primary: "#383B5B" + secondary: "#39386B" + faint: "#2B2B40" diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6e41a4c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [dlvhdr] diff --git a/README.md b/README.md index 85297b3..f5ddcf8 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A git diff pager based on [delta](https://github.com/dandavison/delta) but with a file tree, à la GitHub.

- +

> [!CAUTION] @@ -63,6 +63,7 @@ git config --global pager.diff diffnav | Ctrl-u | Scroll the diff up | | e | Toggle the file tree | | t | Search/go-to file | +| y | Copy file path | | q | Quit | ## Under the hood diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..8957906 --- /dev/null +++ b/devbox.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.14.0/.schema/devbox.schema.json", + "packages": { + "go": "1.22.6", + "gopls": "latest", + "golangci-lint": "latest", + "svu": "latest" + }, + "shell": { + "scripts": { + "test": ["echo \"Error: no test specified\" && exit 1"] + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..16d1923 --- /dev/null +++ b/devbox.lock @@ -0,0 +1,200 @@ +{ + "lockfile_version": "1", + "packages": { + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "resolved": "github:NixOS/nixpkgs/2bfc080955153be0be56724be6fa5477b4eefabb?lastModified=1743689281&narHash=sha256-y7Hg5lwWhEOgflEHRfzSH96BOt26LaYfrYWzZ%2BVoVdg%3D" + }, + "go@1.22.6": { + "last_modified": "2024-08-31T10:12:23Z", + "resolved": "github:NixOS/nixpkgs/5629520edecb69630a3f4d17d3d33fc96c13f6fe#go", + "source": "devbox-search", + "version": "1.22.6", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/bman2jjx2ykfclj3g0wb89cxyzqygh8y-go-1.22.6", + "default": true + } + ], + "store_path": "/nix/store/bman2jjx2ykfclj3g0wb89cxyzqygh8y-go-1.22.6" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/gnm672jywl1b778ql6pf57xka45452b6-go-1.22.6", + "default": true + } + ], + "store_path": "/nix/store/gnm672jywl1b778ql6pf57xka45452b6-go-1.22.6" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/qvr3slzx5av20xkw6i97yz7wla9sf4nc-go-1.22.6", + "default": true + } + ], + "store_path": "/nix/store/qvr3slzx5av20xkw6i97yz7wla9sf4nc-go-1.22.6" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/6rybf4g5b77kz27k07avr7qd44ssw3l2-go-1.22.6", + "default": true + } + ], + "store_path": "/nix/store/6rybf4g5b77kz27k07avr7qd44ssw3l2-go-1.22.6" + } + } + }, + "golangci-lint@latest": { + "last_modified": "2025-03-25T17:32:05Z", + "resolved": "github:NixOS/nixpkgs/25d1b84f5c90632a623c48d83a2faf156451e6b1#golangci-lint", + "source": "devbox-search", + "version": "2.0.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/idv9cyl4i6w9n4sgc29kvqhywm04n1rz-golangci-lint-2.0.0", + "default": true + } + ], + "store_path": "/nix/store/idv9cyl4i6w9n4sgc29kvqhywm04n1rz-golangci-lint-2.0.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/r0jxwvqvk2999dx04v3j9jgd46jscqc4-golangci-lint-2.0.0", + "default": true + } + ], + "store_path": "/nix/store/r0jxwvqvk2999dx04v3j9jgd46jscqc4-golangci-lint-2.0.0" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/lsyy8arab3zvkpi8lr9303mf88y5k1rc-golangci-lint-2.0.0", + "default": true + } + ], + "store_path": "/nix/store/lsyy8arab3zvkpi8lr9303mf88y5k1rc-golangci-lint-2.0.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/834gvbmhpwvy1d65r5x4xihkxm4g91ab-golangci-lint-2.0.0", + "default": true + } + ], + "store_path": "/nix/store/834gvbmhpwvy1d65r5x4xihkxm4g91ab-golangci-lint-2.0.0" + } + } + }, + "gopls@latest": { + "last_modified": "2025-03-25T17:32:05Z", + "resolved": "github:NixOS/nixpkgs/25d1b84f5c90632a623c48d83a2faf156451e6b1#gopls", + "source": "devbox-search", + "version": "0.18.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/1yhl06d3rp0v9nzj9w11d91mzdib9li0-gopls-0.18.1", + "default": true + } + ], + "store_path": "/nix/store/1yhl06d3rp0v9nzj9w11d91mzdib9li0-gopls-0.18.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/f276ys7dbl1c4h5i3yc1yavwn4vkznpm-gopls-0.18.1", + "default": true + } + ], + "store_path": "/nix/store/f276ys7dbl1c4h5i3yc1yavwn4vkznpm-gopls-0.18.1" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/idmdsky8xd2dcwkiwsxka50lwmp6f9s3-gopls-0.18.1", + "default": true + } + ], + "store_path": "/nix/store/idmdsky8xd2dcwkiwsxka50lwmp6f9s3-gopls-0.18.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/2w4s5abaqwm45wkkwwf15yyw5dcrqs7m-gopls-0.18.1", + "default": true + } + ], + "store_path": "/nix/store/2w4s5abaqwm45wkkwwf15yyw5dcrqs7m-gopls-0.18.1" + } + } + }, + "svu@latest": { + "last_modified": "2025-04-17T05:47:26Z", + "resolved": "github:NixOS/nixpkgs/ebe4301cbd8f81c4f8d3244b3632338bbeb6d49c#svu", + "source": "devbox-search", + "version": "3.2.3", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/djfi0c8vvpsfssadwls6y55zyhkif58c-svu-3.2.3", + "default": true + } + ], + "store_path": "/nix/store/djfi0c8vvpsfssadwls6y55zyhkif58c-svu-3.2.3" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/2jy02ldq76r8r3slbcc1cb1dbxahraxr-svu-3.2.3", + "default": true + } + ], + "store_path": "/nix/store/2jy02ldq76r8r3slbcc1cb1dbxahraxr-svu-3.2.3" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/jx3ilvqwkcjmfh9kccyx1rbm06lgfa00-svu-3.2.3", + "default": true + } + ], + "store_path": "/nix/store/jx3ilvqwkcjmfh9kccyx1rbm06lgfa00-svu-3.2.3" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/vmdd4wxfbgp9xiv6icyzsxi36f4i31j8-svu-3.2.3", + "default": true + } + ], + "store_path": "/nix/store/vmdd4wxfbgp9xiv6icyzsxi36f4i31j8-svu-3.2.3" + } + } + } + } +} diff --git a/filetree_test.go b/filetree_test.go deleted file mode 100644 index 9d3f1e4..0000000 --- a/filetree_test.go +++ /dev/null @@ -1,335 +0,0 @@ -package main - -import ( - "testing" - - "github.com/charmbracelet/lipgloss/tree" -) - -func TestEmptyTree(t *testing.T) { - files := []string{} - - want := `.` - got := buildFullFileTree(files).String() - - if got != want { - t.Errorf("files:\n%v\n\n------- want:\n%v\n\n-------got:\n%v\n", files, want, got) - } -} - -func TestBuildTreeSingleFile(t *testing.T) { - files := []string{ - "main.go", - } - - want := `. -└── main.go` - got := buildFullFileTree(files).String() - - if got != want { - t.Errorf("files:\n%v\n\n------- want:\n%v\n\n-------got:\n%v\n", files, want, got) - } -} - -func TestFileWithinDir(t *testing.T) { - files := []string{"ui/main.go"} - - want := tree.Root("ui"). - Child("main.go") - got := buildFullFileTree(files) - got = collapseTree(got) - compareTree(t, want, got) -} - -func TestFileWithinNestedDirAndRootFile(t *testing.T) { - files := []string{"ui/components/main.go", "cmd.go"} - - want := tree.Root("."). - Child("cmd.go"). - Child( - tree.Root("ui/components"). - Child("main.go")) - - got := buildFullFileTree(files) - got = collapseTree(got) - compareTree(t, want, got) -} - -func TestFileWithMultipleFiles(t *testing.T) { - files := []string{"main.go", "cmd.go"} - - want := tree.Root("."). - Child("cmd.go"). - Child("main.go") - - got := buildFullFileTree(files) - got = collapseTree(got) - compareTree(t, want, got) -} - -func TestFileWithNestedDirWithFile(t *testing.T) { - files := []string{"components/main.go", "components/subdir/comp.go"} - - want := tree.Root("."). - Child(tree.Root("components"). - Child("main.go"). - Child(tree.Root("subdir"). - Child("comp.go"))) - got := buildFullFileTree(files).String() - - if got != want.String() { - t.Errorf("files:\n%v\n\n------- want:\n%v\n\n-------got:\n%v\n", files, want, got) - } -} - -func TestDeeplyNestedFile(t *testing.T) { - files := []string{"components/main.go", "components/subdir/comp.go", "components/subdir/subsubdir/deepcomp.go"} - - want := tree.Root("."). - Child(tree.Root("components"). - Child("main.go"). - Child(tree.Root("subdir"). - Child("comp.go"). - Child(tree.Root("subsubdir"). - Child("deepcomp.go")))) - got := buildFullFileTree(files).String() - - if got != want.String() { - t.Errorf("files:\n%v\n\n------- want:\n%v\n\n-------got:\n%v\n", files, want, got) - } -} - -func TestComplex(t *testing.T) { - files := []string{ - "ui/components/a.go", - "ui/components/b.go", - "ui/components/sub/c.go", - "ui/main.go", - "utils/misc/pointers.go", - "utils/misc/sorters.go", - "pkg/internal/ws.go", - } - - want := tree.Root("."). - Child(tree.Root("pkg/internal"). - Child("ws.go")). - Child(tree.Root("ui"). - Child("main.go"). - Child(tree.Root("components"). - Child("a.go"). - Child("b.go"). - Child(tree.Root("sub"). - Child("c.go")))). - Child(tree.Root("utils/misc"). - Child("pointers.go"). - Child("sorters.go")) - got := buildFullFileTree(files) - got = collapseTree(got) - compareTree(t, want, got) -} - -func TestDirectChild(t *testing.T) { - files := []string{"main.go"} - want := tree.Root(".").Child("main.go").String() - got := buildFullFileTree(files).String() - - if got != want { - t.Errorf("want:\n%v\n\n-------got:\n%v\n", want, got) - } -} - -func TestChildWithMultiDirectory(t *testing.T) { - files := []string{"ui/components/subdir/comp.go", "ui/main.go"} - want := tree.Root("ui"). - Child("main.go"). - Child(tree.Root("components/subdir"). - Child("comp.go")) - got := buildFullFileTree(files) - got = collapseTree(got) - compareTree(t, want, got) -} - -func TestCommonAncestor(t *testing.T) { - files := []string{ - "ui/components/subdir/section.go", - "ui/components/subdir/pr.go", - "ui/components/tasks/task/task.go", - } - want := tree.Root("ui/components"). - Child(tree.Root("subdir"). - Child("pr.go"). - Child("section.go")). - Child(tree.Root("tasks/task"). - Child("task.go")) - - got := buildFullFileTree(files) - got = collapseTree(got) - compareTree(t, want, got) -} - -func TestCommonAncestorSorting(t *testing.T) { - files := []string{ - "ui/comp/subdir/pr.go", - "ui/z/section.go", - } - want := tree.Root("."). - Child(tree.Root("ui"). - Child(tree.Root("comp"). - Child(tree.Root("subdir").Child("pr.go"))). - Child(tree.Root("z"). - Child("section.go")), - ). - String() - - got := buildFullFileTree(files).String() - if got != want { - t.Errorf("want:\n%v\n\n-------got:\n%v\n", want, got) - } -} - -func TestGhData(t *testing.T) { - files := []string{ - "ui/components/reposection/commands.go", - "ui/components/reposection/reposection.go", - "ui/components/section/section.go", - "ui/components/tasks/pr.go", - "ui/keys/branchKeys.go", - } - want := tree.Root("."). - Child(tree.Root("ui"). - Child(tree.Root("components"). - Child(tree.Root("reposection"). - Child("commands.go"). - Child("reposection.go")). - Child(tree.Root("section"). - Child("section.go")). - Child(tree.Root("tasks"). - Child("pr.go"))). - Child(tree.Root("keys"). - Child("branchKeys.go"))).String() - - got := buildFullFileTree(files).String() - if got != want { - t.Errorf("want:\n%v\n\n-------got:\n%v\n", want, got) - } -} - -func TestGetDirStructureOneFile(t *testing.T) { - files := []string{ - "ui/main.go", - } - want := tree.Root("."). - Child(tree.Root("ui"). - Child("main.go")).String() - - got := buildFullFileTree(files).String() - if got != want { - t.Errorf("want:\n%v\n\n-------got:\n%v\n", want, got) - } -} - -func TestGetDirStructureTwoUnrelatedFiles(t *testing.T) { - files := []string{ - "ui/main.go", - "pkg/cmd.go", - } - want := tree.Root("."). - Child(tree.Root("pkg"). - Child("cmd.go")). - Child(tree.Root("ui"). - Child("main.go")).String() - - got := buildFullFileTree(files).String() - if got != want { - t.Errorf("want:\n%v\n\n-------got:\n%v\n", want, got) - } -} - -func TestBuildFullComplexTree(t *testing.T) { - files := []string{ - "ui/components/reposection/commands.go", - "ui/components/reposection/reposection.go", - "ui/components/section/section.go", - "ui/components/tasks/pr.go", - "ui/keys/branchKeys.go", - } - want := tree.Root("."). - Child(tree.Root("ui"). - Child(tree.Root("components"). - Child(tree.Root("reposection"). - Child("commands.go"). - Child("reposection.go")). - Child(tree.Root("section"). - Child("section.go")). - Child(tree.Root("tasks"). - Child("pr.go"))). - Child(tree.Root("keys"). - Child("branchKeys.go"))).String() - - got := buildFullFileTree(files).String() - - if got != want { - t.Errorf("want:\n%v\n\n-------got:\n%v\n", want, got) - } -} - -func TestCollapseUncollapsibleTree(t *testing.T) { - input := tree.Root("."). - Child(tree.Root("pkg"). - Child("cmd.go")). - Child(tree.Root("ui"). - Child("main.go")) - want := tree.Root("."). - Child(tree.Root("pkg"). - Child("cmd.go")). - Child(tree.Root("ui"). - Child("main.go")).String() - - collapseTree(input) - if input.String() != want { - t.Errorf("want:\n%v\n\n-------got:\n%v\n", want, input) - } -} - -func TestCollapsibleComplexTree(t *testing.T) { - input := tree.Root("."). - Child(tree.Root("ui"). - Child(tree.Root("components"). - Child(tree.Root("reposection"). - Child("commands.go")). - Child(tree.Root("tasks"). - Child("pr.go")))) - - want := tree.Root("ui/components"). - Child(tree.Root("reposection"). - Child("commands.go")). - Child(tree.Root("tasks"). - Child("pr.go")) - - got := collapseTree(input) - if got.String() != want.String() { - t.Errorf("want:\n%v\n\n-------got:\n%v\n", want, got) - } -} - -func TestCollapsibleTree(t *testing.T) { - input := tree.Root("."). - Child(tree.Root("ui"). - Child(tree.Root("subdir"). - Child("pr.go"). - Child("section.go"))) - want := tree.Root("ui/subdir"). - Child("pr.go"). - Child("section.go") - - got := collapseTree(input) - if got.String() != want.String() { - t.Errorf("want:\n%v\n\n-------got:\n%v\n", want, got) - } -} - -func compareTree(t *testing.T, want, got tree.Node) { - if got.String() != want.String() { - t.Errorf("want:\n%v\n\n-------got:\n%v\n", want, got) - } -} diff --git a/main.go b/main.go index 9f7cf6b..c9fd82e 100644 --- a/main.go +++ b/main.go @@ -5,336 +5,17 @@ import ( "fmt" "io" "os" - "path/filepath" - "slices" "strings" "time" - "github.com/bluekeyes/go-gitdiff/gitdiff" - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/log" "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" - "github.com/dlvhdr/diffnav/pkg/constants" - filetree "github.com/dlvhdr/diffnav/pkg/file_tree" - "github.com/dlvhdr/diffnav/pkg/utils" + "github.com/dlvhdr/diffnav/pkg/ui" ) -type mainModel struct { - input string - files []*gitdiff.File - cursor int - fileTree tea.Model - diffViewer tea.Model - width int - height int - isShowingFileTree bool - search textinput.Model - help help.Model - resultsVp viewport.Model - resultsCursor int - searching bool - filtered []string -} - -func newModel(input string) mainModel { - m := mainModel{input: input, isShowingFileTree: true} - m.fileTree = initialFileTreeModel() - m.diffViewer = initialDiffModel() - - m.help = help.New() - helpSt := lipgloss.NewStyle() - m.help.ShortSeparator = " · " - m.help.Styles.ShortKey = helpSt - m.help.Styles.ShortDesc = helpSt - m.help.Styles.ShortSeparator = helpSt - m.help.Styles.ShortKey = helpSt.Foreground(lipgloss.Color("254")) - m.help.Styles.ShortDesc = helpSt - m.help.Styles.ShortSeparator = helpSt - m.help.Styles.Ellipsis = helpSt - - m.search = textinput.New() - m.search.ShowSuggestions = true - m.search.KeyMap.AcceptSuggestion = key.NewBinding(key.WithKeys("tab")) - m.search.Prompt = " " - m.search.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - m.search.Placeholder = "Filter files 🅃" - m.search.PlaceholderStyle = lipgloss.NewStyle().MaxWidth(lipgloss.Width(m.search.Placeholder)).Foreground(lipgloss.Color("8")) - m.search.Width = constants.OpenFileTreeWidth - 5 - - m.resultsVp = viewport.Model{} - - return m -} - -func (m mainModel) Init() tea.Cmd { - return tea.Batch(tea.EnterAltScreen, m.fetchFileTree, m.diffViewer.Init()) -} - -func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - - if m.search.Focused() { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "esc": - df, dfCmd := m.setDiffViewerDimensions() - cmds = append(cmds, dfCmd) - m.diffViewer = df - m.search.SetValue("") - m.search.Blur() - m.searching = false - break - case "ctrl+c": - return m, tea.Quit - case "enter": - m.searching = false - m.search.SetValue("") - m.search.Blur() - selected := m.filtered[m.resultsCursor] - df, dfCmd := m.setDiffViewerDimensions() - cmds = append(cmds, dfCmd) - m.diffViewer = df - for i, f := range m.files { - if filetree.GetFileName(f) == selected { - m.cursor = i - m.diffViewer, cmd = m.diffViewer.(diffModel).SetFilePatch(f) - cmds = append(cmds, cmd) - break - } - } - - return m, tea.Batch(cmds...) - case "ctrl+n", "down": - m.resultsCursor = min(len(m.files)-1, m.resultsCursor+1) - m.resultsVp.LineDown(1) - case "ctrl+p", "up": - m.resultsCursor = max(0, m.resultsCursor-1) - m.resultsVp.LineUp(1) - default: - m.resultsCursor = 0 - } - } - s, sc := m.search.Update(msg) - cmds = append(cmds, sc) - m.search = s - filtered := make([]string, 0) - for _, f := range m.files { - if strings.Contains(strings.ToLower(filetree.GetFileName(f)), strings.ToLower(m.search.Value())) { - filtered = append(filtered, filetree.GetFileName(f)) - } - } - m.filtered = filtered - m.resultsVp.SetContent(m.resultsView()) - - return m, tea.Batch(cmds...) - } - - switch msg := msg.(type) { - case tea.KeyMsg: - if m.searching { - switch msg.String() { - case "ctrl+n": - if m.searching { - m.resultsCursor = min(len(m.files)-1, m.resultsCursor+1) - m.resultsVp.LineDown(1) - } - case "ctrl+p": - if m.searching { - m.resultsCursor = max(0, m.resultsCursor-1) - m.resultsVp.LineUp(1) - } - } - } - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "t": - m.searching = true - m.search.Width = m.sidebarWidth() - 5 - m.search.SetValue("") - m.resultsCursor = 0 - m.filtered = make([]string, 0) - m.resultsVp.SetContent(m.resultsView()) - m.resultsVp.Height = m.height - footerHeight - headerHeight - searchHeight - m.resultsVp.Width = constants.SearchingFileTreeWidth - df, dfCmd := m.setDiffViewerDimensions() - cmds = append(cmds, dfCmd) - m.diffViewer = df - cmds = append(cmds, m.search.Focus()) - return m, tea.Batch(cmds...) - case "e": - m.isShowingFileTree = !m.isShowingFileTree - df, dfCmd := m.setDiffViewerDimensions() - m.diffViewer = df - return m, dfCmd - case "up", "k", "ctrl+p": - if m.cursor > 0 { - m.cursor-- - m.diffViewer, cmd = m.diffViewer.(diffModel).SetFilePatch(m.files[m.cursor]) - cmds = append(cmds, cmd) - } - case "down", "j", "ctrl+n": - if m.cursor < len(m.files)-1 { - m.cursor++ - m.diffViewer, cmd = m.diffViewer.(diffModel).SetFilePatch(m.files[m.cursor]) - cmds = append(cmds, cmd) - } - } - - case tea.WindowSizeMsg: - m.help.Width = msg.Width - m.width = msg.Width - m.height = msg.Height - df, dfCmd := m.diffViewer.(diffModel).Update(dimensionsMsg{Width: m.width - m.sidebarWidth(), Height: m.height - footerHeight - headerHeight}) - m.diffViewer = df - cmds = append(cmds, dfCmd) - ft, ftCmd := m.fileTree.(ftModel).Update(dimensionsMsg{Width: m.sidebarWidth(), Height: m.height - footerHeight - headerHeight - searchHeight}) - m.fileTree = ft - cmds = append(cmds, ftCmd) - - case fileTreeMsg: - m.files = msg.files - if len(m.files) == 0 { - return m, tea.Quit - } - m.fileTree = m.fileTree.(ftModel).SetFiles(m.files) - m.diffViewer, cmd = m.diffViewer.(diffModel).SetFilePatch(m.files[0]) - cmds = append(cmds, cmd) - - case errMsg: - fmt.Printf("Error: %v\n", msg.err) - log.Fatal(msg.err) - } - - s, sCmd := m.search.Update(msg) - cmds = append(cmds, sCmd) - m.search = s - m.search.Width = m.sidebarWidth() - 5 - - m.fileTree = m.fileTree.(ftModel).SetCursor(m.cursor) - - m.diffViewer, cmd = m.diffViewer.Update(msg) - cmds = append(cmds, cmd) - - m.fileTree, cmd = m.fileTree.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m mainModel) View() string { - header := lipgloss.NewStyle().Width(m.width). - Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(lipgloss.Color("8")). - Foreground(lipgloss.Color("6")). - Render("󰊢 🅳 🅸 🅵 🅵 🅽 🅰 🆅 ") - footer := m.footerView() - - sidebar := "" - if m.isShowingFileTree { - search := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")). - MaxHeight(3). - Width(m.sidebarWidth() - 2). - Render(m.search.View()) - - content := "" - width := m.sidebarWidth() - if m.searching { - content = m.resultsVp.View() - } else { - content = m.fileTree.View() - } - - content = lipgloss.NewStyle(). - Width(width). - Height(m.height - footerHeight - headerHeight).Render(lipgloss.JoinVertical(lipgloss.Left, search, content)) - - sidebar = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, true, false, false). - BorderForeground(lipgloss.Color("8")).Render(content) - } - dv := lipgloss.NewStyle().MaxHeight(m.height - footerHeight - headerHeight).Width(m.width - m.sidebarWidth()).Render(m.diffViewer.View()) - content := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, dv) - return lipgloss.JoinVertical(lipgloss.Left, header, content, footer) -} - -type dimensionsMsg struct { - Width int - Height int -} - -func (m mainModel) fetchFileTree() tea.Msg { - // TODO: handle error - files, _, err := gitdiff.Parse(strings.NewReader(m.input + "\n")) - if err != nil { - return errMsg{err} - } - sortFiles(files) - - return fileTreeMsg{files: files} -} - -type fileTreeMsg struct { - files []*gitdiff.File -} - -func sortFiles(files []*gitdiff.File) { - slices.SortFunc(files, func(a *gitdiff.File, b *gitdiff.File) int { - nameA := filetree.GetFileName(a) - nameB := filetree.GetFileName(b) - dira := filepath.Dir(nameA) - dirb := filepath.Dir(nameB) - if dira != "." && dirb != "." && dira == dirb { - return strings.Compare(strings.ToLower(nameA), strings.ToLower(nameB)) - } - - if dira != "." && dirb == "." { - return -1 - } - if dirb != "." && dira == "." { - return 1 - } - - if dira != "." && dirb != "." { - if strings.HasPrefix(dira, dirb) { - return -1 - } - - if strings.HasPrefix(dirb, dira) { - return 1 - } - } - - return strings.Compare(strings.ToLower(nameA), strings.ToLower(nameB)) - }) -} - -const ( - footerHeight = 2 - headerHeight = 2 - searchHeight = 10 -) - -func (m mainModel) footerView() string { - return lipgloss.NewStyle(). - Width(m.width). - Border(lipgloss.NormalBorder(), true, false, false, false). - BorderForeground(lipgloss.Color("8")). - Height(1). - Render(m.help.ShortHelpView(getKeys())) - -} - func main() { stat, err := os.Stdin.Stat() if err != nil { @@ -342,20 +23,34 @@ func main() { } if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 { - fmt.Println("Try piping in some text.") - os.Exit(1) + fmt.Println("No diff, exiting") + os.Exit(0) } - var fileErr error - logFile, fileErr := os.OpenFile("debug.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) - if fileErr == nil { - log.SetOutput(logFile) - log.SetTimeFormat(time.Kitchen) - log.SetReportCaller(true) - log.SetLevel(log.DebugLevel) + if os.Getenv("DEBUG") == "true" { + var fileErr error + logFile, fileErr := os.OpenFile("debug.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if fileErr != nil { + fmt.Println("Error opening debug.log:", fileErr) + os.Exit(1) + } defer logFile.Close() - log.SetOutput(logFile) - log.Debug("Starting diffnav, logging to debug.log") + + if fileErr == nil { + log.SetOutput(logFile) + log.SetTimeFormat(time.Kitchen) + log.SetReportCaller(true) + log.SetLevel(log.DebugLevel) + + log.SetOutput(logFile) + log.SetColorProfile(termenv.TrueColor) + wd, err := os.Getwd() + if err != nil { + fmt.Println("Error getting current working dir", err) + os.Exit(1) + } + log.Debug("🚀 Starting diffnav", "logFile", wd+string(os.PathSeparator)+logFile.Name()) + } } reader := bufio.NewReader(os.Stdin) @@ -373,43 +68,14 @@ func main() { } } - if os.Getenv("DEBUG") == "true" { - logger, _ := tea.LogToFile("debug.log", "debug") - defer logger.Close() - } - input := ansi.Strip(b.String()) - p := tea.NewProgram(newModel(input), tea.WithMouseAllMotion()) + if strings.TrimSpace(input) == "" { + fmt.Println("No input provided, exiting") + os.Exit(0) + } + p := tea.NewProgram(ui.New(input), tea.WithMouseAllMotion()) if _, err := p.Run(); err != nil { log.Fatal(err) } } - -func (m mainModel) resultsView() string { - sb := strings.Builder{} - for i, f := range m.filtered { - fName := utils.TruncateString(" "+f, constants.SearchingFileTreeWidth-2) - if i == m.resultsCursor { - sb.WriteString(lipgloss.NewStyle().Background(lipgloss.Color("#1b1b33")).Bold(true).Render(fName) + "\n") - } else { - sb.WriteString(fName + "\n") - } - } - return sb.String() -} - -func (m mainModel) sidebarWidth() int { - if m.searching { - return constants.SearchingFileTreeWidth - } else if m.isShowingFileTree { - return constants.OpenFileTreeWidth - } else { - return 0 - } -} - -func (m mainModel) setDiffViewerDimensions() (tea.Model, tea.Cmd) { - df, dfCmd := m.diffViewer.(diffModel).Update(dimensionsMsg{Width: m.width - m.sidebarWidth(), Height: m.height - footerHeight - headerHeight}) - return df, dfCmd -} diff --git a/pkg/file_tree/file_node.go b/pkg/filenode/file_node.go similarity index 98% rename from pkg/file_tree/file_node.go rename to pkg/filenode/file_node.go index 144e975..9409e20 100644 --- a/pkg/file_tree/file_node.go +++ b/pkg/filenode/file_node.go @@ -1,4 +1,4 @@ -package filetree +package filenode import ( "path/filepath" diff --git a/pkg/ui/common/component.go b/pkg/ui/common/component.go new file mode 100644 index 0000000..0cc206b --- /dev/null +++ b/pkg/ui/common/component.go @@ -0,0 +1,14 @@ +package common + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// Common is a struct that contains the width and height of a component. +type Common struct { + Width, Height int +} + +type Component interface { + SetSize(width, height int) tea.Cmd +} diff --git a/pkg/ui/common/msgs.go b/pkg/ui/common/msgs.go new file mode 100644 index 0000000..cd8e1fa --- /dev/null +++ b/pkg/ui/common/msgs.go @@ -0,0 +1,5 @@ +package common + +type ErrMsg struct { + Err error +} diff --git a/keys.go b/pkg/ui/keys.go similarity index 78% rename from keys.go rename to pkg/ui/keys.go index 609fee8..bdd3d4a 100644 --- a/keys.go +++ b/pkg/ui/keys.go @@ -1,4 +1,4 @@ -package main +package ui import "github.com/charmbracelet/bubbles/key" @@ -10,6 +10,7 @@ type KeyMap struct { ToggleFileTree key.Binding Search key.Binding Quit key.Binding + Copy key.Binding } var keys = &KeyMap{ @@ -41,8 +42,21 @@ var keys = &KeyMap{ key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit"), ), + Copy: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "copy file path"), + ), } func getKeys() []key.Binding { - return []key.Binding{keys.Up, keys.Down, keys.CtrlD, keys.CtrlU, keys.ToggleFileTree, keys.Search, keys.Quit} + return []key.Binding{ + keys.Up, + keys.Down, + keys.CtrlD, + keys.CtrlU, + keys.ToggleFileTree, + keys.Search, + keys.Copy, + keys.Quit, + } } diff --git a/pkg/ui/mainModel.go b/pkg/ui/mainModel.go new file mode 100644 index 0000000..2ae11f7 --- /dev/null +++ b/pkg/ui/mainModel.go @@ -0,0 +1,322 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/bluekeyes/go-gitdiff/gitdiff" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + + "github.com/dlvhdr/diffnav/pkg/constants" + "github.com/dlvhdr/diffnav/pkg/filenode" + "github.com/dlvhdr/diffnav/pkg/ui/common" + "github.com/dlvhdr/diffnav/pkg/ui/panes/diffviewer" + "github.com/dlvhdr/diffnav/pkg/ui/panes/filetree" + "github.com/dlvhdr/diffnav/pkg/utils" +) + +const ( + footerHeight = 2 + headerHeight = 2 + searchHeight = 3 +) + +type mainModel struct { + input string + files []*gitdiff.File + cursor int + fileTree filetree.Model + diffViewer diffviewer.Model + width int + height int + isShowingFileTree bool + search textinput.Model + help help.Model + resultsVp viewport.Model + resultsCursor int + searching bool + filtered []string +} + +func New(input string) mainModel { + m := mainModel{input: input, isShowingFileTree: true} + m.fileTree = filetree.New() + m.diffViewer = diffviewer.New() + + m.help = help.New() + helpSt := lipgloss.NewStyle() + m.help.ShortSeparator = " · " + m.help.Styles.ShortKey = helpSt + m.help.Styles.ShortDesc = helpSt + m.help.Styles.ShortSeparator = helpSt + m.help.Styles.ShortKey = helpSt.Foreground(lipgloss.Color("254")) + m.help.Styles.ShortDesc = helpSt + m.help.Styles.ShortSeparator = helpSt + m.help.Styles.Ellipsis = helpSt + + m.search = textinput.New() + m.search.ShowSuggestions = true + m.search.KeyMap.AcceptSuggestion = key.NewBinding(key.WithKeys("tab")) + m.search.Prompt = " " + m.search.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + m.search.Placeholder = "Filter files 󰬛 " + m.search.PlaceholderStyle = lipgloss.NewStyle().MaxWidth(lipgloss.Width(m.search.Placeholder)).Foreground(lipgloss.Color("8")) + m.search.Width = constants.OpenFileTreeWidth - 5 + + m.resultsVp = viewport.Model{} + + return m +} + +func (m mainModel) Init() tea.Cmd { + return tea.Batch(tea.EnterAltScreen, m.fetchFileTree, m.diffViewer.Init()) +} + +func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + if !m.searching { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "t": + m.searching = true + m.search.Width = m.sidebarWidth() - 5 + m.search.SetValue("") + m.resultsCursor = 0 + m.filtered = make([]string, 0) + + m.resultsVp.Width = constants.SearchingFileTreeWidth + m.resultsVp.Height = m.height - footerHeight - headerHeight - searchHeight + m.resultsVp.SetContent(m.resultsView()) + + dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.height-footerHeight-headerHeight) + cmds = append(cmds, dfCmd, m.search.Focus()) + case "e": + m.isShowingFileTree = !m.isShowingFileTree + dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.height-footerHeight-headerHeight) + cmds = append(cmds, dfCmd) + case "up", "k", "ctrl+p": + if m.cursor > 0 { + m.diffViewer.GoToTop() + cmd = m.setCursor(m.cursor - 1) + cmds = append(cmds, cmd) + } + case "down", "j", "ctrl+n": + if m.cursor < len(m.files)-1 { + m.diffViewer.GoToTop() + cmd = m.setCursor(m.cursor + 1) + cmds = append(cmds, cmd) + } + case "y": + cmd = m.fileTree.CopyFilePath(m.cursor) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + case tea.WindowSizeMsg: + m.help.Width = msg.Width + m.width = msg.Width + m.height = msg.Height + dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.height-footerHeight-headerHeight) + cmds = append(cmds, dfCmd) + ftCmd := m.fileTree.SetSize(m.sidebarWidth(), m.height-footerHeight-headerHeight-searchHeight) + cmds = append(cmds, ftCmd) + + case fileTreeMsg: + m.files = msg.files + if len(m.files) == 0 { + return m, tea.Quit + } + m.fileTree = m.fileTree.SetFiles(m.files) + cmd = m.setCursor(0) + cmds = append(cmds, cmd) + + case common.ErrMsg: + fmt.Printf("Error: %v\n", msg.Err) + log.Fatal(msg.Err) + } + } else { + var sCmds []tea.Cmd + m, sCmds = m.searchUpdate(msg) + cmds = append(cmds, sCmds...) + } + + m.diffViewer, cmd = m.diffViewer.Update(msg) + cmds = append(cmds, cmd) + + m.fileTree, cmd = m.fileTree.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m mainModel) searchUpdate(msg tea.Msg) (mainModel, []tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + if m.search.Focused() { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + m.stopSearch() + dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.height-footerHeight-headerHeight) + cmds = append(cmds, dfCmd) + case "ctrl+c": + return m, []tea.Cmd{tea.Quit} + case "enter": + m.stopSearch() + dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.height-footerHeight-headerHeight) + cmds = append(cmds, dfCmd) + + selected := m.filtered[m.resultsCursor] + for i, f := range m.files { + if filenode.GetFileName(f) == selected { + m.cursor = i + m.diffViewer, cmd = m.diffViewer.SetFilePatch(f) + cmds = append(cmds, cmd) + break + } + } + + case "ctrl+n", "down": + m.resultsCursor = min(len(m.files)-1, m.resultsCursor+1) + m.resultsVp.LineDown(1) + case "ctrl+p", "up": + m.resultsCursor = max(0, m.resultsCursor-1) + m.resultsVp.LineUp(1) + default: + m.resultsCursor = 0 + } + } + s, sc := m.search.Update(msg) + cmds = append(cmds, sc) + m.search = s + filtered := make([]string, 0) + for _, f := range m.files { + if strings.Contains(strings.ToLower(filenode.GetFileName(f)), strings.ToLower(m.search.Value())) { + filtered = append(filtered, filenode.GetFileName(f)) + } + } + m.filtered = filtered + m.resultsVp.SetContent(m.resultsView()) + } + + return m, cmds +} + +func (m mainModel) View() string { + header := lipgloss.NewStyle().Width(m.width). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(lipgloss.Color("8")). + Foreground(lipgloss.Color("6")). + Bold(true). + Render("DIFFNAV") + footer := m.footerView() + + sidebar := "" + if m.isShowingFileTree { + search := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("8")). + MaxHeight(3). + Width(m.sidebarWidth() - 2). + Render(m.search.View()) + + content := "" + width := m.sidebarWidth() + if m.searching { + content = m.resultsVp.View() + } else { + content = m.fileTree.View() + } + + content = lipgloss.NewStyle(). + Width(width). + Height(m.height - footerHeight - headerHeight).Render(lipgloss.JoinVertical(lipgloss.Left, search, content)) + + sidebar = lipgloss.NewStyle(). + Width(width). + Border(lipgloss.NormalBorder(), false, true, false, false). + BorderForeground(lipgloss.Color("8")).Render(content) + } + dv := lipgloss.NewStyle().MaxHeight(m.height - footerHeight - headerHeight).Width(m.width - m.sidebarWidth()).Render(m.diffViewer.View()) + return lipgloss.JoinVertical(lipgloss.Left, + header, + lipgloss.JoinHorizontal(lipgloss.Top, sidebar, dv), + footer, + ) +} + +type fileTreeMsg struct { + files []*gitdiff.File +} + +func (m mainModel) fetchFileTree() tea.Msg { + // TODO: handle error + files, _, err := gitdiff.Parse(strings.NewReader(m.input + "\n")) + if err != nil { + return common.ErrMsg{Err: err} + } + sortFiles(files) + + return fileTreeMsg{files: files} +} + +func (m mainModel) footerView() string { + return lipgloss.NewStyle(). + Width(m.width). + Border(lipgloss.NormalBorder(), true, false, false, false). + BorderForeground(lipgloss.Color("8")). + Height(1). + Render(m.help.ShortHelpView(getKeys())) + +} + +func (m mainModel) resultsView() string { + sb := strings.Builder{} + for i, f := range m.filtered { + fName := utils.TruncateString(" "+f, constants.SearchingFileTreeWidth-2) + if i == m.resultsCursor { + sb.WriteString(lipgloss.NewStyle().Background(lipgloss.Color("#1b1b33")).Bold(true).Render(fName) + "\n") + } else { + sb.WriteString(fName + "\n") + } + } + return sb.String() +} + +func (m mainModel) sidebarWidth() int { + if m.searching { + return constants.SearchingFileTreeWidth + } else if m.isShowingFileTree { + return constants.OpenFileTreeWidth + } else { + return 0 + } +} + +func (m *mainModel) stopSearch() { + m.searching = false + m.search.SetValue("") + m.search.Blur() + m.search.Width = m.sidebarWidth() - 5 +} + +func (m *mainModel) setCursor(cursor int) tea.Cmd { + var cmd tea.Cmd + m.cursor = cursor + m.diffViewer, cmd = m.diffViewer.SetFilePatch(m.files[m.cursor]) + m.fileTree = m.fileTree.SetCursor(m.cursor) + return cmd +} diff --git a/diffviewer.go b/pkg/ui/panes/diffviewer/diffviewer.go similarity index 75% rename from diffviewer.go rename to pkg/ui/panes/diffviewer/diffviewer.go index fe66e82..95648bd 100644 --- a/diffviewer.go +++ b/pkg/ui/panes/diffviewer/diffviewer.go @@ -1,4 +1,4 @@ -package main +package diffviewer import ( "bytes" @@ -11,29 +11,30 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + + "github.com/dlvhdr/diffnav/pkg/ui/common" ) -const dirrHeaderHeight = 3 +const dirHeaderHeight = 3 -type diffModel struct { +type Model struct { + common.Common vp viewport.Model buffer *bytes.Buffer - width int - height int file *gitdiff.File } -func initialDiffModel() diffModel { - return diffModel{ +func New() Model { + return Model{ vp: viewport.Model{}, } } -func (m diffModel) Init() tea.Cmd { +func (m Model) Init() tea.Cmd { return nil } -func (m diffModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case tea.KeyMsg: @@ -50,25 +51,27 @@ func (m diffModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case diffContentMsg: m.vp.SetContent(msg.text) - case dimensionsMsg: - m.width = msg.Width - m.height = msg.Height - m.vp.Width = m.width - m.vp.Height = m.height - dirrHeaderHeight - cmds = append(cmds, diff(m.file, m.width)) } return m, tea.Batch(cmds...) } -func (m diffModel) View() string { +func (m Model) View() string { if m.buffer == nil { return "Loading..." } return lipgloss.JoinVertical(lipgloss.Left, m.headerView(), m.vp.View()) } -func (m diffModel) headerView() string { +func (m *Model) SetSize(width, height int) tea.Cmd { + m.Width = width + m.Height = height + m.vp.Width = m.Width + m.vp.Height = m.Height - dirHeaderHeight + return diff(m.file, m.Width) +} + +func (m Model) headerView() string { if m.file == nil { return "" } @@ -94,19 +97,23 @@ func (m diffModel) headerView() string { ) return base. - Width(m.width). + Width(m.Width). PaddingLeft(1). - Height(dirrHeaderHeight - 1). + Height(dirHeaderHeight - 1). BorderStyle(lipgloss.NormalBorder()). BorderBottom(true). BorderForeground(lipgloss.Color("8")). Render(lipgloss.JoinVertical(lipgloss.Left, top, bottom)) } -func (m diffModel) SetFilePatch(file *gitdiff.File) (diffModel, tea.Cmd) { +func (m Model) SetFilePatch(file *gitdiff.File) (Model, tea.Cmd) { m.buffer = new(bytes.Buffer) m.file = file - return m, diff(m.file, m.width) + return m, diff(m.file, m.Width) +} + +func (m *Model) GoToTop() { + m.vp.GotoTop() } func diff(file *gitdiff.File, width int) tea.Cmd { @@ -125,7 +132,7 @@ func diff(file *gitdiff.File, width int) tea.Cmd { out, err := deltac.Output() if err != nil { - return errMsg{err} + return common.ErrMsg{Err: err} } return diffContentMsg{text: string(out)} diff --git a/filetree.go b/pkg/ui/panes/filetree/filetree.go similarity index 81% rename from filetree.go rename to pkg/ui/panes/filetree/filetree.go index d7594f3..08006f7 100644 --- a/filetree.go +++ b/pkg/ui/panes/filetree/filetree.go @@ -1,10 +1,11 @@ -package main +package filetree import ( "os" "path/filepath" "strings" + "github.com/atotto/clipboard" "github.com/bluekeyes/go-gitdiff/gitdiff" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -12,18 +13,19 @@ import ( "github.com/charmbracelet/lipgloss/tree" "github.com/dlvhdr/diffnav/pkg/constants" - filetree "github.com/dlvhdr/diffnav/pkg/file_tree" + "github.com/dlvhdr/diffnav/pkg/filenode" + "github.com/dlvhdr/diffnav/pkg/ui/common" "github.com/dlvhdr/diffnav/pkg/utils" ) -type ftModel struct { +type Model struct { files []*gitdiff.File tree *tree.Tree vp viewport.Model selectedFile *string } -func (m ftModel) SetFiles(files []*gitdiff.File) ftModel { +func (m Model) SetFiles(files []*gitdiff.File) Model { m.files = files t := buildFullFileTree(files) collapsed := collapseTree(t) @@ -32,11 +34,11 @@ func (m ftModel) SetFiles(files []*gitdiff.File) ftModel { return m } -func (m ftModel) SetCursor(cursor int) ftModel { +func (m Model) SetCursor(cursor int) Model { if len(m.files) == 0 { return m } - name := filetree.GetFileName(m.files[cursor]) + name := filenode.GetFileName(m.files[cursor]) m.selectedFile = &name applyStyles(m.tree, m.selectedFile) m.scrollSelectedFileIntoView(m.tree) @@ -44,16 +46,31 @@ func (m ftModel) SetCursor(cursor int) ftModel { return m } +func (m Model) CopyFilePath(cursor int) tea.Cmd { + if len(m.files) == 0 { + return nil + } + name := filenode.GetFileName(m.files[cursor]) + err := clipboard.WriteAll(name) + if err != nil { + return func() tea.Msg { + return common.ErrMsg{Err: err} + } + } + return nil +} + const contextLines = 15 -func (m *ftModel) scrollSelectedFileIntoView(t *tree.Tree) { +func (m *Model) scrollSelectedFileIntoView(t *tree.Tree) { children := t.Children() + found := false for i := 0; i < children.Length(); i++ { child := children.At(i) switch child := child.(type) { case *tree.Tree: m.scrollSelectedFileIntoView(child) - case filetree.FileNode: + case filenode.FileNode: if child.Path() == *m.selectedFile { // offset is 1-based, so we need to subtract 1 offset := child.YOffset - 1 - contextLines @@ -62,29 +79,29 @@ func (m *ftModel) scrollSelectedFileIntoView(t *tree.Tree) { offset = offset - 1 } m.vp.SetYOffset(offset) + found = true + break } } + if found { + break + } } } -func initialFileTreeModel() ftModel { - return ftModel{ +func New() Model { + return Model{ files: []*gitdiff.File{}, vp: viewport.Model{}, } } -func (m ftModel) Init() tea.Cmd { +func (m Model) Init() tea.Cmd { return nil } -func (m ftModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case dimensionsMsg: - m.vp.Width = msg.Width - m.vp.Height = msg.Height - m.vp, _ = m.vp.Update(msg) - } +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + m.vp, _ = m.vp.Update(msg) return m, nil } @@ -102,15 +119,18 @@ var enumerator = func(children tree.Children, index int) string { return "├" } -func (m ftModel) View() string { +func (m Model) View() string { return m.vp.View() } -type errMsg struct { - err error +// SetSize implements the Component interface. +func (m *Model) SetSize(width, height int) tea.Cmd { + m.vp.Width = width + m.vp.Height = height + return nil } -func (m ftModel) printWithoutRoot() string { +func (m Model) printWithoutRoot() string { if m.tree.Value() != dirIcon+"." { return m.tree.String() } @@ -125,7 +145,7 @@ func (m ftModel) printWithoutRoot() string { applyStyles(normalized, m.selectedFile) s += normalized.String() - case filetree.FileNode: + case filenode.FileNode: child.Depth = 0 s += applyStyleToNode(child, m.selectedFile).Render(child.Value()) } @@ -145,7 +165,7 @@ func normalizeDepth(node *tree.Tree, depth int) *tree.Tree { case *tree.Tree: sub := normalizeDepth(child, depth+1) t.Child(sub) - case filetree.FileNode: + case filenode.FileNode: child.Depth = depth + 1 t.Child(child) } @@ -158,7 +178,7 @@ func buildFullFileTree(files []*gitdiff.File) *tree.Tree { for _, file := range files { subTree := t - name := filetree.GetFileName(file) + name := filenode.GetFileName(file) dir := filepath.Dir(name) parts := strings.Split(dir, string(os.PathSeparator)) path := "" @@ -189,7 +209,7 @@ func buildFullFileTree(files []*gitdiff.File) *tree.Tree { for i, part := range parts { var c *tree.Tree if i == len(parts)-1 { - subTree.Child(filetree.FileNode{File: file}) + subTree.Child(filenode.FileNode{File: file}) } else { c = tree.Root(part) subTree.Child(c) @@ -252,9 +272,9 @@ func truncateTree(t *tree.Tree, depth int, numNodes int, numChildren int) (*tree numChildren += subNum numNodes += subNum + 1 newT.Child(sub) - case filetree.FileNode: + case filenode.FileNode: numNodes++ - newT.Child(filetree.FileNode{File: child.File, Depth: depth + 1, YOffset: numNodes}) + newT.Child(filenode.FileNode{File: child.File, Depth: depth + 1, YOffset: numNodes}) default: newT.Child(child) } @@ -263,7 +283,7 @@ func truncateTree(t *tree.Tree, depth int, numNodes int, numChildren int) (*tree } func applyStyles(t *tree.Tree, selectedFile *string) { - enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).PaddingRight(1) + enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).PaddingRight(1) rootStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("4")) t.Enumerator(enumerator).Indenter(indenter). EnumeratorStyle(enumeratorStyle). @@ -288,7 +308,7 @@ func applyStyleAux(children tree.Children, i int, selectedFile *string) lipgloss func applyStyleToNode(node tree.Node, selectedFile *string) lipgloss.Style { st := lipgloss.NewStyle().MaxHeight(1) switch n := node.(type) { - case filetree.FileNode: + case filenode.FileNode: if selectedFile != nil && n.Path() == *selectedFile { return st.Background(lipgloss.Color("#1b1b33")).Bold(true) } diff --git a/pkg/ui/utils.go b/pkg/ui/utils.go new file mode 100644 index 0000000..54e9113 --- /dev/null +++ b/pkg/ui/utils.go @@ -0,0 +1,42 @@ +package ui + +import ( + "path/filepath" + "slices" + "strings" + + "github.com/bluekeyes/go-gitdiff/gitdiff" + + "github.com/dlvhdr/diffnav/pkg/filenode" +) + +func sortFiles(files []*gitdiff.File) { + slices.SortFunc(files, func(a *gitdiff.File, b *gitdiff.File) int { + nameA := filenode.GetFileName(a) + nameB := filenode.GetFileName(b) + dira := filepath.Dir(nameA) + dirb := filepath.Dir(nameB) + if dira != "." && dirb != "." && dira == dirb { + return strings.Compare(strings.ToLower(nameA), strings.ToLower(nameB)) + } + + if dira != "." && dirb == "." { + return -1 + } + if dirb != "." && dira == "." { + return 1 + } + + if dira != "." && dirb != "." { + if strings.HasPrefix(dira, dirb) { + return -1 + } + + if strings.HasPrefix(dirb, dira) { + return 1 + } + } + + return strings.Compare(strings.ToLower(nameA), strings.ToLower(nameB)) + }) +}