diff --git a/scanner/excluded.go b/scanner/excluded.go index 6a0b786..8796e91 100644 --- a/scanner/excluded.go +++ b/scanner/excluded.go @@ -53,9 +53,9 @@ func (e Excluder) FilterPorcelainStatus(st PorcelainStatus) PorcelainStatus { return filtered } -func NewExcluder(files, dirs []string) (Excluder, error) { +func NewExcluder(files, dirs []string) Excluder { return Excluder{ files: files, dirs: dirs, - }, nil + } } diff --git a/scanner/find.go b/scanner/find.go index ec25271..fd10cec 100644 --- a/scanner/find.go +++ b/scanner/find.go @@ -13,10 +13,6 @@ import ( "golang.org/x/sync/errgroup" ) -func skip(needle string, haystack []string) bool { - return slices.Contains(haystack, needle) -} - func isGitMetadataDir(path string, d fs.DirEntry) (bool, error) { if d.Name() != ".git" { return false, nil @@ -60,7 +56,11 @@ func walkone(ctx context.Context, dir string, config *Config, results chan strin default: } - if skip(path, config.ScanDirs.Exclude) { + if d.IsDir() { + log.Printf("path %s", path) + } + + if slices.Contains(config.ScanDirs.Exclude, path) { return filepath.SkipDir } diff --git a/scanner/scan.go b/scanner/scan.go index da8a68e..dd48b06 100644 --- a/scanner/scan.go +++ b/scanner/scan.go @@ -23,11 +23,6 @@ func Scan(config *Config) (*MultiGitStatus, error) { // ScanWithProgress runs the same scan as [Scan] and invokes onProgress from concurrent // discovery and the status loop. Callbacks should be non-blocking (e.g. small channel send). func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitStatus, error) { - ex, e := NewExcluder(config.GitIgnore.FileGlob, config.GitIgnore.DirGlob) - if e != nil { - return nil, e - } - ctx := context.Background() repositories := make(chan string, 1000) @@ -70,18 +65,10 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS CurrentPath: d, }) - start := time.Now() - - porcelain, err := GitStatus(d) + rs, include, err := StatusForRepo(config, d) if err != nil { return err } - porcelain = ex.FilterPorcelainStatus(porcelain) - st := porcelain.ToGitStatus() - - duration := time.Since(start) - log.Println(d, duration) - n := checked.Add(1) // Git status finished for this repo; advance ReposChecked and retain // CurrentPath until the next channel receive so the path line does not @@ -91,20 +78,11 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS ReposChecked: int(n), CurrentPath: d, }) + log.Println(d, rs.ScanTime) - branches, err := GitBranchStatus(d) - if err != nil { - log.Printf("branch status scan failed for %s: %v", d, err) - } - branches.FilterLocalOnlyForConfig(config) - if !st.IsClean() || branches.HasLocalRemoteMismatch() { - atomic.AddInt64(&totalStatusDuration, duration.Nanoseconds()) - results.AddResult(d, RepoStatus{ - Status: st, - Porcelain: porcelain, - Branches: branches, - ScanTime: duration, - }) + if include { + atomic.AddInt64(&totalStatusDuration, rs.ScanTime.Nanoseconds()) + results.AddResult(d, rs) } return nil }) @@ -123,11 +101,11 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS // StatusForRepo returns fresh status for a single repository directory using the // same porcelain filtering and branch metadata as [ScanWithProgress]. The bool // is whether this repo should appear in the dirty list (!clean or remote mismatch). +// If ex is nil, a new excluder is built from config (for single-repo refresh paths). +// afterPorcelain, if non-nil, is invoked after porcelain is resolved and before +// branch metadata is collected (used by [ScanWithProgress] for progress timing). func StatusForRepo(config *Config, dir string) (RepoStatus, bool, error) { - ex, err := NewExcluder(config.GitIgnore.FileGlob, config.GitIgnore.DirGlob) - if err != nil { - return RepoStatus{}, false, err - } + ex := NewExcluder(config.GitIgnore.FileGlob, config.GitIgnore.DirGlob) start := time.Now() porcelain, err := GitStatus(dir) if err != nil { @@ -136,9 +114,9 @@ func StatusForRepo(config *Config, dir string) (RepoStatus, bool, error) { porcelain = ex.FilterPorcelainStatus(porcelain) st := porcelain.ToGitStatus() - branches, berr := GitBranchStatus(dir) - if berr != nil { - log.Printf("branch status scan failed for %s: %v", dir, berr) + branches, err := GitBranchStatus(dir) + if err != nil { + log.Printf("branch status scan failed for %s: %v", dir, err) } branches.FilterLocalOnlyForConfig(config) diff --git a/ui/app.go b/ui/app.go index 299385d..97d1b8a 100644 --- a/ui/app.go +++ b/ui/app.go @@ -31,7 +31,7 @@ func Run(config *scanner.Config) error { m := &model{ config: config, - logBuf: &logBuffer{max: 500}, + logBuf: &logBuffer{max: 10000}, repositories: scanner.NewMultiGitStatus(), focus: paneRepo, scanResultCh: make(chan scanResult, 1), diff --git a/ui/update.go b/ui/update.go index 6f43ac2..f69fed9 100644 --- a/ui/update.go +++ b/ui/update.go @@ -438,6 +438,97 @@ func (m *model) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { // listKeyScrollPage is the step size for Shift+↑/↓ in scrollable lists and viewports. const listKeyScrollPage = 10 +// viewportVerticalKey scrolls a viewport by a page (shift arrows) or delegates to Update for single-line motion. +func viewportVerticalKey(vp viewport.Model, msg tea.KeyMsg, step int, up bool) (viewport.Model, tea.Cmd) { + if step == listKeyScrollPage { + if up { + vp.ScrollUp(listKeyScrollPage) + } else { + vp.ScrollDown(listKeyScrollPage) + } + return vp, nil + } + return vp.Update(msg) +} + +func (m *model) repoListVerticalArrow(step int, up, down bool) (tea.Model, tea.Cmd, bool) { + prev := m.cursor + if up && m.cursor > 0 { + m.cursor = max(0, m.cursor-step) + } else if down && len(m.repoList) > 0 && m.cursor < len(m.repoList)-1 { + m.cursor = min(len(m.repoList)-1, m.cursor+step) + } + if m.cursor != prev { + m.statusFileSelected = false + m.diffNeedsRefresh = true + m.syncRepoListScrollOnly() + m.repoNavSettleGen++ + g := m.repoNavSettleGen + return m, tea.Tick(repoNavSettleDebounce, func(t time.Time) tea.Msg { + return repoNavSettledMsg{gen: g} + }), true + } + return m, nil, true +} + +func (m *model) statusVerticalArrow(msg tea.KeyMsg, step int, up bool) (tea.Model, tea.Cmd, bool) { + if !m.statusFileSelected && len(m.statusPaths) > 0 { + m.statusFileSelected = true + m.statusTable.Focus() + } + var cmd tea.Cmd + if step == listKeyScrollPage { + if up { + m.statusTable.MoveUp(listKeyScrollPage) + } else { + m.statusTable.MoveDown(listKeyScrollPage) + } + } else { + m.statusTable, cmd = m.statusTable.Update(msg) + } + if len(m.statusPaths) > 0 { + m.statusFileSelected = true + m.diffNeedsRefresh = true + m.syncViewports() + } + return m, cmd, true +} + +func (m *model) focusStatusDiff(toDiff bool) { + if toDiff { + m.focus = paneDiff + if m.zoomed { + m.zoomTarget = paneDiff + } + } else { + m.focus = paneStatus + if m.zoomed { + m.zoomTarget = paneStatus + } + } + m.syncViewports() +} + +func (m *model) handleVerticalArrowKey(msg tea.KeyMsg, step int, up, down bool) (tea.Model, tea.Cmd, bool) { + switch m.focus { + case paneRepo: + return m.repoListVerticalArrow(step, up, down) + case paneStatus: + return m.statusVerticalArrow(msg, step, up) + case paneDiff: + var cmd tea.Cmd + m.diffVP, cmd = viewportVerticalKey(m.diffVP, msg, step, up) + return m, cmd, true + case paneLog: + m.setLogVPContent() + var cmd tea.Cmd + m.logVP, cmd = viewportVerticalKey(m.logVP, msg, step, up) + return m, cmd, true + default: + return m, nil, false + } +} + // handleArrowKey applies directional keys: scrolling / repo navigation, and // ←/→ to move focus between Status and Diff. func (m *model) handleArrowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { @@ -449,91 +540,15 @@ func (m *model) handleArrowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { } up := msg.Type == tea.KeyUp || msg.Type == tea.KeyShiftUp down := msg.Type == tea.KeyDown || msg.Type == tea.KeyShiftDown - - if m.focus == paneRepo { - prev := m.cursor - if up && m.cursor > 0 { - m.cursor = max(0, m.cursor-step) - } else if down && len(m.repoList) > 0 && m.cursor < len(m.repoList)-1 { - m.cursor = min(len(m.repoList)-1, m.cursor+step) - } - if m.cursor != prev { - m.statusFileSelected = false - m.diffNeedsRefresh = true - m.syncRepoListScrollOnly() - m.repoNavSettleGen++ - g := m.repoNavSettleGen - return m, tea.Tick(repoNavSettleDebounce, func(t time.Time) tea.Msg { - return repoNavSettledMsg{gen: g} - }), true - } - return m, nil, true - } - if m.focus == paneStatus { - if !m.statusFileSelected && len(m.statusPaths) > 0 { - m.statusFileSelected = true - m.statusTable.Focus() - } - var cmd tea.Cmd - if step == listKeyScrollPage { - if up { - m.statusTable.MoveUp(listKeyScrollPage) - } else { - m.statusTable.MoveDown(listKeyScrollPage) - } - } else { - m.statusTable, cmd = m.statusTable.Update(msg) - } - if len(m.statusPaths) > 0 { - m.statusFileSelected = true - m.diffNeedsRefresh = true - m.syncViewports() - } - return m, cmd, true - } - if m.focus == paneDiff { - if step == listKeyScrollPage { - if up { - m.diffVP.ScrollUp(listKeyScrollPage) - } else { - m.diffVP.ScrollDown(listKeyScrollPage) - } - return m, nil, true - } - var cmd tea.Cmd - m.diffVP, cmd = m.diffVP.Update(msg) - return m, cmd, true - } - if m.focus == paneLog { - m.setLogVPContent() - if step == listKeyScrollPage { - if up { - m.logVP.ScrollUp(listKeyScrollPage) - } else { - m.logVP.ScrollDown(listKeyScrollPage) - } - return m, nil, true - } - var cmd tea.Cmd - m.logVP, cmd = m.logVP.Update(msg) - return m, cmd, true - } + return m.handleVerticalArrowKey(msg, step, up, down) case tea.KeyRight: if m.focus == paneStatus { - m.focus = paneDiff - if m.zoomed { - m.zoomTarget = paneDiff - } - m.syncViewports() + m.focusStatusDiff(true) return m, nil, true } case tea.KeyLeft: if m.focus == paneDiff { - m.focus = paneStatus - if m.zoomed { - m.zoomTarget = paneStatus - } - m.syncViewports() + m.focusStatusDiff(false) return m, nil, true } }