diff --git a/pkg/tui/dialog/file_picker.go b/pkg/tui/dialog/file_picker.go index 0cc415681..90b4e6f8c 100644 --- a/pkg/tui/dialog/file_picker.go +++ b/pkg/tui/dialog/file_picker.go @@ -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 @@ -89,6 +119,7 @@ func NewFilePickerDialog(initialPath string) Dialog { d := &filePickerDialog{ pickerCore: newPickerCore(filePickerLayout, "Type to filter files…"), + fpKeyMap: defaultFilePickerKeyMap(), currentDir: startDir, } @@ -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: diff --git a/pkg/tui/dialog/file_picker_test.go b/pkg/tui/dialog/file_picker_test.go index 85ac14169..0380023d9 100644 --- a/pkg/tui/dialog/file_picker_test.go +++ b/pkg/tui/dialog/file_picker_test.go @@ -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() @@ -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()