| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "time" |
| 5 | |
| 6 | "charm.land/bubbles/v2/help" |
| 7 | "charm.land/bubbles/v2/list" |
| 8 | "charm.land/bubbles/v2/textinput" |
| 9 | "charm.land/bubbles/v2/viewport" |
| 10 | tea "charm.land/bubbletea/v2" |
| 11 | |
| 12 | "github.com/stevedylandev/andromeda/apps/blobs/preview" |
| 13 | ) |
| 14 | |
| 15 | type tuiState uint8 |
| 16 | |
| 17 | const ( |
| 18 | stateBuckets tuiState = iota |
| 19 | stateBrowse |
| 20 | ) |
| 21 | |
| 22 | type uploadPromptState uint8 |
| 23 | |
| 24 | const ( |
| 25 | uploadPromptOff uploadPromptState = iota |
| 26 | uploadPromptActive |
| 27 | ) |
| 28 | |
| 29 | type tuiModel struct { |
| 30 | s3 *S3Client |
| 31 | cfg ClientConfig |
| 32 | opts tuiOptions |
| 33 | |
| 34 | state tuiState |
| 35 | |
| 36 | bucketsList list.Model |
| 37 | browseList list.Model |
| 38 | |
| 39 | preview viewport.Model |
| 40 | previewProto preview.Protocol |
| 41 | showPreview bool |
| 42 | previewSeq int |
| 43 | |
| 44 | currentBucket string |
| 45 | currentPrefix string |
| 46 | |
| 47 | width, height int |
| 48 | ready bool |
| 49 | loading bool |
| 50 | |
| 51 | // modals |
| 52 | confirmDelete bool |
| 53 | uploadPrompt uploadPromptState |
| 54 | uploadInput textinput.Model |
| 55 | |
| 56 | // help overlay |
| 57 | showHelp bool |
| 58 | help help.Model |
| 59 | keys tuiKeyMap |
| 60 | |
| 61 | // status |
| 62 | status string |
| 63 | statusOK bool |
| 64 | statusUntil time.Time |
| 65 | } |
| 66 | |
| 67 | func newTUIModel(s3 *S3Client, cfg ClientConfig, opts tuiOptions) tuiModel { |
| 68 | bl := newList("buckets", nil) |
| 69 | brl := newList("/", nil) |
| 70 | ti := textinput.New() |
| 71 | ti.Placeholder = "/path/to/file" |
| 72 | ti.Prompt = "upload: " |
| 73 | |
| 74 | m := tuiModel{ |
| 75 | s3: s3, |
| 76 | cfg: cfg, |
| 77 | opts: opts, |
| 78 | state: stateBuckets, |
| 79 | bucketsList: bl, |
| 80 | browseList: brl, |
| 81 | preview: viewport.New(), |
| 82 | previewProto: preview.Detect(), |
| 83 | showPreview: true, |
| 84 | help: help.New(), |
| 85 | keys: defaultTUIKeys(), |
| 86 | uploadInput: ti, |
| 87 | loading: true, |
| 88 | } |
| 89 | if cfg.DefaultBucket != "" { |
| 90 | m.state = stateBrowse |
| 91 | m.currentBucket = cfg.DefaultBucket |
| 92 | m.currentPrefix = opts.Prefix |
| 93 | } |
| 94 | return m |
| 95 | } |
| 96 | |
| 97 | func (m tuiModel) Init() tea.Cmd { |
| 98 | cmds := []tea.Cmd{tea.RequestWindowSize} |
| 99 | if m.state == stateBrowse { |
| 100 | cmds = append(cmds, loadListingCmd(m.s3, m.currentBucket, m.currentPrefix)) |
| 101 | } else { |
| 102 | cmds = append(cmds, loadBucketsCmd(m.s3)) |
| 103 | } |
| 104 | return tea.Batch(cmds...) |
| 105 | } |
| 106 | |
| 107 | func (m *tuiModel) setStatus(text string, ok bool) tea.Cmd { |
| 108 | m.status = text |
| 109 | m.statusOK = ok |
| 110 | m.statusUntil = time.Now().Add(2 * time.Second) |
| 111 | return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} }) |
| 112 | } |
| 113 | |
| 114 | func (m *tuiModel) selectedFile() (ObjectInfo, bool) { |
| 115 | if m.state != stateBrowse { |
| 116 | return ObjectInfo{}, false |
| 117 | } |
| 118 | it := m.browseList.SelectedItem() |
| 119 | if it == nil { |
| 120 | return ObjectInfo{}, false |
| 121 | } |
| 122 | f, ok := it.(fileItemTUI) |
| 123 | if !ok { |
| 124 | return ObjectInfo{}, false |
| 125 | } |
| 126 | return f.obj, true |
| 127 | } |
| 128 | |
| 129 | func (m *tuiModel) selectedFolderPrefix() (string, bool) { |
| 130 | if m.state != stateBrowse { |
| 131 | return "", false |
| 132 | } |
| 133 | it := m.browseList.SelectedItem() |
| 134 | if it == nil { |
| 135 | return "", false |
| 136 | } |
| 137 | f, ok := it.(folderItemTUI) |
| 138 | if !ok { |
| 139 | return "", false |
| 140 | } |
| 141 | return f.prefix, true |
| 142 | } |
| 143 | |
| 144 | func (m *tuiModel) selectedBucket() (string, bool) { |
| 145 | if m.state != stateBuckets { |
| 146 | return "", false |
| 147 | } |
| 148 | it := m.bucketsList.SelectedItem() |
| 149 | if it == nil { |
| 150 | return "", false |
| 151 | } |
| 152 | b, ok := it.(bucketItem) |
| 153 | if !ok { |
| 154 | return "", false |
| 155 | } |
| 156 | return b.b.Name, true |
| 157 | } |
| 158 | |
| 159 | func (m *tuiModel) applyLayout() { |
| 160 | if !m.ready { |
| 161 | return |
| 162 | } |
| 163 | bodyH := m.height - 2 |
| 164 | if bodyH < 5 { |
| 165 | bodyH = 5 |
| 166 | } |
| 167 | |
| 168 | switch m.state { |
| 169 | case stateBuckets: |
| 170 | fw, fh := paneFrameW(), paneFrameH() |
| 171 | m.bucketsList.SetSize(max(m.width-fw, 1), max(bodyH-fh, 1)) |
| 172 | case stateBrowse: |
| 173 | listW := m.width |
| 174 | if m.showPreview { |
| 175 | listW = m.width / 2 |
| 176 | } |
| 177 | fw, fh := paneFrameW(), paneFrameH() |
| 178 | m.browseList.SetSize(max(listW-fw, 1), max(bodyH-fh, 1)) |
| 179 | if m.showPreview { |
| 180 | pw := m.width - listW |
| 181 | m.preview.SetWidth(max(pw-fw, 1)) |
| 182 | m.preview.SetHeight(max(bodyH-fh-1, 1)) |
| 183 | } |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func setItemsFromListing(l *list.Model, prefix string, folders []string, files []ObjectInfo) tea.Cmd { |
| 188 | items := make([]list.Item, 0, len(folders)+len(files)) |
| 189 | for _, fp := range folders { |
| 190 | name := fp |
| 191 | if len(prefix) <= len(fp) { |
| 192 | name = fp[len(prefix):] |
| 193 | } |
| 194 | name = trimTrailingSlash(name) |
| 195 | items = append(items, folderItemTUI{prefix: fp, name: name}) |
| 196 | } |
| 197 | for _, f := range files { |
| 198 | items = append(items, fileItemTUI{obj: f}) |
| 199 | } |
| 200 | return l.SetItems(items) |
| 201 | } |
| 202 | |
| 203 | func trimTrailingSlash(s string) string { |
| 204 | if len(s) > 0 && s[len(s)-1] == '/' { |
| 205 | return s[:len(s)-1] |
| 206 | } |
| 207 | return s |
| 208 | } |