From 88d7c9b4b4a71fea0592dca94b8913dcf38ba85e Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Sat, 23 May 2026 02:09:15 +0900 Subject: [PATCH] feat: add file picker attachments Open a modal file browser from compose, support selecting and removing pending attachments before send, and upload multi-file batches as a single Slack share. --- cmd/slk/main.go | 16 +- internal/slack/client.go | 58 ++++ internal/slack/client_test.go | 73 +++++ internal/ui/app.go | 95 +++++++ internal/ui/app_test.go | 58 ++++ internal/ui/compose/model.go | 110 +++++++- internal/ui/compose/model_test.go | 94 +++++++ internal/ui/filepicker/model.go | 399 +++++++++++++++++++++++++++ internal/ui/filepicker/model_test.go | 118 ++++++++ internal/ui/keys.go | 2 + internal/ui/mode.go | 3 + 11 files changed, 1008 insertions(+), 18 deletions(-) create mode 100644 internal/ui/filepicker/model.go create mode 100644 internal/ui/filepicker/model_test.go diff --git a/cmd/slk/main.go b/cmd/slk/main.go index a2eebf29..d6041dc4 100644 --- a/cmd/slk/main.go +++ b/cmd/slk/main.go @@ -1095,6 +1095,7 @@ func run() error { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() + params := make([]slack.UploadFileParameters, 0, len(attachments)) for i, att := range attachments { p.Send(ui.UploadProgressMsg{Done: i, Total: len(attachments)}) @@ -1110,14 +1111,15 @@ func run() error { reader = f } - currentCaption := "" - if i == len(attachments)-1 { - currentCaption = caption - } + params = append(params, slack.UploadFileParameters{ + Filename: att.Filename, + Reader: reader, + FileSize: int(att.Size), + }) + } - if _, err := client.UploadFile(ctx, channelID, threadTS, att.Filename, reader, att.Size, currentCaption); err != nil { - return ui.UploadResultMsg{Err: fmt.Errorf("uploading %s (%d/%d): %w", att.Filename, i+1, len(attachments), err)} - } + if _, err := client.UploadFiles(ctx, channelID, threadTS, params, caption); err != nil { + return ui.UploadResultMsg{Err: err} } p.Send(ui.UploadProgressMsg{Done: len(attachments), Total: len(attachments)}) return ui.UploadResultMsg{Err: nil} diff --git a/internal/slack/client.go b/internal/slack/client.go index d1704788..1613febc 100644 --- a/internal/slack/client.go +++ b/internal/slack/client.go @@ -44,6 +44,9 @@ type SlackAPI interface { EndDNDContext(ctx context.Context) error GetDNDInfoContext(ctx context.Context, user *string, options ...slack.ParamOption) (*slack.DNDStatus, error) UploadFileContext(ctx context.Context, params slack.UploadFileParameters) (*slack.FileSummary, error) + GetUploadURLExternalContext(ctx context.Context, params slack.GetUploadURLExternalParameters) (*slack.GetUploadURLExternalResponse, error) + UploadToURL(ctx context.Context, params slack.UploadToURLParameters) error + CompleteUploadExternalContext(ctx context.Context, params slack.CompleteUploadExternalParameters) (*slack.CompleteUploadExternalResponse, error) } // defaultAPIBaseURL is the canonical Slack Web API root used as a fallback @@ -679,6 +682,61 @@ func (c *Client) UploadFile( return f, nil } +// UploadFiles uploads multiple files and completes them in a single share so +// Slack renders them under one message. caption, when non-empty, is applied to +// the shared message created by files.completeUploadExternal. +func (c *Client) UploadFiles( + ctx context.Context, + channelID, threadTS string, + files []slack.UploadFileParameters, + caption string, +) ([]slack.FileSummary, error) { + if len(files) == 0 { + return nil, nil + } + uploads := make([]slack.FileSummary, 0, len(files)) + for _, file := range files { + if file.Filename == "" { + return nil, fmt.Errorf("uploading file: filename cannot be empty") + } + if file.FileSize == 0 { + return nil, fmt.Errorf("uploading %q: file size cannot be 0", file.Filename) + } + u, err := c.api.GetUploadURLExternalContext(ctx, slack.GetUploadURLExternalParameters{ + AltTxt: file.AltTxt, + FileName: file.Filename, + FileSize: file.FileSize, + SnippetType: file.SnippetType, + }) + if err != nil { + return nil, fmt.Errorf("get upload URL for %q: %w", file.Filename, err) + } + if err := c.api.UploadToURL(ctx, slack.UploadToURLParameters{ + UploadURL: u.UploadURL, + Reader: file.Reader, + File: file.File, + Content: file.Content, + Filename: file.Filename, + }); err != nil { + return nil, fmt.Errorf("uploading %q to external URL: %w", file.Filename, err) + } + uploads = append(uploads, slack.FileSummary{ID: u.FileID, Title: file.Title}) + } + resp, err := c.api.CompleteUploadExternalContext(ctx, slack.CompleteUploadExternalParameters{ + Files: uploads, + Channel: channelID, + InitialComment: caption, + ThreadTimestamp: threadTS, + }) + if err != nil { + return nil, fmt.Errorf("complete external upload: %w", err) + } + if len(resp.Files) != len(files) { + return nil, fmt.Errorf("complete external upload: got %d files, want %d", len(resp.Files), len(files)) + } + return resp.Files, nil +} + // UnreadInfo holds the unread state for a single channel. type UnreadInfo struct { ChannelID string diff --git a/internal/slack/client_test.go b/internal/slack/client_test.go index e8167783..b8f18a59 100644 --- a/internal/slack/client_test.go +++ b/internal/slack/client_test.go @@ -145,6 +145,9 @@ type mockSlackAPI struct { endDNDContextFn func(ctx context.Context) error getDNDInfoContextFn func(ctx context.Context, user *string, options ...slack.ParamOption) (*slack.DNDStatus, error) uploadFileContextFn func(ctx context.Context, params slack.UploadFileParameters) (*slack.FileSummary, error) + getUploadURLExternalContextFn func(ctx context.Context, params slack.GetUploadURLExternalParameters) (*slack.GetUploadURLExternalResponse, error) + uploadToURLFn func(ctx context.Context, params slack.UploadToURLParameters) error + completeUploadExternalContextFn func(ctx context.Context, params slack.CompleteUploadExternalParameters) (*slack.CompleteUploadExternalResponse, error) getUsersInConversationContextFn func(ctx context.Context, params *slack.GetUsersInConversationParameters) ([]string, string, error) } @@ -275,6 +278,27 @@ func (m *mockSlackAPI) UploadFileContext(ctx context.Context, params slack.Uploa return &slack.FileSummary{}, nil } +func (m *mockSlackAPI) GetUploadURLExternalContext(ctx context.Context, params slack.GetUploadURLExternalParameters) (*slack.GetUploadURLExternalResponse, error) { + if m.getUploadURLExternalContextFn != nil { + return m.getUploadURLExternalContextFn(ctx, params) + } + return &slack.GetUploadURLExternalResponse{}, nil +} + +func (m *mockSlackAPI) UploadToURL(ctx context.Context, params slack.UploadToURLParameters) error { + if m.uploadToURLFn != nil { + return m.uploadToURLFn(ctx, params) + } + return nil +} + +func (m *mockSlackAPI) CompleteUploadExternalContext(ctx context.Context, params slack.CompleteUploadExternalParameters) (*slack.CompleteUploadExternalResponse, error) { + if m.completeUploadExternalContextFn != nil { + return m.completeUploadExternalContextFn(ctx, params) + } + return &slack.CompleteUploadExternalResponse{}, nil +} + func (m *mockSlackAPI) GetUsersInConversationContext(ctx context.Context, params *slack.GetUsersInConversationParameters) ([]string, string, error) { if m.getUsersInConversationContextFn != nil { return m.getUsersInConversationContextFn(ctx, params) @@ -361,6 +385,55 @@ func TestUploadFile_ErrorWraps(t *testing.T) { } } +func TestUploadFiles_SharesBatchWithSingleComment(t *testing.T) { + var complete slack.CompleteUploadExternalParameters + var uploaded []string + mock := &mockSlackAPI{ + getUploadURLExternalContextFn: func(ctx context.Context, params slack.GetUploadURLExternalParameters) (*slack.GetUploadURLExternalResponse, error) { + return &slack.GetUploadURLExternalResponse{UploadURL: "https://upload.example/" + params.FileName, FileID: "F-" + params.FileName}, nil + }, + uploadToURLFn: func(ctx context.Context, params slack.UploadToURLParameters) error { + uploaded = append(uploaded, params.Filename) + return nil + }, + completeUploadExternalContextFn: func(ctx context.Context, params slack.CompleteUploadExternalParameters) (*slack.CompleteUploadExternalResponse, error) { + complete = params + return &slack.CompleteUploadExternalResponse{Files: params.Files}, nil + }, + } + c := &Client{api: mock} + files := []slack.UploadFileParameters{ + {Filename: "a.png", Reader: strings.NewReader("aaa"), FileSize: 3, Title: "A"}, + {Filename: "b.png", Reader: strings.NewReader("bbbb"), FileSize: 4, Title: "B"}, + } + + got, err := c.UploadFiles(context.Background(), "C1", "123.456", files, "caption") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 uploaded files, got %d", len(got)) + } + if len(uploaded) != 2 || uploaded[0] != "a.png" || uploaded[1] != "b.png" { + t.Fatalf("unexpected uploaded sequence: %#v", uploaded) + } + if complete.Channel != "C1" { + t.Fatalf("expected channel C1, got %q", complete.Channel) + } + if complete.ThreadTimestamp != "123.456" { + t.Fatalf("expected thread ts preserved, got %q", complete.ThreadTimestamp) + } + if complete.InitialComment != "caption" { + t.Fatalf("expected shared comment caption, got %q", complete.InitialComment) + } + if len(complete.Files) != 2 { + t.Fatalf("expected 2 file summaries, got %d", len(complete.Files)) + } + if complete.Files[0].ID != "F-a.png" || complete.Files[1].ID != "F-b.png" { + t.Fatalf("unexpected file IDs: %#v", complete.Files) + } +} + func TestClient_SetUserPresence(t *testing.T) { var calls int var gotPresence string diff --git a/internal/ui/app.go b/internal/ui/app.go index 27dfeb3e..a93f7174 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -30,6 +30,7 @@ import ( "github.com/gammons/slk/internal/ui/channelpicker" "github.com/gammons/slk/internal/ui/compose" "github.com/gammons/slk/internal/ui/confirmprompt" + "github.com/gammons/slk/internal/ui/filepicker" "github.com/gammons/slk/internal/ui/help" "github.com/gammons/slk/internal/ui/imgrender" "github.com/gammons/slk/internal/ui/mentionpicker" @@ -707,6 +708,7 @@ type App struct { statusbar statusbar.Model channelFinder channelfinder.Model workspaceFinder workspacefinder.Model + filePicker filepicker.Model themeSwitcher themeswitcher.Model presenceMenu presencemenu.Model help help.Model @@ -1014,6 +1016,7 @@ func NewApp() *App { statusbar: statusbar.New(), channelFinder: channelfinder.New(), workspaceFinder: workspacefinder.New(), + filePicker: filepicker.New(), themeSwitcher: themeswitcher.New(), presenceMenu: presencemenu.New(), help: help.New(), @@ -2734,6 +2737,8 @@ func (a *App) handleKey(msg tea.KeyMsg) tea.Cmd { return a.handleConfirmMode(msg) case ModeWorkspaceFinder: return a.handleWorkspaceFinderMode(msg) + case ModeFilePicker: + return a.handleFilePickerMode(msg) case ModeThemeSwitcher: return a.handleThemeSwitcherMode(msg) case ModePresenceMenu: @@ -3148,6 +3153,9 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { if isPaste { return a.smartPaste() } + if key.Matches(msg, a.keys.AttachFile) { + return a.openFilePicker() + } // Insert-mode shortcuts that operate on the active compose: // Ctrl+U → clear compose (text + attachments + uploading flag) @@ -3161,6 +3169,20 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { target.Reset() return nil } + if len(target.Attachments()) > 0 { + switch { + case code == tea.KeyLeft && mod.Contains(tea.ModAlt), code == 'h' && mod == tea.ModCtrl: + target.SelectPrevAttachment() + return nil + case code == tea.KeyRight && mod.Contains(tea.ModAlt), code == 'l' && mod == tea.ModCtrl: + target.SelectNextAttachment() + return nil + case code == tea.KeyDelete || (code == tea.KeyBackspace && target.SelectedAttachmentIndex() >= 0 && target.Value() == ""): + if _, ok := target.RemoveSelectedAttachment(); ok { + return nil + } + } + } // If a compose-overlay picker (emoji / @mention / #channel) is active, // let it own Up/Down so users can navigate the suggestion list. Without // this guard, the jump-to-start/end shortcuts below swallow the arrow @@ -3368,6 +3390,37 @@ func (a *App) handleWorkspaceFinderMode(msg tea.KeyMsg) tea.Cmd { return nil } +func (a *App) handleFilePickerMode(msg tea.KeyMsg) tea.Cmd { + keyStr := msg.String() + switch msg.Key().Code { + case tea.KeyEnter: + keyStr = "enter" + case tea.KeyEscape: + keyStr = "esc" + case tea.KeyUp: + keyStr = "up" + case tea.KeyDown: + keyStr = "down" + case tea.KeyBackspace: + keyStr = "backspace" + case tea.KeyLeft: + keyStr = "h" + } + result := a.filePicker.HandleKey(keyStr) + if result != nil { + a.SetMode(ModeInsert) + return a.attachFileToActiveCompose(result.Path) + } + if !a.filePicker.IsVisible() { + a.SetMode(ModeInsert) + if a.focusedPanel == PanelThread && a.threadVisible { + return a.threadCompose.Focus() + } + return a.compose.Focus() + } + return nil +} + func (a *App) handleThemeSwitcherMode(msg tea.KeyMsg) tea.Cmd { keyStr := msg.String() switch msg.Key().Code { @@ -5663,6 +5716,10 @@ func (a *App) View() tea.View { screen = a.workspaceFinder.ViewOverlay(a.width, a.height, screen) } + if a.filePicker.IsVisible() { + screen = a.filePicker.ViewOverlay(a.width, a.height, screen) + } + if a.themeSwitcher.IsVisible() { screen = a.themeSwitcher.ViewOverlay(a.width, a.height, screen) } @@ -5696,6 +5753,7 @@ func (a *App) View() tea.View { a.reactionPicker.IsVisible() || a.confirmPrompt.IsVisible() || a.workspaceFinder.IsVisible() || + a.filePicker.IsVisible() || a.themeSwitcher.IsVisible() || a.presenceMenu.IsVisible() || a.mode == ModePresenceCustomSnooze || @@ -5831,6 +5889,43 @@ func (a *App) smartPaste() tea.Cmd { return nil } +func (a *App) openFilePicker() tea.Cmd { + if a.compose.Uploading() || a.threadCompose.Uploading() { + return a.uploadToastCmd("Upload in progress", 2*time.Second) + } + a.filePicker.Open() + a.SetMode(ModeFilePicker) + return nil +} + +func (a *App) attachFileToActiveCompose(path string) tea.Cmd { + target := &a.compose + if a.focusedPanel == PanelThread && a.threadVisible { + target = &a.threadCompose + } + info, err := os.Stat(path) + if err != nil { + return a.uploadToastCmd("Cannot attach: "+truncateReason(err.Error(), 40), 3*time.Second) + } + if !info.Mode().IsRegular() { + return a.uploadToastCmd("Cannot attach: not a regular file", 3*time.Second) + } + if info.Size() > maxAttachmentSize { + return a.uploadToastCmd("File too large (>10 MB limit)", 3*time.Second) + } + if info.Size() == 0 { + return a.uploadToastCmd("Empty file", 2*time.Second) + } + filename := filepath.Base(path) + target.AddAttachment(compose.PendingAttachment{ + Filename: filename, + Path: path, + Mime: mime.TypeByExtension(filepath.Ext(path)), + Size: info.Size(), + }) + return a.uploadToastCmd(fmt.Sprintf("Attached: %s (%s)", filename, humanSize(info.Size())), 2*time.Second) +} + // tryAttachFromClipboard inspects the OS clipboard for an image and the // supplied text for a file-path reference, attaching the first match // to the given compose. Returns consumed=true if an attachment (or an diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 80371659..080dbe44 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -2136,6 +2136,64 @@ func TestUploadResultMsg_FailureKeepsAttachments(t *testing.T) { } } +func TestOpenFilePickerFromInsertMode(t *testing.T) { + app := NewApp() + app.SetMode(ModeInsert) + app.focusedPanel = PanelMessages + _ = app.compose.Focus() + + app.handleInsertMode(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl}) + + if app.mode != ModeFilePicker { + t.Fatalf("expected ModeFilePicker, got %v", app.mode) + } + if !app.filePicker.IsVisible() { + t.Fatal("expected file picker visible") + } +} + +func TestAttachFileToActiveComposeAddsPendingAttachment(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "doc.txt") + if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + app := NewApp() + app.focusedPanel = PanelMessages + + cmd := app.attachFileToActiveCompose(path) + if cmd == nil { + t.Fatal("expected toast cmd") + } + atts := app.compose.Attachments() + if len(atts) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(atts)) + } + if atts[0].Path != path { + t.Fatalf("expected path %q, got %q", path, atts[0].Path) + } +} + +func TestHandleInsertMode_RemoveSelectedAttachment(t *testing.T) { + app := NewApp() + app.SetMode(ModeInsert) + app.focusedPanel = PanelMessages + _ = app.compose.Focus() + app.compose.AddAttachment(compose.PendingAttachment{Filename: "a.png", Size: 1}) + app.compose.AddAttachment(compose.PendingAttachment{Filename: "b.png", Size: 2}) + + app.handleInsertMode(tea.KeyPressMsg{Code: 'l', Mod: tea.ModCtrl}) + app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyDelete}) + + atts := app.compose.Attachments() + if len(atts) != 1 { + t.Fatalf("expected 1 attachment after delete, got %d", len(atts)) + } + if atts[0].Filename != "b.png" { + t.Fatalf("expected b.png to remain, got %q", atts[0].Filename) + } +} + func TestEscDuringUpload_RefusedWithToast(t *testing.T) { app := NewApp() app.SetMode(ModeInsert) diff --git a/internal/ui/compose/model.go b/internal/ui/compose/model.go index 27a92f55..552f41d7 100644 --- a/internal/ui/compose/model.go +++ b/internal/ui/compose/model.go @@ -83,6 +83,10 @@ type Model struct { // successful submit; preserved on failure for retry. pending []PendingAttachment + // selectedAttachment is the selected pending attachment chip. -1 means + // no chip is selected. + selectedAttachment int + // uploading is true while attachments are mid-upload. Causes the // chip row to render in muted style and the Update() to refuse // Esc / Backspace-clear. @@ -158,8 +162,9 @@ func New(channelName string) Model { ta.SetStyles(s) return Model{ - input: ta, - channelName: channelName, + input: ta, + channelName: channelName, + selectedAttachment: -1, } } @@ -232,16 +237,86 @@ func (m *Model) AddAttachment(a PendingAttachment) { m.dirty() } +// RemoveAttachment removes the pending attachment at index and returns it. +func (m *Model) RemoveAttachment(index int) (PendingAttachment, bool) { + if index < 0 || index >= len(m.pending) || m.uploading { + return PendingAttachment{}, false + } + removed := m.pending[index] + m.pending = append(m.pending[:index], m.pending[index+1:]...) + m.clampAttachmentSelection() + m.dirty() + return removed, true +} + +// RemoveSelectedAttachment removes the selected pending attachment and returns it. +func (m *Model) RemoveSelectedAttachment() (PendingAttachment, bool) { + if m.selectedAttachment < 0 { + return PendingAttachment{}, false + } + return m.RemoveAttachment(m.selectedAttachment) +} + // RemoveLastAttachment removes the most-recently-added pending // attachment and returns it. Returns ok=false if pending is empty. func (m *Model) RemoveLastAttachment() (PendingAttachment, bool) { - if len(m.pending) == 0 { - return PendingAttachment{}, false + return m.RemoveAttachment(len(m.pending) - 1) +} + +// HasAttachments reports whether there are pending attachments. +func (m *Model) HasAttachments() bool { return len(m.pending) > 0 } + +// SelectedAttachmentIndex returns the selected pending attachment index, or -1. +func (m *Model) SelectedAttachmentIndex() int { return m.selectedAttachment } + +// SelectPrevAttachment selects the previous pending attachment chip. +func (m *Model) SelectPrevAttachment() bool { + if len(m.pending) == 0 || m.uploading { + return false + } + if m.selectedAttachment < 0 || m.selectedAttachment >= len(m.pending) { + m.selectedAttachment = len(m.pending) - 1 + } else if m.selectedAttachment > 0 { + m.selectedAttachment-- + } + m.dirty() + return true +} + +// SelectNextAttachment selects the next pending attachment chip. +func (m *Model) SelectNextAttachment() bool { + if len(m.pending) == 0 || m.uploading { + return false + } + if m.selectedAttachment < 0 || m.selectedAttachment >= len(m.pending) { + m.selectedAttachment = 0 + } else if m.selectedAttachment < len(m.pending)-1 { + m.selectedAttachment++ + } + m.dirty() + return true +} + +// ClearAttachmentSelection clears any selected pending attachment chip. +func (m *Model) ClearAttachmentSelection() { + if m.selectedAttachment == -1 { + return } - last := m.pending[len(m.pending)-1] - m.pending = m.pending[:len(m.pending)-1] + m.selectedAttachment = -1 m.dirty() - return last, true +} + +func (m *Model) clampAttachmentSelection() { + if len(m.pending) == 0 { + m.selectedAttachment = -1 + return + } + if m.selectedAttachment >= len(m.pending) { + m.selectedAttachment = len(m.pending) - 1 + } + if m.selectedAttachment < -1 { + m.selectedAttachment = -1 + } } // Attachments returns a copy of the current pending attachments. @@ -260,6 +335,7 @@ func (m *Model) ClearAttachments() { return } m.pending = nil + m.selectedAttachment = -1 m.dirty() } @@ -269,8 +345,11 @@ func (m *Model) SetUploading(on bool) { if m.uploading == on { return } - m.uploading = on - m.dirty() + m.uploading = on + if on { + m.selectedAttachment = -1 + } + m.dirty() } // Uploading reports whether an upload is currently in flight. @@ -317,6 +396,7 @@ func (m *Model) Reset() { m.emojiActive = false m.emojiPicker.Close() m.pending = nil + m.selectedAttachment = -1 m.uploading = false m.dirty() } @@ -1154,17 +1234,25 @@ func (m Model) renderChips(width int) string { Foreground(fg). Padding(0, 1). MarginRight(1) + selectedChipStyle := chipStyle. + Background(styles.Primary). + Foreground(styles.Background). + Bold(true) const maxNameLen = 32 var rendered []string - for _, p := range m.pending { + for i, p := range m.pending { name := p.Filename runes := []rune(name) if len(runes) > maxNameLen { name = string(runes[:maxNameLen-1]) + "…" } label := fmt.Sprintf("📎 %s %s", name, formatChipSize(p.Size)) - rendered = append(rendered, chipStyle.Render(label)) + style := chipStyle + if !m.uploading && i == m.selectedAttachment { + style = selectedChipStyle + } + rendered = append(rendered, style.Render(label)) } row := lipgloss.JoinHorizontal(lipgloss.Top, rendered...) diff --git a/internal/ui/compose/model_test.go b/internal/ui/compose/model_test.go index a1e5fcd5..2db41d90 100644 --- a/internal/ui/compose/model_test.go +++ b/internal/ui/compose/model_test.go @@ -731,10 +731,15 @@ func TestSetUploading(t *testing.T) { if m.Uploading() { t.Error("expected !Uploading() initially") } + m.AddAttachment(PendingAttachment{Filename: "a.png", Size: 1}) + m.SelectNextAttachment() m.SetUploading(true) if !m.Uploading() { t.Error("expected Uploading() after SetUploading(true)") } + if m.SelectedAttachmentIndex() != -1 { + t.Error("expected selected attachment cleared while uploading") + } m.SetUploading(false) if m.Uploading() { t.Error("expected !Uploading() after SetUploading(false)") @@ -775,6 +780,95 @@ func TestComposeView_MultipleAttachments_AllChipsRender(t *testing.T) { } } +func TestAttachmentSelectionAndIndexedRemoval(t *testing.T) { + m := New("general") + m.AddAttachment(PendingAttachment{Filename: "a.png", Size: 1}) + m.AddAttachment(PendingAttachment{Filename: "b.png", Size: 2}) + m.AddAttachment(PendingAttachment{Filename: "c.png", Size: 3}) + + if m.SelectedAttachmentIndex() != -1 { + t.Fatalf("expected no default selection, got %d", m.SelectedAttachmentIndex()) + } + if !m.SelectNextAttachment() || m.SelectedAttachmentIndex() != 0 { + t.Fatalf("expected first SelectNextAttachment to select index 0, got %d", m.SelectedAttachmentIndex()) + } + if !m.SelectNextAttachment() || m.SelectedAttachmentIndex() != 1 { + t.Fatalf("expected second SelectNextAttachment to select index 1, got %d", m.SelectedAttachmentIndex()) + } + removed, ok := m.RemoveSelectedAttachment() + if !ok { + t.Fatal("expected RemoveSelectedAttachment ok=true") + } + if removed.Filename != "b.png" { + t.Fatalf("expected removed b.png, got %q", removed.Filename) + } + got := m.Attachments() + if len(got) != 2 { + t.Fatalf("expected 2 attachments remaining, got %d", len(got)) + } + if got[0].Filename != "a.png" || got[1].Filename != "c.png" { + t.Fatalf("unexpected attachments after removal: %#v", got) + } + if m.SelectedAttachmentIndex() != 1 { + t.Fatalf("expected selection to clamp to index 1, got %d", m.SelectedAttachmentIndex()) + } +} + +func TestClearAttachmentsClearsSelection(t *testing.T) { + m := New("general") + m.AddAttachment(PendingAttachment{Filename: "a.png", Size: 1}) + m.SelectNextAttachment() + m.ClearAttachments() + if len(m.Attachments()) != 0 { + t.Fatalf("expected no attachments after ClearAttachments") + } + if m.SelectedAttachmentIndex() != -1 { + t.Fatalf("expected selection cleared, got %d", m.SelectedAttachmentIndex()) + } +} + +func TestComposeView_SelectedAttachmentRendersHighlightedChip(t *testing.T) { + m := New("general") + m.AddAttachment(PendingAttachment{Filename: "a.png", Size: 1}) + m.SelectNextAttachment() + view := m.View(60, false) + if !strings.Contains(view, "a.png") { + t.Fatalf("expected selected attachment filename in view") + } +} + +func TestUpdate_BackspaceSelectedAttachment_RemovesSelected(t *testing.T) { + m := New("general") + m.AddAttachment(PendingAttachment{Filename: "a.png", Size: 1}) + m.AddAttachment(PendingAttachment{Filename: "b.png", Size: 2}) + m.SelectNextAttachment() + m.SelectNextAttachment() + + m2, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + got := m2.Attachments() + if len(got) != 1 { + t.Fatalf("expected 1 attachment after selected backspace, got %d", len(got)) + } + if got[0].Filename != "a.png" { + t.Fatalf("expected a.png to remain, got %q", got[0].Filename) + } +} + +func TestUpdate_BackspaceNoSelection_RemovesLastAttachment(t *testing.T) { + m := New("general") + m.AddAttachment(PendingAttachment{Filename: "a.png", Size: 1}) + m.AddAttachment(PendingAttachment{Filename: "b.png", Size: 2}) + + m2, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + got := m2.Attachments() + if len(got) != 1 { + t.Fatalf("expected 1 attachment after backspace, got %d", len(got)) + } + if got[0].Filename != "a.png" { + t.Fatalf("expected a.png to remain, got %q", got[0].Filename) + } +} + func TestUpdate_BackspaceAtColZeroEmpty_RemovesLastAttachment(t *testing.T) { m := New("general") m.AddAttachment(PendingAttachment{Filename: "a.png", Size: 1}) diff --git a/internal/ui/filepicker/model.go b/internal/ui/filepicker/model.go new file mode 100644 index 00000000..2bd863c6 --- /dev/null +++ b/internal/ui/filepicker/model.go @@ -0,0 +1,399 @@ +package filepicker + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "charm.land/lipgloss/v2" + "github.com/gammons/slk/internal/text" + "github.com/gammons/slk/internal/ui/messages" + "github.com/gammons/slk/internal/ui/overlay" + "github.com/gammons/slk/internal/ui/styles" + "github.com/muesli/reflow/truncate" +) + +type Result struct { + Path string +} + +type entry struct { + Name string + Path string + Dir bool + Parent bool + Size int64 +} + +type Model struct { + visible bool + cwd string + query string + entries []entry + filtered []int + selected int + err string +} + +func New() Model { return Model{} } + +func (m *Model) Open() { + cwd, err := os.Getwd() + if err != nil || cwd == "" { + cwd, _ = os.UserHomeDir() + } + m.OpenAt(cwd) +} + +func (m *Model) OpenAt(path string) { + m.visible = true + m.query = "" + m.selected = 0 + m.setDir(path) +} + +func (m *Model) Close() { + m.visible = false + m.query = "" + m.selected = 0 + m.entries = nil + m.filtered = nil + m.err = "" +} + +func (m Model) IsVisible() bool { return m.visible } + +func (m Model) Cwd() string { return m.cwd } + +func (m Model) Query() string { return m.query } + +func (m Model) FilteredCount() int { return len(m.filtered) } + +func (m Model) SelectedPath() string { + if len(m.filtered) == 0 || m.selected < 0 || m.selected >= len(m.filtered) { + return "" + } + return m.entries[m.filtered[m.selected]].Path +} + +func (m *Model) HandleKey(keyStr string) *Result { + if !m.visible { + return nil + } + switch keyStr { + case "enter": + return m.selectCurrent() + case "esc": + m.Close() + return nil + case "down", "ctrl+n", "j": + if m.selected < len(m.filtered)-1 { + m.selected++ + } + return nil + case "up", "ctrl+p", "k": + if m.selected > 0 { + m.selected-- + } + return nil + case "backspace": + if m.query != "" { + m.query = m.query[:len(m.query)-1] + m.selected = 0 + m.filter() + } else { + m.goParent() + } + return nil + case "h": + if m.query == "" { + m.goParent() + } else { + m.query += keyStr + m.selected = 0 + m.filter() + } + return nil + } + + if len(keyStr) == 1 && keyStr[0] >= 32 && keyStr[0] <= 126 { + m.query += keyStr + m.selected = 0 + m.filter() + } + return nil +} + +func (m *Model) selectCurrent() *Result { + if len(m.filtered) == 0 || m.selected < 0 || m.selected >= len(m.filtered) { + return nil + } + item := m.entries[m.filtered[m.selected]] + if item.Parent || item.Dir { + m.setDir(item.Path) + m.query = "" + m.selected = 0 + m.filter() + return nil + } + path := item.Path + m.Close() + return &Result{Path: path} +} + +func (m *Model) goParent() { + if m.cwd == "" { + return + } + parent := filepath.Dir(m.cwd) + if parent == m.cwd { + return + } + m.setDir(parent) +} + +func (m *Model) setDir(path string) { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + infos, err := os.ReadDir(abs) + if err != nil { + m.err = err.Error() + m.entries = nil + m.filtered = nil + m.cwd = abs + return + } + m.err = "" + m.cwd = abs + entries := []entry{} + parent := filepath.Dir(abs) + if parent != abs { + entries = append(entries, entry{Name: "..", Path: parent, Dir: true, Parent: true}) + } + for _, info := range infos { + name := info.Name() + if strings.HasPrefix(name, ".") { + continue + } + full := filepath.Join(abs, name) + item := entry{Name: name, Path: full, Dir: info.IsDir()} + if !info.IsDir() { + if stat, err := info.Info(); err == nil { + item.Size = stat.Size() + } + } + entries = append(entries, item) + } + sort.SliceStable(entries, func(i, j int) bool { + a, b := entries[i], entries[j] + if a.Parent != b.Parent { + return a.Parent + } + if a.Dir != b.Dir { + return a.Dir + } + return strings.ToLower(a.Name) < strings.ToLower(b.Name) + }) + m.entries = entries + m.selected = 0 + m.filter() +} + +func (m *Model) filter() { + m.filtered = nil + q := text.Fold(m.query) + for i, item := range m.entries { + if q == "" || strings.Contains(text.Fold(item.Name), q) { + m.filtered = append(m.filtered, i) + } + } + if m.selected >= len(m.filtered) { + m.selected = len(m.filtered) - 1 + } + if m.selected < 0 { + m.selected = 0 + } +} + +func (m Model) View(termWidth int) string { return m.renderBox(termWidth) } + +func (m Model) ViewOverlay(termWidth, termHeight int, background string) string { + if !m.visible { + return background + } + box := m.renderBox(termWidth) + if box == "" { + return background + } + result := overlay.DimmedOverlay(termWidth, termHeight, background, box, 0.5) + lines := strings.Split(result, "\n") + if len(lines) > termHeight { + lines = lines[:termHeight] + } + return strings.Join(lines, "\n") +} + +func (m Model) renderBox(termWidth int) string { + if !m.visible { + return "" + } + overlayWidth := termWidth * 55 / 100 + if overlayWidth < 50 { + overlayWidth = 50 + } + if overlayWidth > 100 { + overlayWidth = 100 + } + innerWidth := overlayWidth - 4 + bg := styles.Background + + title := lipgloss.NewStyle(). + Bold(true). + Background(bg). + Foreground(styles.Primary). + Render("Attach File") + + cwd := truncate.StringWithTail(m.cwd, uint(innerWidth), "…") + cwdLine := lipgloss.NewStyle().Background(bg).Foreground(styles.TextMuted).Render(cwd) + + inputText := m.query + "█" + if m.query == "" { + placeholder := lipgloss.NewStyle().Background(bg).Foreground(styles.TextMuted).Render("Type to filter files...") + inputText = "█ " + placeholder + } + input := lipgloss.NewStyle(). + BorderStyle(lipgloss.Border{Left: "▌"}). + BorderLeft(true). + BorderForeground(styles.Primary). + BorderBackground(bg). + PaddingLeft(1). + Background(bg). + Foreground(styles.TextPrimary). + Render(inputText) + + rows := m.renderRows(innerWidth) + footer := lipgloss.NewStyle().Background(bg).Foreground(styles.TextMuted). + Render("[enter] open/attach [h/backspace] parent [esc] cancel") + content := title + "\n" + cwdLine + "\n" + input + "\n\n" + strings.Join(rows, "\n") + "\n\n" + footer + content = messages.ReapplyBgAfterResets(content, messages.BgANSI()+messages.FgANSI()) + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(styles.Primary). + BorderBackground(bg). + Background(bg). + Padding(1, 1). + Width(overlayWidth). + Render(content) +} + +func (m Model) renderRows(innerWidth int) []string { + bg := styles.Background + if m.err != "" { + return []string{lipgloss.NewStyle().Background(styles.Background).Foreground(styles.Error).Render("Error: " + m.err)} + } + if len(m.filtered) == 0 { + return []string{lipgloss.NewStyle().Background(styles.Background).Foreground(styles.TextMuted).Italic(true).Render("No matching files")} + } + maxVisible := 12 + total := len(m.filtered) + if maxVisible > total { + maxVisible = total + } + start := 0 + if m.selected >= maxVisible { + start = m.selected - maxVisible + 1 + } + end := start + maxVisible + if end > total { + end = total + start = end - maxVisible + if start < 0 { + start = 0 + } + } + showScrollbar := total > maxVisible + contentWidth := innerWidth - 1 + if showScrollbar { + contentWidth-- + } + + thumbStart, thumbEnd := 0, 0 + if showScrollbar { + thumbHeight := maxVisible * maxVisible / total + if thumbHeight < 1 { + thumbHeight = 1 + } + denom := total - maxVisible + if denom < 1 { + denom = 1 + } + thumbStart = start * (maxVisible - thumbHeight) / denom + thumbEnd = thumbStart + thumbHeight + } + + thumbStyle := lipgloss.NewStyle().Background(bg).Foreground(styles.Primary) + trackStyle := lipgloss.NewStyle().Background(bg).Foreground(styles.Border) + var rows []string + for i := start; i < end; i++ { + item := m.entries[m.filtered[i]] + selected := i == m.selected + line := formatEntry(item) + if lipgloss.Width(line) > contentWidth { + line = truncate.StringWithTail(line, uint(contentWidth), "…") + } + if pad := contentWidth - lipgloss.Width(line); pad > 0 { + line += strings.Repeat(" ", pad) + } + nameStyle := lipgloss.NewStyle().Background(bg).Foreground(styles.TextPrimary) + if item.Dir { + nameStyle = nameStyle.Foreground(styles.Primary) + } + if selected { + nameStyle = nameStyle.Bold(true).Foreground(styles.Accent) + } + line = nameStyle.Render(line) + indicator := " " + if selected { + indicator = lipgloss.NewStyle().Background(bg).Foreground(styles.Accent).Render("▌") + } + row := indicator + line + if showScrollbar { + rel := i - start + if rel >= thumbStart && rel < thumbEnd { + row += thumbStyle.Render("█") + } else { + row += trackStyle.Render("│") + } + } + rows = append(rows, row) + } + return rows +} + +func formatEntry(item entry) string { + if item.Parent { + return "↩ .." + } + if item.Dir { + return "📁 " + item.Name + "/" + } + return fmt.Sprintf("📄 %s %s", item.Name, formatSize(item.Size)) +} + +func formatSize(size int64) string { + const kb = 1024 + const mb = 1024 * kb + switch { + case size >= mb: + return fmt.Sprintf("%.1f MB", float64(size)/float64(mb)) + case size >= kb: + return fmt.Sprintf("%d KB", size/kb) + default: + return "<1 KB" + } +} diff --git a/internal/ui/filepicker/model_test.go b/internal/ui/filepicker/model_test.go new file mode 100644 index 00000000..d2b82824 --- /dev/null +++ b/internal/ui/filepicker/model_test.go @@ -0,0 +1,118 @@ +package filepicker + +import ( + "os" + "path/filepath" + "testing" +) + +func TestOpenAtListsDirectory(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + m := New() + m.OpenAt(dir) + if !m.IsVisible() { + t.Fatal("expected picker visible") + } + if m.FilteredCount() == 0 { + t.Fatal("expected entries") + } +} + +func TestHandleKeyFiltersEntries(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "alpha.txt"), []byte("a"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "beta.txt"), []byte("b"), 0o644); err != nil { + t.Fatal(err) + } + + m := New() + m.OpenAt(dir) + m.HandleKey("a") + if m.Query() != "a" { + t.Fatalf("expected query a, got %q", m.Query()) + } + if m.FilteredCount() == 0 { + t.Fatal("expected filtered result") + } +} + +func TestEnterOnDirectoryNavigates(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "subdir") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "note.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + m := New() + m.OpenAt(root) + m.HandleKey("s") + m.HandleKey("u") + m.HandleKey("b") + if result := m.HandleKey("enter"); result != nil { + t.Fatal("expected directory enter to navigate, not return file") + } + if m.Cwd() != sub { + t.Fatalf("expected cwd %q, got %q", sub, m.Cwd()) + } +} + +func TestBackspaceWithoutQueryGoesParent(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "subdir") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + + m := New() + m.OpenAt(sub) + m.HandleKey("backspace") + if m.Cwd() != root { + t.Fatalf("expected parent cwd %q, got %q", root, m.Cwd()) + } +} + +func TestEnterOnFileReturnsResult(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "note.txt") + if err := os.WriteFile(file, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + m := New() + m.OpenAt(dir) + m.HandleKey("n") + m.HandleKey("o") + m.HandleKey("t") + m.HandleKey("e") + result := m.HandleKey("enter") + if result == nil { + t.Fatal("expected file result") + } + if result.Path != file { + t.Fatalf("expected path %q, got %q", file, result.Path) + } + if m.IsVisible() { + t.Fatal("expected picker closed after file selection") + } +} + +func TestEscClosesPicker(t *testing.T) { + m := New() + m.OpenAt(t.TempDir()) + m.HandleKey("esc") + if m.IsVisible() { + t.Fatal("expected picker hidden after esc") + } +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 8b1d3a3b..bf2424c3 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -39,6 +39,7 @@ type KeyMap struct { ThemeSwitcher key.Binding ThemeSwitcherGlobal key.Binding PresenceMenu key.Binding + AttachFile key.Binding ToggleSection key.Binding NavBack key.Binding NavForward key.Binding @@ -82,6 +83,7 @@ func DefaultKeyMap() KeyMap { ThemeSwitcher: key.NewBinding(key.WithKeys("ctrl+y"), key.WithHelp("ctrl+y", "switch theme (per workspace)")), ThemeSwitcherGlobal: key.NewBinding(key.WithKeys("ctrl+shift+y"), key.WithHelp("ctrl+shift+y", "set default theme")), PresenceMenu: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "set status")), + AttachFile: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "attach file")), ToggleSection: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle section")), NavBack: key.NewBinding(key.WithKeys("ctrl+h"), key.WithHelp("ctrl+h", "navigate back")), NavForward: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "navigate forward")), diff --git a/internal/ui/mode.go b/internal/ui/mode.go index 31d58729..00af60bc 100644 --- a/internal/ui/mode.go +++ b/internal/ui/mode.go @@ -14,6 +14,7 @@ const ( ModeThemeSwitcher ModePresenceMenu ModePresenceCustomSnooze + ModeFilePicker ModeConfirm ModeHelp ) @@ -40,6 +41,8 @@ func (m Mode) String() string { return "STATUS" case ModePresenceCustomSnooze: return "STATUS-INPUT" + case ModeFilePicker: + return "ATTACH" case ModeConfirm: return "CONFIRM" case ModeHelp: