Files

573 lines
12 KiB
Go

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)
}