diff --git a/internal/claude/manifest.go b/internal/claude/manifest.go index bbc1a05..867ed34 100644 --- a/internal/claude/manifest.go +++ b/internal/claude/manifest.go @@ -157,8 +157,7 @@ func listMarkdownFilesFS(fsys fs.FS, dir string) []string { var result []string for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") { - name := strings.TrimSuffix(entry.Name(), ".md") + if name, ok := strings.CutSuffix(entry.Name(), ".md"); !entry.IsDir() && ok { result = append(result, name) } } diff --git a/internal/tui/model.go b/internal/tui/model.go index 2676544..c8df266 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,7 +2,9 @@ package tui import ( - "sort" + "cmp" + "maps" + "slices" "strings" tea "github.com/charmbracelet/bubbletea" @@ -252,15 +254,6 @@ func firstScope(m map[claude.Scope]bool) claude.Scope { return claude.ScopeNone } -// copyMap creates a shallow copy of a scope map. -func copyMap(m map[claude.Scope]bool) map[claude.Scope]bool { - result := make(map[claude.Scope]bool, len(m)) - for k, v := range m { - result[k] = v - } - return result -} - // MainState holds state for the main two-pane view. type MainState struct { pendingOps map[string]Operation @@ -532,18 +525,14 @@ func processInstalledPlugin(p claude.InstalledPlugin, allScopes claude.ScopeStat // sortAndGroupByMarketplace sorts and groups plugins by marketplace with headers. func sortAndGroupByMarketplace(byMarketplace map[string][]PluginState) []PluginState { // Sort marketplace names - marketplaces := make([]string, 0, len(byMarketplace)) - for marketplace := range byMarketplace { - marketplaces = append(marketplaces, marketplace) - } - sort.Strings(marketplaces) + marketplaces := slices.Sorted(maps.Keys(byMarketplace)) // Build result with headers var result []PluginState for _, marketplace := range marketplaces { plugins := byMarketplace[marketplace] - sort.Slice(plugins, func(i, j int) bool { - return strings.ToLower(plugins[i].Name) < strings.ToLower(plugins[j].Name) + slices.SortFunc(plugins, func(a, b PluginState) int { + return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) result = append(result, PluginState{ Name: marketplace, @@ -613,7 +602,7 @@ func (m *Model) openScopeDialog(pluginID string, installedScopes map[claude.Scop m.mode = ModeScopeDialog m.main.scopeDialog = scopeDialogState{ pluginID: pluginID, - originalScopes: copyMap(installedScopes), + originalScopes: maps.Clone(installedScopes), } // Initialize checkboxes from current installed scopes (presence, not enabled value) _, m.main.scopeDialog.scopes[0] = installedScopes[claude.ScopeUser] diff --git a/internal/tui/update.go b/internal/tui/update.go index c00e4b2..89bd201 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,10 +1,12 @@ package tui import ( + "cmp" "fmt" "io/fs" + "maps" "os" - "sort" + "slices" "strings" tea "github.com/charmbracelet/bubbletea" @@ -225,7 +227,7 @@ func (m *Model) applyScopeDialogDelta() { m.main.pendingOps[dialog.pluginID] = Operation{ PluginID: dialog.pluginID, Scopes: uninstallScopes, - OriginalScopes: copyMap(original), + OriginalScopes: maps.Clone(original), Type: OpUninstall, } case len(installScopes) > 0 && len(uninstallScopes) == 0: @@ -233,7 +235,7 @@ func (m *Model) applyScopeDialogDelta() { m.main.pendingOps[dialog.pluginID] = Operation{ PluginID: dialog.pluginID, Scopes: installScopes, - OriginalScopes: copyMap(original), + OriginalScopes: maps.Clone(original), Type: OpInstall, } default: @@ -244,7 +246,7 @@ func (m *Model) applyScopeDialogDelta() { PluginID: dialog.pluginID, Scopes: installScopes, UninstallScopes: uninstallScopes, - OriginalScopes: copyMap(original), + OriginalScopes: maps.Clone(original), Type: OpScopeChange, } } @@ -297,7 +299,7 @@ func (m *Model) moveDown() { // pageUp moves up by a page. func (m *Model) pageUp() { pageSize := m.getPageSize() - for i := 0; i < pageSize; i++ { + for range pageSize { m.moveUp() } } @@ -305,7 +307,7 @@ func (m *Model) pageUp() { // pageDown moves down by a page. func (m *Model) pageDown() { pageSize := m.getPageSize() - for i := 0; i < pageSize; i++ { + for range pageSize { m.moveDown() } } @@ -358,10 +360,7 @@ func (m *Model) ensureVisible() { if m.listOffset < 0 { m.listOffset = 0 } - maxOffset := len(m.plugins) - pageSize - if maxOffset < 0 { - maxOffset = 0 - } + maxOffset := max(len(m.plugins)-pageSize, 0) if m.listOffset > maxOffset { m.listOffset = maxOffset } @@ -400,7 +399,7 @@ func (m *Model) selectForInstall(scope claude.Scope) { m.main.pendingOps[plugin.ID] = Operation{ PluginID: plugin.ID, Scopes: []claude.Scope{scope}, - OriginalScopes: copyMap(plugin.InstalledScopes), + OriginalScopes: maps.Clone(plugin.InstalledScopes), Type: OpMigrate, } continue @@ -455,7 +454,7 @@ func (m *Model) computeNextToggleOp(plugin *PluginState) *Operation { return &Operation{ PluginID: plugin.ID, Scopes: []claude.Scope{}, - OriginalScopes: copyMap(plugin.InstalledScopes), + OriginalScopes: maps.Clone(plugin.InstalledScopes), Type: OpUninstall, } } @@ -473,7 +472,7 @@ func (m *Model) firstToggleOp(plugin *PluginState, scope claude.Scope) *Operatio return &Operation{ PluginID: plugin.ID, Scopes: []claude.Scope{scope}, - OriginalScopes: copyMap(plugin.InstalledScopes), + OriginalScopes: maps.Clone(plugin.InstalledScopes), Type: OpMigrate, } } @@ -514,7 +513,7 @@ func (m *Model) selectForUninstall() { m.main.pendingOps[plugin.ID] = Operation{ PluginID: plugin.ID, Scopes: []claude.Scope{plugin.SingleScope()}, - OriginalScopes: copyMap(plugin.InstalledScopes), + OriginalScopes: maps.Clone(plugin.InstalledScopes), Type: OpUninstall, } } @@ -587,7 +586,7 @@ func (m *Model) startExecution() (tea.Model, tea.Cmd) { } // Sort operations: uninstalls first, then migrations, then scope changes, then updates, then installs, then enable/disable - sort.Slice(m.progress.operations, func(i, j int) bool { + slices.SortFunc(m.progress.operations, func(a, b Operation) int { typeOrder := map[OperationType]int{ OpUninstall: 0, OpMigrate: 1, @@ -597,14 +596,7 @@ func (m *Model) startExecution() (tea.Model, tea.Cmd) { OpEnable: 5, OpDisable: 6, } - orderI := typeOrder[m.progress.operations[i].Type] - orderJ := typeOrder[m.progress.operations[j].Type] - - // If same order, maintain stable sort (don't swap) - if orderI == orderJ { - return false - } - return orderI < orderJ + return cmp.Compare(typeOrder[a.Type], typeOrder[b.Type]) }) m.progress.currentIdx = 0 @@ -943,11 +935,11 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { case tea.MouseButtonLeft: m.handleMouseClick(msg) case tea.MouseButtonWheelUp: - for i := 0; i < wheelScrollSpeed; i++ { + for range wheelScrollSpeed { m.moveUp() } case tea.MouseButtonWheelDown: - for i := 0; i < wheelScrollSpeed; i++ { + for range wheelScrollSpeed { m.moveDown() } } @@ -1031,12 +1023,12 @@ func (m *Model) extractNonHeaderPlugins() []PluginState { func applySortMode(plugins []PluginState, sortMode SortMode) { switch sortMode { case SortByNameAsc: - sort.Slice(plugins, func(i, j int) bool { - return strings.ToLower(plugins[i].Name) < strings.ToLower(plugins[j].Name) + slices.SortFunc(plugins, func(a, b PluginState) int { + return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) case SortByNameDesc: - sort.Slice(plugins, func(i, j int) bool { - return strings.ToLower(plugins[i].Name) > strings.ToLower(plugins[j].Name) + slices.SortFunc(plugins, func(a, b PluginState) int { + return cmp.Compare(strings.ToLower(b.Name), strings.ToLower(a.Name)) }) case SortByScope: sortByScope(plugins) @@ -1053,23 +1045,21 @@ func sortByScope(plugins []PluginState) { claude.ScopeUser: 2, claude.ScopeNone: 3, } - sort.Slice(plugins, func(i, j int) bool { - orderI := scopeOrder[plugins[i].SingleScope()] - orderJ := scopeOrder[plugins[j].SingleScope()] - if orderI != orderJ { - return orderI < orderJ + slices.SortFunc(plugins, func(a, b PluginState) int { + if c := cmp.Compare(scopeOrder[a.SingleScope()], scopeOrder[b.SingleScope()]); c != 0 { + return c } - return strings.ToLower(plugins[i].Name) < strings.ToLower(plugins[j].Name) + return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) } // sortByMarketplace sorts plugins by marketplace name. func sortByMarketplace(plugins []PluginState) { - sort.Slice(plugins, func(i, j int) bool { - if plugins[i].Marketplace != plugins[j].Marketplace { - return plugins[i].Marketplace < plugins[j].Marketplace + slices.SortFunc(plugins, func(a, b PluginState) int { + if c := cmp.Compare(a.Marketplace, b.Marketplace); c != 0 { + return c } - return strings.ToLower(plugins[i].Name) < strings.ToLower(plugins[j].Name) + return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) } @@ -1157,7 +1147,7 @@ func rebuildWithGroupHeaders(plugins []PluginState, sortMode SortMode) []PluginS } byGroup[group] = append(byGroup[group], p) } - sort.Strings(groups) // Sort groups alphabetically + slices.Sort(groups) // Sort groups alphabetically for _, group := range groups { result = append(result, PluginState{ Name: group, @@ -1167,12 +1157,12 @@ func rebuildWithGroupHeaders(plugins []PluginState, sortMode SortMode) []PluginS // Sort within group by current sort mode groupPlugins := byGroup[group] if sortMode == SortByNameDesc { - sort.Slice(groupPlugins, func(i, j int) bool { - return strings.ToLower(groupPlugins[i].Name) > strings.ToLower(groupPlugins[j].Name) + slices.SortFunc(groupPlugins, func(a, b PluginState) int { + return cmp.Compare(strings.ToLower(b.Name), strings.ToLower(a.Name)) }) } else { - sort.Slice(groupPlugins, func(i, j int) bool { - return strings.ToLower(groupPlugins[i].Name) < strings.ToLower(groupPlugins[j].Name) + slices.SortFunc(groupPlugins, func(a, b PluginState) int { + return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) } result = append(result, groupPlugins...) diff --git a/internal/tui/view.go b/internal/tui/view.go index 7be98dc..910660f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1,8 +1,10 @@ package tui import ( + "cmp" "fmt" - "sort" + "maps" + "slices" "strconv" "strings" "time" @@ -73,10 +75,7 @@ func (m *Model) renderList(styles Styles) string { // Calculate visible range start := m.listOffset - end := start + visibleHeight - if end > len(plugins) { - end = len(plugins) - } + end := min(start+visibleHeight, len(plugins)) for i := start; i < end; i++ { plugin := plugins[i] @@ -189,7 +188,7 @@ func (m *Model) getScopeIndicator(plugin PluginState, styles Styles) string { // applyScopeDelta returns a new scope set with uninstalls removed and installs added. func applyScopeDelta(base map[claude.Scope]bool, uninstall, install []claude.Scope) map[claude.Scope]bool { - result := copyMap(base) + result := maps.Clone(base) for _, s := range uninstall { delete(result, s) } @@ -478,19 +477,13 @@ func (m *Model) renderDoc(styles Styles) string { visibleHeight := m.height - 4 // Account for header and help bar // Clamp scroll position - maxScroll := len(lines) - visibleHeight - if maxScroll < 0 { - maxScroll = 0 - } + maxScroll := max(len(lines)-visibleHeight, 0) if m.doc.scroll > maxScroll { m.doc.scroll = maxScroll } // Get visible lines - endIdx := m.doc.scroll + visibleHeight - if endIdx > len(lines) { - endIdx = len(lines) - } + endIdx := min(m.doc.scroll+visibleHeight, len(lines)) visibleLines := lines[m.doc.scroll:endIdx] content := strings.Join(visibleLines, "\n") @@ -527,7 +520,7 @@ func (m *Model) renderConfirmation(styles Styles) string { // Sort operations by type for consistent display // Uninstalls, then migrations, then updates, then installs, then enables, then disables - sort.Slice(operations, func(i, j int) bool { + slices.SortFunc(operations, func(a, b Operation) int { typeOrder := map[OperationType]int{ OpUninstall: 0, OpMigrate: 1, @@ -536,7 +529,7 @@ func (m *Model) renderConfirmation(styles Styles) string { OpEnable: 4, OpDisable: 5, } - return typeOrder[operations[i].Type] < typeOrder[operations[j].Type] + return cmp.Compare(typeOrder[a.Type], typeOrder[b.Type]) }) for _, op := range operations { @@ -795,7 +788,7 @@ func (m *Model) renderScopeDialog(styles Styles) string { lines = append(lines, styles.Header.Render(" Scopes for "+dialog.pluginID+" ")) lines = append(lines, "") - for i := 0; i < 3; i++ { + for i := range 3 { checkbox := "[ ]" if dialog.scopes[i] { checkbox = "[x]" @@ -841,24 +834,12 @@ func (m *Model) renderConfig(styles Styles) string { header := styles.Header.Render(" " + m.config.title + " ") // Content area - contentHeight := m.height - 4 // Account for header and help bar - if contentHeight < 1 { - contentHeight = 1 - } + contentHeight := max(m.height-4, 1) // Account for header and help bar // Split content into lines and apply scroll lines := strings.Split(m.config.content, "\n") - startLine := m.config.scroll - if startLine >= len(lines) { - startLine = len(lines) - 1 - if startLine < 0 { - startLine = 0 - } - } - endLine := startLine + contentHeight - if endLine > len(lines) { - endLine = len(lines) - } + startLine := min(m.config.scroll, max(len(lines)-1, 0)) + endLine := min(startLine+contentHeight, len(lines)) visibleLines := lines[startLine:endLine] content := strings.Join(visibleLines, "\n")