Skip to content

Commit ec70b5c

Browse files
authored
feat: add search/filter to Projects tab in TUI (#39)
Implements issue #35 - press '/' in the Projects tab to enter search mode. Type to filter projects by name (case-insensitive substring match). Backspace removes last character, Ctrl+U clears the query, Enter confirms and exits search mode keeping the filter, Esc clears and restores the full list. - Add searchActive/searchQuery fields to Model struct - Disable list built-in filter; implement custom search handler - Show live search input in status bar with cursor indicator - Show context-specific help when search mode is active
1 parent 644ace8 commit ec70b5c

1 file changed

Lines changed: 84 additions & 7 deletions

File tree

internal/tui/model.go

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ type Model struct {
104104
workflowList []workflow.Workflow
105105
workflowRun *workflow.WorkflowRunResult
106106
workflowCursor int
107+
// Projects tab search
108+
searchActive bool
109+
searchQuery string
107110
}
108111

109112
// projectDeletedMsg is sent after deleting a project.
@@ -212,7 +215,7 @@ func NewModel(version string) Model {
212215
pl.SetShowTitle(false)
213216
pl.SetShowHelp(false)
214217
pl.SetShowStatusBar(false)
215-
pl.SetFilteringEnabled(true)
218+
pl.SetFilteringEnabled(false) // Custom search implemented below
216219

217220
// Load profiles
218221
profileItems, _ := loadProfiles()
@@ -335,15 +338,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
335338
return m.updatePartialRollback(msg)
336339
}
337340

341+
// Handle custom search mode for Projects tab
342+
if m.state == viewProjects && m.searchActive && msg.String() != "tab" {
343+
return m.updateProjectSearch(msg)
344+
}
345+
if m.state == viewProjects && m.searchActive { // tab pressed during search
346+
m.searchActive = false
347+
m.searchQuery = ""
348+
m.projectList.SetItems(loadProjects())
349+
}
350+
338351
// Right panel focused — handle session selection
339352
if m.focus == focusRight && m.state == viewProjects {
340353
return m.updateRightPanel(msg)
341354
}
342355

343356
// Don't intercept keys when the current list is filtering
344-
if m.state == viewProjects && m.projectList.FilterState() == list.Filtering {
345-
return m.updateList(msg)
346-
}
347357
if m.state == viewConfig {
348358
if m.configSubTab == configProfiles && m.profileList.FilterState() == list.Filtering {
349359
return m.updateList(msg)
@@ -446,6 +456,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
446456
}
447457
}
448458

459+
case msg.String() == "/" && m.state == viewProjects && m.focus == focusLeft:
460+
m.searchActive = true
461+
m.searchQuery = ""
462+
return m, nil
463+
449464
case msg.String() == "l" && m.state == viewProjects:
450465
// Open link management for selected project
451466
if item, ok := m.projectList.SelectedItem().(projectItem); ok {
@@ -1360,7 +1375,12 @@ func (m Model) View() string {
13601375
}
13611376

13621377
// Status/Error display
1363-
if m.err != "" {
1378+
if m.state == viewProjects && m.searchActive {
1379+
b.WriteString("\n")
1380+
searchStyle := lipgloss.NewStyle().Foreground(primaryColor).Bold(true)
1381+
cursor := lipgloss.NewStyle().Background(primaryColor).Foreground(lipgloss.Color("#FFFFFF")).Render(" ")
1382+
b.WriteString(" " + searchStyle.Render("/") + " " + m.searchQuery + cursor)
1383+
} else if m.err != "" {
13641384
b.WriteString("\n")
13651385
b.WriteString(statusErrorStyle.Render(" Error: " + m.err))
13661386
} else if m.statusMsg != "" {
@@ -1476,8 +1496,10 @@ func (m Model) renderAgentSubHeader(width int) string {
14761496
}
14771497

14781498
func (m Model) renderHelp() string {
1479-
if m.state == viewAddForm {
1480-
return formHintStyle.Render("Tab: switch fields Enter: add Esc: cancel")
1499+
if m.state == viewProjects && m.searchActive {
1500+
return formHintStyle.Render("type to filter Backspace: delete Enter: confirm Esc: clear")
1501+
}
1502+
if m.state == viewAddForm { return formHintStyle.Render("Tab: switch fields Enter: add Esc: cancel")
14811503
}
14821504
if m.state == viewAddProfile {
14831505
return formHintStyle.Render("Tab: switch fields Space: toggle Enter: add Esc: cancel")
@@ -1692,3 +1714,58 @@ func openBrowser(url string) error {
16921714
return fmt.Errorf("unsupported platform")
16931715
}
16941716
}
1717+
1718+
// updateProjectSearch handles key events when search mode is active on the Projects tab.
1719+
func (m Model) updateProjectSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
1720+
switch msg.String() {
1721+
case "esc":
1722+
m.searchActive = false
1723+
m.searchQuery = ""
1724+
m.projectList.SetItems(loadProjects())
1725+
case "enter":
1726+
// Confirm filter, exit search mode (filtered items remain)
1727+
m.searchActive = false
1728+
case "backspace", "ctrl+h":
1729+
if len(m.searchQuery) > 0 {
1730+
runes := []rune(m.searchQuery)
1731+
m.searchQuery = string(runes[:len(runes)-1])
1732+
m = m.applyProjectSearch()
1733+
}
1734+
case "ctrl+u":
1735+
// Clear entire query
1736+
m.searchQuery = ""
1737+
m.projectList.SetItems(loadProjects())
1738+
case "q", "ctrl+c":
1739+
return m, tea.Quit
1740+
default:
1741+
if len(msg.Runes) > 0 {
1742+
m.searchQuery += string(msg.Runes)
1743+
m = m.applyProjectSearch()
1744+
}
1745+
}
1746+
return m, nil
1747+
}
1748+
1749+
// applyProjectSearch filters the project list by the current searchQuery.
1750+
func (m Model) applyProjectSearch() Model {
1751+
if m.searchQuery == "" {
1752+
m.projectList.SetItems(loadProjects())
1753+
return m
1754+
}
1755+
query := strings.ToLower(m.searchQuery)
1756+
all := loadProjects()
1757+
var filtered []list.Item
1758+
for _, item := range all {
1759+
if proj, ok := item.(projectItem); ok {
1760+
if strings.Contains(strings.ToLower(proj.info.Name), query) {
1761+
filtered = append(filtered, item)
1762+
}
1763+
}
1764+
}
1765+
if len(filtered) == 0 {
1766+
m.projectList.SetItems([]list.Item{})
1767+
} else {
1768+
m.projectList.SetItems(filtered)
1769+
}
1770+
return m
1771+
}

0 commit comments

Comments
 (0)