573 lines
12 KiB
Go
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)
|
|
}
|