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))
+ })
+}