Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions pkg/tui/dialog/file_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,39 @@ var filePickerLayout = pickerLayout{
ListStartOffset: filePickerListStartY,
}

// filePickerKeyMap holds the file-picker-specific visibility toggles, on top
// of the navigation bindings provided by the shared pickerKeyMap.
//
// On macOS the OS substitutes a Unicode character for Option+key at the
// input-system level before the terminal sees the event, so the alt+h / alt+i
// ESC sequences never reach us. Each toggle therefore also accepts the
// characters that the standard US and US-International layouts emit (Option+H
// -> "˙" U+02D9, Option+I -> "ˆ" U+02C6). Option+I is a dead key on macOS, so
// the first press may arrive as an escaped or combining circumflex. See issue
// #2611. The visible help labels are rendered separately (and dynamically) by
// filePickerHelpKeysRows.
type filePickerKeyMap struct {
ToggleHidden key.Binding
ToggleIgnored key.Binding
}

func defaultFilePickerKeyMap() filePickerKeyMap {
return filePickerKeyMap{
ToggleHidden: key.NewBinding(
key.WithKeys("alt+h", "˙", "alt+˙"),
key.WithHelp("alt+h", "toggle hidden"),
),
ToggleIgnored: key.NewBinding(
key.WithKeys("alt+i", "ˆ", "alt+ˆ", "\u0302", "alt+\u0302"),
key.WithHelp("alt+i", "toggle ignored"),
),
}
}

type filePickerDialog struct {
pickerCore

fpKeyMap filePickerKeyMap
currentDir string
entries []fileEntry
filtered []fileEntry
Expand Down Expand Up @@ -89,6 +119,7 @@ func NewFilePickerDialog(initialPath string) Dialog {

d := &filePickerDialog{
pickerCore: newPickerCore(filePickerLayout, "Type to filter files…"),
fpKeyMap: defaultFilePickerKeyMap(),
currentDir: startDir,
}

Expand Down Expand Up @@ -209,10 +240,10 @@ func (d *filePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
case key.Matches(msg, d.keyMap.Enter):
cmd := d.activateSelected()
return d, cmd
case msg.String() == "alt+h":
case key.Matches(msg, d.fpKeyMap.ToggleHidden):
d.toggleHidden()
return d, nil
case msg.String() == "alt+i":
case key.Matches(msg, d.fpKeyMap.ToggleIgnored):
d.toggleIgnored()
return d, nil
default:
Expand Down
67 changes: 67 additions & 0 deletions pkg/tui/dialog/file_picker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
func newTestFilePickerDialog(dir string) *filePickerDialog {
d := &filePickerDialog{
pickerCore: newPickerCore(filePickerLayout, "Type to filter files…"),
fpKeyMap: defaultFilePickerKeyMap(),
currentDir: dir,
}
d.loadDirectory()
Expand Down Expand Up @@ -131,6 +132,72 @@ func TestFilePickerToggleIgnoredViaAltI(t *testing.T) {
require.False(t, d.showIgnored)
}

// On macOS the OS substitutes a Unicode character for Option+key before the
// terminal sees it, so the file picker also accepts those characters as
// aliases for the alt+h / alt+i toggles. See issue #2611.
func TestFilePickerToggleHiddenViaMacOSOptionChar(t *testing.T) {
t.Parallel()
dir := setupTestDir(t)

d := newTestFilePickerDialog(dir)
d.SetSize(100, 50)

require.False(t, d.showHidden)
names := entryNames(d.filtered)
require.NotContains(t, names, ".hidden_file")

// macOS emits "˙" (U+02D9) for Option+H.
optionH := tea.KeyPressMsg{Code: '˙', Text: "˙"}
updated, _ := d.Update(optionH)
d = updated.(*filePickerDialog)

require.True(t, d.showHidden)
names = entryNames(d.filtered)
assert.Contains(t, names, ".hidden_dir/")
assert.Contains(t, names, ".hidden_file")

// Pressing it again toggles hidden files back off.
updated, _ = d.Update(optionH)
d = updated.(*filePickerDialog)

require.False(t, d.showHidden)
names = entryNames(d.filtered)
assert.NotContains(t, names, ".hidden_file")
}

func TestFilePickerToggleIgnoredViaMacOSOptionChar(t *testing.T) {
t.Parallel()

cases := []struct {
name string
msg tea.KeyPressMsg
}{
{name: "standalone circumflex", msg: tea.KeyPressMsg{Code: 'ˆ', Text: "ˆ"}},
{name: "escaped circumflex dead key", msg: tea.KeyPressMsg{Code: 'ˆ', Mod: tea.ModAlt}},
{name: "combining circumflex dead key", msg: tea.KeyPressMsg{Code: '\u0302', Text: "\u0302"}},
{name: "escaped combining circumflex dead key", msg: tea.KeyPressMsg{Code: '\u0302', Mod: tea.ModAlt}},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := setupTestDir(t)

d := newTestFilePickerDialog(dir)
d.SetSize(100, 50)

require.False(t, d.showIgnored)

updated, _ := d.Update(tc.msg)
d = updated.(*filePickerDialog)
require.True(t, d.showIgnored)

updated, _ = d.Update(tc.msg)
d = updated.(*filePickerDialog)
require.False(t, d.showIgnored)
})
}
}

func TestFilePickerShowIgnoredInGitRepo(t *testing.T) {
t.Parallel()

Expand Down
Loading