package main import "database/sql" import "flag" import "fmt" import "os" import "path/filepath" import "sort" import "strconv" import "strings" import _ "modernc.org/sqlite" import "github.com/gdamore/tcell/v2" import "github.com/rivo/tview" type projectInfo struct { ID int64 PublicID string Slug string Name string } type repoInfo struct { ID int64 PublicID string ProjectID int64 Name string RepoType string LegacyPath string } type entryInfo struct { Name string IsDir bool Hint string } type browser struct { DB *sql.DB DataDir string Cwd string ProjectsByID map[int64]projectInfo ReposByID map[int64]repoInfo Entries []entryInfo App *tview.Application Root tview.Primitive Header *tview.TextView Table *tview.Table Status *tview.TextView } func main() { var dbPath string var dataDir string var err error var br *browser flag.StringVar(&dbPath, "db", "./codit-data/codit.db", "sqlite database path") flag.StringVar(&dataDir, "data", "./codit-data", "codit data directory") flag.Parse() br, err = newBrowser(dbPath, dataDir) if err != nil { fmt.Fprintf(os.Stderr, "init error: %v\n", err) os.Exit(1) } err = br.run() if err != nil { fmt.Fprintf(os.Stderr, "run error: %v\n", err) os.Exit(1) } } func newBrowser(dbPath string, dataDir string) (*browser, error) { var err error var db *sql.DB var br *browser db, err = sql.Open("sqlite", "file:"+dbPath+"?_pragma=foreign_keys(1)") if err != nil { return nil, err } br = &browser{ DB: db, DataDir: filepath.Clean(dataDir), Cwd: filepath.Clean(dataDir), ProjectsByID: map[int64]projectInfo{}, ReposByID: map[int64]repoInfo{}, App: tview.NewApplication(), Header: tview.NewTextView(), Table: tview.NewTable(), Status: tview.NewTextView(), } err = br.loadMaps() if err != nil { _ = db.Close() return nil, err } err = br.refreshEntries() if err != nil { _ = db.Close() return nil, err } br.setupUI() br.renderAll("Ready. q quit, Enter/Right open, Left/Backspace up, PgUp/PgDn/Home/End, r reload, i info, c check") return br, nil } func (b *browser) run() error { var err error b.Root = b.layout() err = b.App.SetRoot(b.Root, true).EnableMouse(false).Run() _ = b.DB.Close() return err } func (b *browser) layout() tview.Primitive { var root *tview.Flex root = tview.NewFlex().SetDirection(tview.FlexRow) root.AddItem(b.Header, 1, 0, false) root.AddItem(b.Table, 0, 1, true) root.AddItem(b.Status, 1, 0, false) return root } func (b *browser) setupUI() { var header string header = "Type Name Hint" b.Header.SetDynamicColors(true) b.Header.SetText(header) b.Table.SetSelectable(true, false) b.Table.SetFixed(0, 0) b.Table.SetBorders(false) b.Table.SetSeparator(' ') b.Table.SetInputCapture(b.captureKey) b.Table.SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorLightCyan)) } func (b *browser) renderAll(status string) { var i int var e entryInfo var kind string var name string var rel string var err error var row int b.Table.Clear() for i = 0; i < len(b.Entries); i++ { e = b.Entries[i] if e.IsDir { kind = "DIR " } else { kind = "FILE" } name = e.Name row = i b.Table.SetCell(row, 0, tview.NewTableCell(kind).SetSelectable(false)) b.Table.SetCell(row, 1, tview.NewTableCell(name)) b.Table.SetCell(row, 2, tview.NewTableCell(e.Hint).SetSelectable(false)) } if len(b.Entries) > 0 { b.Table.Select(0, 1) } rel, err = filepath.Rel(b.DataDir, b.Cwd) if err != nil { rel = b.Cwd } if rel == "." { rel = "/" } else { rel = "/" + filepath.ToSlash(rel) } b.Header.SetText("Path: " + rel + " (entries: " + strconv.Itoa(len(b.Entries)) + ")") b.Status.SetText(status) } func (b *browser) captureKey(event *tcell.EventKey) *tcell.EventKey { var key tcell.Key var r rune var row int var _, _, _, h = b.Table.GetInnerRect() var page int var err error var info string var mismatches int key = event.Key() r = event.Rune() if key == tcell.KeyEnter || key == tcell.KeyRight { err = b.openSelected() if err != nil { b.Status.SetText("open failed: " + err.Error()) } return nil } if key == tcell.KeyLeft || key == tcell.KeyBackspace || key == tcell.KeyBackspace2 { err = b.up() if err != nil { b.Status.SetText("up failed: " + err.Error()) } return nil } if key == tcell.KeyHome { if len(b.Entries) > 0 { b.Table.Select(0, 1) } return nil } if key == tcell.KeyEnd { if len(b.Entries) > 0 { b.Table.Select(len(b.Entries)-1, 1) } return nil } if key == tcell.KeyPgUp || key == tcell.KeyPgDn { row, _ = b.Table.GetSelection() page = h if page < 1 { page = 10 } if key == tcell.KeyPgUp { row = row - page if row < 0 { row = 0 } } else { row = row + page if row >= len(b.Entries) { row = len(b.Entries) - 1 } if row < 0 { row = 0 } } if len(b.Entries) > 0 { b.Table.Select(row, 1) } return nil } if r == 'q' || r == 'Q' { b.App.Stop() return nil } if r == 'r' || r == 'R' { b.ProjectsByID = map[int64]projectInfo{} b.ReposByID = map[int64]repoInfo{} err = b.loadMaps() if err != nil { b.Status.SetText("reload failed: " + err.Error()) return nil } err = b.refreshEntries() if err != nil { b.Status.SetText("reload list failed: " + err.Error()) return nil } b.renderAll("Reloaded") return nil } if r == 'i' || r == 'I' { info = b.selectedInfo() b.Status.SetText(info) return nil } if r == 'c' || r == 'C' { mismatches = b.showMismatches() if mismatches == 0 { b.Status.SetText("check complete: no missing expected repo directories") } return nil } return event } func (b *browser) openSelected() error { var row int var name string var err error row, _ = b.Table.GetSelection() if row < 0 || row >= len(b.Entries) { return nil } if !b.Entries[row].IsDir { return nil } name = b.Entries[row].Name b.Cwd = filepath.Clean(filepath.Join(b.Cwd, name)) if !pathInside(b.Cwd, b.DataDir) { return fmt.Errorf("outside data dir") } err = b.refreshEntries() if err != nil { return err } b.renderAll("Opened " + b.Cwd) return nil } func (b *browser) up() error { var parent string var err error parent = filepath.Dir(b.Cwd) if !pathInside(parent, b.DataDir) { return nil } b.Cwd = parent err = b.refreshEntries() if err != nil { return err } b.renderAll("Opened " + b.Cwd) return nil } func (b *browser) selectedInfo() string { var row int var e entryInfo row, _ = b.Table.GetSelection() if row < 0 || row >= len(b.Entries) { return "no selection" } e = b.Entries[row] if e.Hint == "" { return e.Name } return e.Name + " | " + e.Hint } func (b *browser) refreshEntries() error { var raw []os.DirEntry var i int var item os.DirEntry var entries []entryInfo var info entryInfo var dirs []entryInfo var files []entryInfo var err error raw, err = os.ReadDir(b.Cwd) if err != nil { return err } entries = make([]entryInfo, 0, len(raw)) for i = 0; i < len(raw); i++ { item = raw[i] info = entryInfo{ Name: item.Name(), IsDir: item.IsDir(), Hint: b.resolvePathHint(item.Name()), } entries = append(entries, info) } dirs = make([]entryInfo, 0, len(entries)) files = make([]entryInfo, 0, len(entries)) for i = 0; i < len(entries); i++ { if entries[i].IsDir { dirs = append(dirs, entries[i]) } else { files = append(files, entries[i]) } } sort.Slice(dirs, func(i int, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) }) sort.Slice(files, func(i int, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) }) b.Entries = make([]entryInfo, 0, len(entries)) b.Entries = append(b.Entries, dirs...) b.Entries = append(b.Entries, files...) return nil } func (b *browser) loadMaps() error { var err error var rows *sql.Rows var p projectInfo var r repoInfo rows, err = b.DB.Query(`SELECT id, public_id, slug, name FROM projects`) if err != nil { return err } for rows.Next() { err = rows.Scan(&p.ID, &p.PublicID, &p.Slug, &p.Name) if err != nil { rows.Close() return err } b.ProjectsByID[p.ID] = p } err = rows.Close() if err != nil { return err } rows, err = b.DB.Query(`SELECT id, public_id, project_id, name, type, path FROM repos`) if err != nil { return err } for rows.Next() { err = rows.Scan(&r.ID, &r.PublicID, &r.ProjectID, &r.Name, &r.RepoType, &r.LegacyPath) if err != nil { rows.Close() return err } b.ReposByID[r.ID] = r } err = rows.Close() if err != nil { return err } return nil } func (b *browser) resolvePathHint(name string) string { var rel string var parts []string var service string var projectID int64 var repoID int64 var project projectInfo var repo repoInfo var err error rel, err = filepath.Rel(b.DataDir, filepath.Join(b.Cwd, name)) if err != nil { return "" } parts = strings.Split(filepath.ToSlash(rel), "/") if len(parts) < 2 { return "" } service = parts[0] projectID, err = parseStorageIDSegment(parts[1]) if err == nil { project = b.ProjectsByID[projectID] if project.ID > 0 && len(parts) == 2 { return fmt.Sprintf("project: %s (%s)", project.Slug, project.PublicID) } } if len(parts) < 3 { return "" } if service == "git" { parts[2] = strings.TrimSuffix(parts[2], ".git") } repoID, err = parseStorageIDSegment(parts[2]) if err != nil { return "" } repo = b.ReposByID[repoID] if repo.ID == 0 { return "" } return fmt.Sprintf("repo: %s (%s) type=%s", repo.Name, repo.PublicID, repo.RepoType) } func (b *browser) countMismatches() int { var ids []int64 var repoID int64 var repo repoInfo var expected string var err error var count int ids = make([]int64, 0, len(b.ReposByID)) for repoID = range b.ReposByID { ids = append(ids, repoID) } sort.Slice(ids, func(i int, j int) bool { return ids[i] < ids[j] }) count = 0 for _, repoID = range ids { repo = b.ReposByID[repoID] expected = expectedRepoPath(b.DataDir, repo.RepoType, repo.ProjectID, repo.ID) _, err = os.Stat(expected) if err != nil { count = count + 1 } } return count } func (b *browser) showMismatches() int { var lines []string var text string var modal *tview.Modal lines = b.collectMismatchLines() if len(lines) == 0 { return 0 } text = strings.Join(lines, "\n") modal = tview.NewModal(). SetText(text). AddButtons([]string{"Close"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { b.App.SetRoot(b.Root, true).SetFocus(b.Table) }) b.App.SetRoot(modal, true).SetFocus(modal) return len(lines) } func (b *browser) collectMismatchLines() []string { var ids []int64 var repoID int64 var repo repoInfo var project projectInfo var expected string var lines []string var err error ids = make([]int64, 0, len(b.ReposByID)) for repoID = range b.ReposByID { ids = append(ids, repoID) } sort.Slice(ids, func(i int, j int) bool { return ids[i] < ids[j] }) lines = []string{} for _, repoID = range ids { repo = b.ReposByID[repoID] expected = expectedRepoPath(b.DataDir, repo.RepoType, repo.ProjectID, repo.ID) _, err = os.Stat(expected) if err == nil { continue } project = b.ProjectsByID[repo.ProjectID] lines = append(lines, fmt.Sprintf("%s/%s [%s]", project.Slug, repo.Name, repo.RepoType)) lines = append(lines, "expected: "+expected) lines = append(lines, "current : "+repo.LegacyPath) lines = append(lines, "") } if len(lines) > 0 { lines = lines[:len(lines)-1] } return lines } func expectedRepoPath(dataDir string, repoType string, projectID int64, repoID int64) string { var p string var r string p = formatStorageIDSegment(projectID) r = formatStorageIDSegment(repoID) if repoType == "git" { return filepath.Join(dataDir, "git", p, r+".git") } if repoType == "rpm" { return filepath.Join(dataDir, "rpm", p, r) } if repoType == "docker" { return filepath.Join(dataDir, "docker", p, r) } return "" } func formatStorageIDSegment(id int64) string { return fmt.Sprintf("%016x", id) } func parseStorageIDSegment(s string) (int64, error) { var parsed int64 var err error var trimmed string trimmed = strings.TrimSpace(s) parsed, err = strconv.ParseInt(trimmed, 16, 64) if err == nil { return parsed, nil } return strconv.ParseInt(trimmed, 10, 64) } func pathInside(path string, root string) bool { var p string var r string var prefix string p = filepath.Clean(path) r = filepath.Clean(root) if p == r { return true } prefix = r + string(os.PathSeparator) return strings.HasPrefix(p, prefix) }