From 15d195a005a12ec111aa78437184ab57fe0c95a6 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 7 Jun 2025 16:54:09 +0200 Subject: [PATCH] feat: add new filepicker --- go.mod | 12 +- go.sum | 17 +- internal/tui/components/chat/editor/editor.go | 15 +- internal/tui/components/dialog/filepicker.go | 932 +++++++++--------- .../dialogs/filepicker/filepicker.go | 47 +- internal/tui/components/image/image.go | 89 ++ internal/tui/components/image/load.go | 157 +++ internal/tui/image/images.go | 73 -- 8 files changed, 776 insertions(+), 566 deletions(-) create mode 100644 internal/tui/components/image/image.go create mode 100644 internal/tui/components/image/load.go delete mode 100644 internal/tui/image/images.go diff --git a/go.mod b/go.mod index c4892e8b3616ed276178f7a4c32db9dae48a3439..edb43f7075b9959941ac398c85fafb9d6d6e4610 100644 --- a/go.mod +++ b/go.mod @@ -18,11 +18,14 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 + github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/fsnotify/fsnotify v1.8.0 github.com/go-logfmt/logfmt v0.6.0 github.com/google/uuid v1.6.0 github.com/mark3labs/mcp-go v0.17.0 + github.com/muesli/termenv v0.16.0 github.com/ncruces/go-sqlite3 v0.25.0 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/openai/openai-go v0.1.0-beta.2 github.com/pressly/goose/v3 v3.24.2 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 @@ -30,10 +33,17 @@ require ( github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.0 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/stretchr/testify v1.10.0 ) -require github.com/dustin/go-humanize v1.0.1 // indirect +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/disintegration/gift v1.1.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect +) require ( cloud.google.com/go v0.116.0 // indirect diff --git a/go.sum b/go.sum index 45bfc062f74dc87fd689169a910d2d3cf151b59f..0811dc5e595582b364af04758a219f2cbe634441 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudr github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -66,8 +68,6 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8= github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI= -github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607105053-36addcd3ab8c h1:Dgy7cOR3skvJjGVnhyaixW6ugxVxLtSjRCiMRSdbXSc= -github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607105053-36addcd3ab8c/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174 h1:TlVW+df0rdU/osP0O8DIVS9WFOAzXe3nuiMwJR4n+CA= @@ -98,6 +98,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs= +github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= +github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg= +github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= @@ -164,12 +168,16 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I= github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894= github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= @@ -215,6 +223,10 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY= github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -298,6 +310,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 096e6c5e97ce8d6f2a3fc243fe44fa095a858f11..aae5d3ec5b0c51c99f328ebb489af06c537adbe8 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -19,7 +19,6 @@ import ( "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/tui/components/chat" "github.com/opencode-ai/opencode/internal/tui/components/completions" - "github.com/opencode-ai/opencode/internal/tui/components/dialog" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/util" @@ -142,13 +141,13 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.session = msg } return m, nil - case dialog.AttachmentAddedMsg: - if len(m.attachments) >= maxAttachments { - logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments)) - return m, cmd - } - m.attachments = append(m.attachments, msg.Attachment) - return m, nil + // case dialog.AttachmentAddedMsg: + // if len(m.attachments) >= maxAttachments { + // logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments)) + // return m, cmd + // } + // m.attachments = append(m.attachments, msg.Attachment) + // return m, nil case completions.CompletionsClosedMsg: m.isCompletionsOpen = false m.currentQuery = "" diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go index fdb23f4d3afafcd9a548eb254afd076fe22212d9..85c946b79dc5a55b1031a17138c3e4bcf4136131 100644 --- a/internal/tui/components/dialog/filepicker.go +++ b/internal/tui/components/dialog/filepicker.go @@ -1,468 +1,468 @@ package dialog -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/textinput" - "github.com/charmbracelet/bubbles/v2/viewport" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/tui/image" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -const ( - maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB - downArrow = "down" - upArrow = "up" -) - -type FilePrickerKeyMap struct { - Enter key.Binding - Down key.Binding - Up key.Binding - Forward key.Binding - Backward key.Binding - OpenFilePicker key.Binding - Esc key.Binding - InsertCWD key.Binding -} - -var filePickerKeyMap = FilePrickerKeyMap{ - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select file/enter directory"), - ), - Down: key.NewBinding( - key.WithKeys("j", downArrow), - key.WithHelp("↓/j", "down"), - ), - Up: key.NewBinding( - key.WithKeys("k", upArrow), - key.WithHelp("↑/k", "up"), - ), - Forward: key.NewBinding( - key.WithKeys("l"), - key.WithHelp("l", "enter directory"), - ), - Backward: key.NewBinding( - key.WithKeys("h", "backspace"), - key.WithHelp("h/backspace", "go back"), - ), - OpenFilePicker: key.NewBinding( - key.WithKeys("ctrl+f"), - key.WithHelp("ctrl+f", "open file picker"), - ), - Esc: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close/exit"), - ), - InsertCWD: key.NewBinding( - key.WithKeys("i"), - key.WithHelp("i", "manual path input"), - ), -} - -type filepickerCmp struct { - basePath string - width int - height int - cursor int - err error - cursorChain stack - viewport viewport.Model - dirs []os.DirEntry - cwdDetails *DirNode - selectedFile string - cwd textinput.Model - ShowFilePicker bool - app *app.App -} - -type DirNode struct { - parent *DirNode - child *DirNode - directory string -} -type stack []int - -func (s stack) Push(v int) stack { - return append(s, v) -} - -func (s stack) Pop() (stack, int) { - l := len(s) - return s[:l-1], s[l-1] -} - -type AttachmentAddedMsg struct { - Attachment message.Attachment -} - -func (f *filepickerCmp) Init() tea.Cmd { - return nil -} - -func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - f.width = 60 - f.height = 20 - f.viewport.SetWidth(80) - f.viewport.SetHeight(22) - f.cursor = 0 - f.getCurrentFileBelowCursor() - case tea.KeyPressMsg: - if f.cwd.Focused() { - f.cwd, cmd = f.cwd.Update(msg) - } - switch { - case key.Matches(msg, filePickerKeyMap.InsertCWD): - f.cwd.Focus() - return f, cmd - case key.Matches(msg, filePickerKeyMap.Esc): - if f.cwd.Focused() { - f.cwd.Blur() - } - case key.Matches(msg, filePickerKeyMap.Down): - if !f.cwd.Focused() || msg.String() == downArrow { - if f.cursor < len(f.dirs)-1 { - f.cursor++ - f.getCurrentFileBelowCursor() - } - } - case key.Matches(msg, filePickerKeyMap.Up): - if !f.cwd.Focused() || msg.String() == upArrow { - if f.cursor > 0 { - f.cursor-- - f.getCurrentFileBelowCursor() - } - } - case key.Matches(msg, filePickerKeyMap.Enter): - var path string - var isPathDir bool - if f.cwd.Focused() { - path = f.cwd.Value() - fileInfo, err := os.Stat(path) - if err != nil { - logging.ErrorPersist("Invalid path") - return f, cmd - } - isPathDir = fileInfo.IsDir() - } else { - path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name()) - isPathDir = f.dirs[f.cursor].IsDir() - } - if isPathDir { - newWorkingDir := DirNode{parent: f.cwdDetails, directory: path} - f.cwdDetails.child = &newWorkingDir - f.cwdDetails = f.cwdDetails.child - f.cursorChain = f.cursorChain.Push(f.cursor) - f.dirs = readDir(f.cwdDetails.directory, false) - f.cursor = 0 - f.cwd.SetValue(f.cwdDetails.directory) - f.getCurrentFileBelowCursor() - } else { - f.selectedFile = path - return f.addAttachmentToMessage() - } - case key.Matches(msg, filePickerKeyMap.Esc): - if !f.cwd.Focused() { - f.cursorChain = make(stack, 0) - f.cursor = 0 - } else { - f.cwd.Blur() - } - case key.Matches(msg, filePickerKeyMap.Forward): - if !f.cwd.Focused() { - if f.dirs[f.cursor].IsDir() { - path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name()) - newWorkingDir := DirNode{parent: f.cwdDetails, directory: path} - f.cwdDetails.child = &newWorkingDir - f.cwdDetails = f.cwdDetails.child - f.cursorChain = f.cursorChain.Push(f.cursor) - f.dirs = readDir(f.cwdDetails.directory, false) - f.cursor = 0 - f.cwd.SetValue(f.cwdDetails.directory) - f.getCurrentFileBelowCursor() - } - } - case key.Matches(msg, filePickerKeyMap.Backward): - if !f.cwd.Focused() { - if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil { - f.cursorChain, f.cursor = f.cursorChain.Pop() - f.cwdDetails = f.cwdDetails.parent - f.cwdDetails.child = nil - f.dirs = readDir(f.cwdDetails.directory, false) - f.cwd.SetValue(f.cwdDetails.directory) - f.getCurrentFileBelowCursor() - } - } - case key.Matches(msg, filePickerKeyMap.OpenFilePicker): - f.dirs = readDir(f.cwdDetails.directory, false) - f.cursor = 0 - f.getCurrentFileBelowCursor() - } - } - return f, cmd -} - -func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) { - // modeInfo := GetSelectedModel(config.Get()) - // if !modeInfo.SupportsAttachments { - // logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name)) - // return f, nil - // } - - selectedFilePath := f.selectedFile - if !isExtSupported(selectedFilePath) { - logging.ErrorPersist("Unsupported file") - return f, nil - } - - isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize) - if err != nil { - logging.ErrorPersist("unable to read the image") - return f, nil - } - if isFileLarge { - logging.ErrorPersist("file too large, max 5MB") - return f, nil - } - - content, err := os.ReadFile(selectedFilePath) - if err != nil { - logging.ErrorPersist("Unable read selected file") - return f, nil - } - - mimeBufferSize := min(512, len(content)) - mimeType := http.DetectContentType(content[:mimeBufferSize]) - fileName := filepath.Base(selectedFilePath) - attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content} - f.selectedFile = "" - return f, util.CmdHandler(AttachmentAddedMsg{attachment}) -} - -func (f *filepickerCmp) View() tea.View { - t := styles.CurrentTheme() - baseStyle := t.S().Base - const maxVisibleDirs = 20 - const maxWidth = 80 - - adjustedWidth := maxWidth - for _, file := range f.dirs { - if len(file.Name()) > adjustedWidth-4 { // Account for padding - adjustedWidth = len(file.Name()) + 4 - } - } - adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1 - - files := make([]string, 0, maxVisibleDirs) - startIdx := 0 - - if len(f.dirs) > maxVisibleDirs { - halfVisible := maxVisibleDirs / 2 - if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible { - startIdx = f.cursor - halfVisible - } else if f.cursor >= len(f.dirs)-halfVisible { - startIdx = len(f.dirs) - maxVisibleDirs - } - } - - endIdx := min(startIdx+maxVisibleDirs, len(f.dirs)) - - for i := startIdx; i < endIdx; i++ { - file := f.dirs[i] - itemStyle := t.S().Text.Width(adjustedWidth) - - if i == f.cursor { - itemStyle = itemStyle. - Background(t.Primary). - Bold(true) - } - filename := file.Name() - - if len(filename) > adjustedWidth-4 { - filename = filename[:adjustedWidth-7] + "..." - } - if file.IsDir() { - filename = filename + "/" - } - // No need to reassign filename if it's not changing - - files = append(files, itemStyle.Padding(0, 1).Render(filename)) - } - - // Pad to always show exactly 21 lines - for len(files) < maxVisibleDirs { - files = append(files, baseStyle.Width(adjustedWidth).Render("")) - } - - currentPath := baseStyle. - Height(1). - Width(adjustedWidth). - Render(f.cwd.View()) - - viewportstyle := baseStyle. - Width(f.viewport.Width()). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Padding(2). - Render(f.viewport.View()) - var insertExitText string - if f.IsCWDFocused() { - insertExitText = "Press esc to exit typing path" - } else { - insertExitText = "Press i to start typing path" - } - - content := lipgloss.JoinVertical( - lipgloss.Left, - currentPath, - baseStyle.Width(adjustedWidth).Render(""), - baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)), - baseStyle.Width(adjustedWidth).Render(""), - t.S().Muted.Width(adjustedWidth).Render(insertExitText), - ) - - f.cwd.SetValue(f.cwd.Value()) - contentStyle := baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Width(lipgloss.Width(content) + 4) - - return tea.NewView( - lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle), - ) -} - -type FilepickerCmp interface { - util.Model - ToggleFilepicker(showFilepicker bool) - IsCWDFocused() bool -} - -func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) { - f.ShowFilePicker = showFilepicker -} - -func (f *filepickerCmp) IsCWDFocused() bool { - return f.cwd.Focused() -} - -func NewFilepickerCmp(app *app.App) FilepickerCmp { - homepath, err := os.UserHomeDir() - if err != nil { - logging.Error("error loading user files") - return nil - } - baseDir := DirNode{parent: nil, directory: homepath} - dirs := readDir(homepath, false) - viewport := viewport.New() - currentDirectory := textinput.New() - currentDirectory.CharLimit = 200 - currentDirectory.SetWidth(44) - currentDirectory.Cursor().Blink = true - currentDirectory.SetValue(baseDir.directory) - return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app} -} - -func (f *filepickerCmp) getCurrentFileBelowCursor() { - if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) { - logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor)) - f.viewport.SetContent("Preview unavailable") - return - } - - dir := f.dirs[f.cursor] - filename := dir.Name() - if !dir.IsDir() && isExtSupported(filename) { - fullPath := f.cwdDetails.directory + "/" + dir.Name() - - go func() { - imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath) - if err != nil { - logging.Error(err.Error()) - f.viewport.SetContent("Preview unavailable") - return - } - - f.viewport.SetContent(imageString) - }() - } else { - f.viewport.SetContent("Preview unavailable") - } -} - -func readDir(path string, showHidden bool) []os.DirEntry { - logging.Info(fmt.Sprintf("Reading directory: %s", path)) - - entriesChan := make(chan []os.DirEntry, 1) - errChan := make(chan error, 1) - - go func() { - dirEntries, err := os.ReadDir(path) - if err != nil { - logging.ErrorPersist(err.Error()) - errChan <- err - return - } - entriesChan <- dirEntries - }() - - select { - case dirEntries := <-entriesChan: - sort.Slice(dirEntries, func(i, j int) bool { - if dirEntries[i].IsDir() == dirEntries[j].IsDir() { - return dirEntries[i].Name() < dirEntries[j].Name() - } - return dirEntries[i].IsDir() - }) - - if showHidden { - return dirEntries - } - - var sanitizedDirEntries []os.DirEntry - for _, dirEntry := range dirEntries { - isHidden, _ := IsHidden(dirEntry.Name()) - if !isHidden { - if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) { - sanitizedDirEntries = append(sanitizedDirEntries, dirEntry) - } - } - } - - return sanitizedDirEntries - - case err := <-errChan: - logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err) - return []os.DirEntry{} - - case <-time.After(5 * time.Second): - logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil) - return []os.DirEntry{} - } -} - -func IsHidden(file string) (bool, error) { - return strings.HasPrefix(file, "."), nil -} - -func isExtSupported(path string) bool { - ext := strings.ToLower(filepath.Ext(path)) - return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png") -} +// import ( +// "fmt" +// "net/http" +// "os" +// "path/filepath" +// "sort" +// "strings" +// "time" +// +// "github.com/charmbracelet/bubbles/v2/key" +// "github.com/charmbracelet/bubbles/v2/textinput" +// "github.com/charmbracelet/bubbles/v2/viewport" +// tea "github.com/charmbracelet/bubbletea/v2" +// "github.com/charmbracelet/lipgloss/v2" +// "github.com/opencode-ai/opencode/internal/app" +// "github.com/opencode-ai/opencode/internal/logging" +// "github.com/opencode-ai/opencode/internal/message" +// "github.com/opencode-ai/opencode/internal/tui/image" +// "github.com/opencode-ai/opencode/internal/tui/styles" +// "github.com/opencode-ai/opencode/internal/tui/util" +// ) +// +// const ( +// maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB +// downArrow = "down" +// upArrow = "up" +// ) +// +// type FilePrickerKeyMap struct { +// Enter key.Binding +// Down key.Binding +// Up key.Binding +// Forward key.Binding +// Backward key.Binding +// OpenFilePicker key.Binding +// Esc key.Binding +// InsertCWD key.Binding +// } +// +// var filePickerKeyMap = FilePrickerKeyMap{ +// Enter: key.NewBinding( +// key.WithKeys("enter"), +// key.WithHelp("enter", "select file/enter directory"), +// ), +// Down: key.NewBinding( +// key.WithKeys("j", downArrow), +// key.WithHelp("↓/j", "down"), +// ), +// Up: key.NewBinding( +// key.WithKeys("k", upArrow), +// key.WithHelp("↑/k", "up"), +// ), +// Forward: key.NewBinding( +// key.WithKeys("l"), +// key.WithHelp("l", "enter directory"), +// ), +// Backward: key.NewBinding( +// key.WithKeys("h", "backspace"), +// key.WithHelp("h/backspace", "go back"), +// ), +// OpenFilePicker: key.NewBinding( +// key.WithKeys("ctrl+f"), +// key.WithHelp("ctrl+f", "open file picker"), +// ), +// Esc: key.NewBinding( +// key.WithKeys("esc"), +// key.WithHelp("esc", "close/exit"), +// ), +// InsertCWD: key.NewBinding( +// key.WithKeys("i"), +// key.WithHelp("i", "manual path input"), +// ), +// } +// +// type filepickerCmp struct { +// basePath string +// width int +// height int +// cursor int +// err error +// cursorChain stack +// viewport viewport.Model +// dirs []os.DirEntry +// cwdDetails *DirNode +// selectedFile string +// cwd textinput.Model +// ShowFilePicker bool +// app *app.App +// } +// +// type DirNode struct { +// parent *DirNode +// child *DirNode +// directory string +// } +// type stack []int +// +// func (s stack) Push(v int) stack { +// return append(s, v) +// } +// +// func (s stack) Pop() (stack, int) { +// l := len(s) +// return s[:l-1], s[l-1] +// } +// +// type AttachmentAddedMsg struct { +// Attachment message.Attachment +// } +// +// func (f *filepickerCmp) Init() tea.Cmd { +// return nil +// } +// +// func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// var cmd tea.Cmd +// switch msg := msg.(type) { +// case tea.WindowSizeMsg: +// f.width = 60 +// f.height = 20 +// f.viewport.SetWidth(80) +// f.viewport.SetHeight(22) +// f.cursor = 0 +// f.getCurrentFileBelowCursor() +// case tea.KeyPressMsg: +// if f.cwd.Focused() { +// f.cwd, cmd = f.cwd.Update(msg) +// } +// switch { +// case key.Matches(msg, filePickerKeyMap.InsertCWD): +// f.cwd.Focus() +// return f, cmd +// case key.Matches(msg, filePickerKeyMap.Esc): +// if f.cwd.Focused() { +// f.cwd.Blur() +// } +// case key.Matches(msg, filePickerKeyMap.Down): +// if !f.cwd.Focused() || msg.String() == downArrow { +// if f.cursor < len(f.dirs)-1 { +// f.cursor++ +// f.getCurrentFileBelowCursor() +// } +// } +// case key.Matches(msg, filePickerKeyMap.Up): +// if !f.cwd.Focused() || msg.String() == upArrow { +// if f.cursor > 0 { +// f.cursor-- +// f.getCurrentFileBelowCursor() +// } +// } +// case key.Matches(msg, filePickerKeyMap.Enter): +// var path string +// var isPathDir bool +// if f.cwd.Focused() { +// path = f.cwd.Value() +// fileInfo, err := os.Stat(path) +// if err != nil { +// logging.ErrorPersist("Invalid path") +// return f, cmd +// } +// isPathDir = fileInfo.IsDir() +// } else { +// path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name()) +// isPathDir = f.dirs[f.cursor].IsDir() +// } +// if isPathDir { +// newWorkingDir := DirNode{parent: f.cwdDetails, directory: path} +// f.cwdDetails.child = &newWorkingDir +// f.cwdDetails = f.cwdDetails.child +// f.cursorChain = f.cursorChain.Push(f.cursor) +// f.dirs = readDir(f.cwdDetails.directory, false) +// f.cursor = 0 +// f.cwd.SetValue(f.cwdDetails.directory) +// f.getCurrentFileBelowCursor() +// } else { +// f.selectedFile = path +// return f.addAttachmentToMessage() +// } +// case key.Matches(msg, filePickerKeyMap.Esc): +// if !f.cwd.Focused() { +// f.cursorChain = make(stack, 0) +// f.cursor = 0 +// } else { +// f.cwd.Blur() +// } +// case key.Matches(msg, filePickerKeyMap.Forward): +// if !f.cwd.Focused() { +// if f.dirs[f.cursor].IsDir() { +// path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name()) +// newWorkingDir := DirNode{parent: f.cwdDetails, directory: path} +// f.cwdDetails.child = &newWorkingDir +// f.cwdDetails = f.cwdDetails.child +// f.cursorChain = f.cursorChain.Push(f.cursor) +// f.dirs = readDir(f.cwdDetails.directory, false) +// f.cursor = 0 +// f.cwd.SetValue(f.cwdDetails.directory) +// f.getCurrentFileBelowCursor() +// } +// } +// case key.Matches(msg, filePickerKeyMap.Backward): +// if !f.cwd.Focused() { +// if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil { +// f.cursorChain, f.cursor = f.cursorChain.Pop() +// f.cwdDetails = f.cwdDetails.parent +// f.cwdDetails.child = nil +// f.dirs = readDir(f.cwdDetails.directory, false) +// f.cwd.SetValue(f.cwdDetails.directory) +// f.getCurrentFileBelowCursor() +// } +// } +// case key.Matches(msg, filePickerKeyMap.OpenFilePicker): +// f.dirs = readDir(f.cwdDetails.directory, false) +// f.cursor = 0 +// f.getCurrentFileBelowCursor() +// } +// } +// return f, cmd +// } +// +// func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) { +// // modeInfo := GetSelectedModel(config.Get()) +// // if !modeInfo.SupportsAttachments { +// // logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name)) +// // return f, nil +// // } +// +// selectedFilePath := f.selectedFile +// if !isExtSupported(selectedFilePath) { +// logging.ErrorPersist("Unsupported file") +// return f, nil +// } +// +// isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize) +// if err != nil { +// logging.ErrorPersist("unable to read the image") +// return f, nil +// } +// if isFileLarge { +// logging.ErrorPersist("file too large, max 5MB") +// return f, nil +// } +// +// content, err := os.ReadFile(selectedFilePath) +// if err != nil { +// logging.ErrorPersist("Unable read selected file") +// return f, nil +// } +// +// mimeBufferSize := min(512, len(content)) +// mimeType := http.DetectContentType(content[:mimeBufferSize]) +// fileName := filepath.Base(selectedFilePath) +// attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content} +// f.selectedFile = "" +// return f, util.CmdHandler(AttachmentAddedMsg{attachment}) +// } +// +// func (f *filepickerCmp) View() tea.View { +// t := styles.CurrentTheme() +// baseStyle := t.S().Base +// const maxVisibleDirs = 20 +// const maxWidth = 80 +// +// adjustedWidth := maxWidth +// for _, file := range f.dirs { +// if len(file.Name()) > adjustedWidth-4 { // Account for padding +// adjustedWidth = len(file.Name()) + 4 +// } +// } +// adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1 +// +// files := make([]string, 0, maxVisibleDirs) +// startIdx := 0 +// +// if len(f.dirs) > maxVisibleDirs { +// halfVisible := maxVisibleDirs / 2 +// if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible { +// startIdx = f.cursor - halfVisible +// } else if f.cursor >= len(f.dirs)-halfVisible { +// startIdx = len(f.dirs) - maxVisibleDirs +// } +// } +// +// endIdx := min(startIdx+maxVisibleDirs, len(f.dirs)) +// +// for i := startIdx; i < endIdx; i++ { +// file := f.dirs[i] +// itemStyle := t.S().Text.Width(adjustedWidth) +// +// if i == f.cursor { +// itemStyle = itemStyle. +// Background(t.Primary). +// Bold(true) +// } +// filename := file.Name() +// +// if len(filename) > adjustedWidth-4 { +// filename = filename[:adjustedWidth-7] + "..." +// } +// if file.IsDir() { +// filename = filename + "/" +// } +// // No need to reassign filename if it's not changing +// +// files = append(files, itemStyle.Padding(0, 1).Render(filename)) +// } +// +// // Pad to always show exactly 21 lines +// for len(files) < maxVisibleDirs { +// files = append(files, baseStyle.Width(adjustedWidth).Render("")) +// } +// +// currentPath := baseStyle. +// Height(1). +// Width(adjustedWidth). +// Render(f.cwd.View()) +// +// viewportstyle := baseStyle. +// Width(f.viewport.Width()). +// Border(lipgloss.RoundedBorder()). +// BorderForeground(t.BorderFocus). +// Padding(2). +// Render(f.viewport.View()) +// var insertExitText string +// if f.IsCWDFocused() { +// insertExitText = "Press esc to exit typing path" +// } else { +// insertExitText = "Press i to start typing path" +// } +// +// content := lipgloss.JoinVertical( +// lipgloss.Left, +// currentPath, +// baseStyle.Width(adjustedWidth).Render(""), +// baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)), +// baseStyle.Width(adjustedWidth).Render(""), +// t.S().Muted.Width(adjustedWidth).Render(insertExitText), +// ) +// +// f.cwd.SetValue(f.cwd.Value()) +// contentStyle := baseStyle.Padding(1, 2). +// Border(lipgloss.RoundedBorder()). +// BorderForeground(t.BorderFocus). +// Width(lipgloss.Width(content) + 4) +// +// return tea.NewView( +// lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle), +// ) +// } +// +// type FilepickerCmp interface { +// util.Model +// ToggleFilepicker(showFilepicker bool) +// IsCWDFocused() bool +// } +// +// func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) { +// f.ShowFilePicker = showFilepicker +// } +// +// func (f *filepickerCmp) IsCWDFocused() bool { +// return f.cwd.Focused() +// } +// +// func NewFilepickerCmp(app *app.App) FilepickerCmp { +// homepath, err := os.UserHomeDir() +// if err != nil { +// logging.Error("error loading user files") +// return nil +// } +// baseDir := DirNode{parent: nil, directory: homepath} +// dirs := readDir(homepath, false) +// viewport := viewport.New() +// currentDirectory := textinput.New() +// currentDirectory.CharLimit = 200 +// currentDirectory.SetWidth(44) +// currentDirectory.Cursor().Blink = true +// currentDirectory.SetValue(baseDir.directory) +// return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app} +// } +// +// func (f *filepickerCmp) getCurrentFileBelowCursor() { +// if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) { +// logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor)) +// f.viewport.SetContent("Preview unavailable") +// return +// } +// +// dir := f.dirs[f.cursor] +// filename := dir.Name() +// if !dir.IsDir() && isExtSupported(filename) { +// fullPath := f.cwdDetails.directory + "/" + dir.Name() +// +// go func() { +// imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath) +// if err != nil { +// logging.Error(err.Error()) +// f.viewport.SetContent("Preview unavailable") +// return +// } +// +// f.viewport.SetContent(imageString) +// }() +// } else { +// f.viewport.SetContent("Preview unavailable") +// } +// } +// +// func readDir(path string, showHidden bool) []os.DirEntry { +// logging.Info(fmt.Sprintf("Reading directory: %s", path)) +// +// entriesChan := make(chan []os.DirEntry, 1) +// errChan := make(chan error, 1) +// +// go func() { +// dirEntries, err := os.ReadDir(path) +// if err != nil { +// logging.ErrorPersist(err.Error()) +// errChan <- err +// return +// } +// entriesChan <- dirEntries +// }() +// +// select { +// case dirEntries := <-entriesChan: +// sort.Slice(dirEntries, func(i, j int) bool { +// if dirEntries[i].IsDir() == dirEntries[j].IsDir() { +// return dirEntries[i].Name() < dirEntries[j].Name() +// } +// return dirEntries[i].IsDir() +// }) +// +// if showHidden { +// return dirEntries +// } +// +// var sanitizedDirEntries []os.DirEntry +// for _, dirEntry := range dirEntries { +// isHidden, _ := IsHidden(dirEntry.Name()) +// if !isHidden { +// if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) { +// sanitizedDirEntries = append(sanitizedDirEntries, dirEntry) +// } +// } +// } +// +// return sanitizedDirEntries +// +// case err := <-errChan: +// logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err) +// return []os.DirEntry{} +// +// case <-time.After(5 * time.Second): +// logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil) +// return []os.DirEntry{} +// } +// } +// +// func IsHidden(file string) (bool, error) { +// return strings.HasPrefix(file, "."), nil +// } +// +// func isExtSupported(path string) bool { +// ext := strings.ToLower(filepath.Ext(path)) +// return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png") +// } diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go index d80031c6115e6aaed7b071406c129ac7ceecc233..cf1949b3c1fc0ec8020ccad0a65fee2fd58b9fce 100644 --- a/internal/tui/components/dialogs/filepicker/filepicker.go +++ b/internal/tui/components/dialogs/filepicker/filepicker.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/tui/components/core" "github.com/opencode-ai/opencode/internal/tui/components/dialogs" + "github.com/opencode-ai/opencode/internal/tui/components/image" "github.com/opencode-ai/opencode/internal/tui/styles" ) @@ -23,11 +24,13 @@ type FilePicker interface { } type filePicker struct { - wWidth int - wHeight int - width int - filepicker filepicker.Model - selectedFile string + wWidth int + wHeight int + width int + filepicker filepicker.Model + selectedFile string + highlightedFile string + image image.Model } func NewFilePickerCmp() FilePicker { @@ -42,7 +45,8 @@ func NewFilePickerCmp() FilePicker { fp.Cursor = "" fp.SetHeight(fileSelectionHight) - return &filePicker{filepicker: fp} + image := image.New(1, 1, "") + return &filePicker{filepicker: fp, image: image} } func (m *filePicker) Init() tea.Cmd { @@ -65,15 +69,24 @@ func (m *filePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd + var cmds []tea.Cmd m.filepicker, cmd = m.filepicker.Update(msg) + cmds = append(cmds, cmd) + if m.highlightedFile != m.currentImage() && m.currentImage() != "" { + w, h := m.imagePreviewSize() + cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage()) + cmds = append(cmds, cmd) + } + m.highlightedFile = m.currentImage() // Did the user select a file? if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { // Get the path of the selected file. m.selectedFile = path } - - return m, cmd + m.image, cmd = m.image.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) } func (m *filePicker) View() tea.View { @@ -98,21 +111,23 @@ func (m *filePicker) currentImage() string { } func (m *filePicker) imagePreview() string { + t := styles.CurrentTheme() + w, h := m.imagePreviewSize() if m.currentImage() == "" { - return m.imagePreviewStyle().Render() + imgPreview := t.S().Base. + Width(w). + Height(h). + Background(t.BgOverlay) + + return m.imagePreviewStyle().Render(imgPreview.Render()) } - return "" + return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View()) } func (m *filePicker) imagePreviewStyle() lipgloss.Style { t := styles.CurrentTheme() - w, h := m.imagePreviewSize() - return t.S().Base. - Width(w). - Height(h). - Margin(1). - Background(t.BgOverlay) + return t.S().Base.Padding(1, 1, 1, 1) } func (m *filePicker) imagePreviewSize() (int, int) { diff --git a/internal/tui/components/image/image.go b/internal/tui/components/image/image.go new file mode 100644 index 0000000000000000000000000000000000000000..d1dbfea85c825a66c4543e152511a767c874f838 --- /dev/null +++ b/internal/tui/components/image/image.go @@ -0,0 +1,89 @@ +// Based on the implementation by @trashhalo at: +// https://github.com/trashhalo/imgcat +package image + +import ( + "context" + "fmt" + _ "image/jpeg" + _ "image/png" + + tea "github.com/charmbracelet/bubbletea/v2" +) + +type Model struct { + url string + image string + width uint + height uint + err error + + cancelAnimation context.CancelFunc +} + +func New(width, height uint, url string) Model { + return Model{ + width: width, + height: height, + url: url, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case errMsg: + m.err = msg + return m, nil + case rewdrawMsg: + m.width = msg.width + m.height = msg.height + m.url = msg.url + return m, loadUrl(m.url) + case loadMsg: + return handleLoadMsg(m, msg) + } + return m, nil +} + +func (m Model) View() string { + if m.err != nil { + return fmt.Sprintf("couldn't load image(s): %v", m.err) + } + return m.image +} + +type errMsg struct{ error } + +func (m Model) Redraw(width uint, height uint, url string) tea.Cmd { + return func() tea.Msg { + return rewdrawMsg{ + width: width, + height: height, + url: url, + } + } +} + +func (m Model) UpdateUrl(url string) tea.Cmd { + return func() tea.Msg { + return rewdrawMsg{ + width: m.width, + height: m.height, + url: url, + } + } +} + +type rewdrawMsg struct { + width uint + height uint + url string +} + +func (m Model) IsLoading() bool { + return m.image == "" +} diff --git a/internal/tui/components/image/load.go b/internal/tui/components/image/load.go new file mode 100644 index 0000000000000000000000000000000000000000..f6015b8e2725bf3a5380eef11357e1b779bba62f --- /dev/null +++ b/internal/tui/components/image/load.go @@ -0,0 +1,157 @@ +// Based on the implementation by @trashhalo at: +// https://github.com/trashhalo/imgcat +package image + +import ( + "image" + "image/png" + "io" + "io/ioutil" + "net/http" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/disintegration/imageorient" + "github.com/lucasb-eyer/go-colorful" + "github.com/muesli/termenv" + "github.com/nfnt/resize" + "github.com/srwiley/oksvg" + "github.com/srwiley/rasterx" +) + +type loadMsg struct { + io.ReadCloser +} + +func loadUrl(url string) tea.Cmd { + var r io.ReadCloser + var err error + + if strings.HasPrefix(url, "http") { + var resp *http.Response + resp, err = http.Get(url) + r = resp.Body + } else { + r, err = os.Open(url) + } + + if err != nil { + return func() tea.Msg { + return errMsg{err} + } + } + + return load(r) +} + +func load(r io.ReadCloser) tea.Cmd { + return func() tea.Msg { + return loadMsg{r} + } +} + +func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) { + if m.cancelAnimation != nil { + m.cancelAnimation() + } + + // blank out image so it says "loading..." + m.image = "" + + return handleLoadMsgStatic(m, msg) +} + +func handleLoadMsgStatic(m Model, msg loadMsg) (Model, tea.Cmd) { + defer msg.Close() + + img, err := readerToimage(m.width, m.height, m.url, msg) + if err != nil { + return m, func() tea.Msg { return errMsg{err} } + } + m.image = img + return m, nil +} + +func imageToString(width, height uint, url string, img image.Image) (string, error) { + img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3) + b := img.Bounds() + w := b.Max.X + h := b.Max.Y + p := termenv.ColorProfile() + str := strings.Builder{} + for y := 0; y < h; y += 2 { + for x := w; x < int(width); x = x + 2 { + str.WriteString(" ") + } + for x := 0; x < w; x++ { + c1, _ := colorful.MakeColor(img.At(x, y)) + color1 := p.Color(c1.Hex()) + c2, _ := colorful.MakeColor(img.At(x, y+1)) + color2 := p.Color(c2.Hex()) + str.WriteString(termenv.String("▀"). + Foreground(color1). + Background(color2). + String()) + } + str.WriteString("\n") + } + return str.String(), nil +} + +func readerToimage(width uint, height uint, url string, r io.Reader) (string, error) { + if strings.HasSuffix(strings.ToLower(url), ".svg") { + return svgToimage(width, height, url, r) + } + + img, _, err := imageorient.Decode(r) + if err != nil { + return "", err + } + + return imageToString(width, height, url, img) +} + +func svgToimage(width uint, height uint, url string, r io.Reader) (string, error) { + // Original author: https://stackoverflow.com/users/10826783/usual-human + // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang + // Adapted to use size from SVG, and to use temp file. + + tmpPngFile, err := ioutil.TempFile("", "imgcat.*.png") + if err != nil { + return "", err + } + tmpPngPath := tmpPngFile.Name() + defer os.Remove(tmpPngPath) + defer tmpPngFile.Close() + + // Rasterize the SVG: + icon, err := oksvg.ReadIconStream(r) + if err != nil { + return "", err + } + w := int(icon.ViewBox.W) + h := int(icon.ViewBox.H) + icon.SetTarget(0, 0, float64(w), float64(h)) + rgba := image.NewRGBA(image.Rect(0, 0, w, h)) + icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1) + // Write rasterized image as PNG: + err = png.Encode(tmpPngFile, rgba) + if err != nil { + tmpPngFile.Close() + return "", err + } + tmpPngFile.Close() + + rPng, err := os.Open(tmpPngPath) + if err != nil { + return "", err + } + defer rPng.Close() + + img, _, err := imageorient.Decode(rPng) + if err != nil { + return "", err + } + return imageToString(width, height, url, img) +} diff --git a/internal/tui/image/images.go b/internal/tui/image/images.go deleted file mode 100644 index 72ce2b38f069deff64a367dc89003489d92a498c..0000000000000000000000000000000000000000 --- a/internal/tui/image/images.go +++ /dev/null @@ -1,73 +0,0 @@ -package image - -import ( - "fmt" - "image" - "image/color" - "os" - "strings" - - "github.com/charmbracelet/lipgloss/v2" - "github.com/disintegration/imaging" - "github.com/lucasb-eyer/go-colorful" -) - -func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) { - fileInfo, err := os.Stat(filePath) - if err != nil { - return false, fmt.Errorf("error getting file info: %w", err) - } - - if fileInfo.Size() > sizeLimit { - return true, nil - } - - return false, nil -} - -func ToString(width int, img image.Image) string { - img = imaging.Resize(img, width, 0, imaging.Lanczos) - b := img.Bounds() - imageWidth := b.Max.X - h := b.Max.Y - str := strings.Builder{} - - for heightCounter := 0; heightCounter < h; heightCounter += 2 { - for x := range imageWidth { - c1, _ := colorful.MakeColor(img.At(x, heightCounter)) - color1 := lipgloss.Color(c1.Hex()) - - var color2 color.Color - if heightCounter+1 < h { - c2, _ := colorful.MakeColor(img.At(x, heightCounter+1)) - color2 = lipgloss.Color(c2.Hex()) - } else { - color2 = color1 - } - - str.WriteString(lipgloss.NewStyle().Foreground(color1). - Background(color2).Render("▀")) - } - - str.WriteString("\n") - } - - return str.String() -} - -func ImagePreview(width int, filename string) (string, error) { - imageContent, err := os.Open(filename) - if err != nil { - return "", err - } - defer imageContent.Close() - - img, _, err := image.Decode(imageContent) - if err != nil { - return "", err - } - - imageString := ToString(width, img) - - return imageString, nil -}