mirror of https://github.com/curusarn/resh
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
525 lines
13 KiB
525 lines
13 KiB
package searchapp
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/curusarn/resh/internal/recordint"
|
|
"golang.org/x/exp/utf8string"
|
|
)
|
|
|
|
const itemLocationLength = 30
|
|
const dots = "…"
|
|
|
|
// Item holds item info for normal mode
|
|
type Item struct {
|
|
isRaw bool
|
|
|
|
time float64
|
|
|
|
// [host:]pwd
|
|
differentHost bool
|
|
host string
|
|
home string
|
|
samePwd bool
|
|
pwd string
|
|
|
|
// [G] [E#]
|
|
sameGitRepo bool
|
|
exitCode int
|
|
|
|
CmdLineWithColor string
|
|
CmdLine string
|
|
|
|
Score float64
|
|
|
|
Key string
|
|
// cmdLineRaw string
|
|
}
|
|
|
|
// ItemColumns holds rendered columns
|
|
type ItemColumns struct {
|
|
DateWithColor string
|
|
Date string
|
|
|
|
// [host:]pwd
|
|
differentHost bool
|
|
Host string
|
|
PwdTilde string
|
|
samePwd bool
|
|
//locationWithColor string
|
|
//location string
|
|
|
|
// [G] [E#]
|
|
FlagsWithColor string
|
|
Flags string
|
|
|
|
CmdLineWithColor string
|
|
CmdLine string
|
|
|
|
// score float64
|
|
|
|
Key string
|
|
// cmdLineRaw string
|
|
}
|
|
|
|
func (i Item) less(i2 Item) bool {
|
|
// reversed order
|
|
return i.Score > i2.Score
|
|
}
|
|
|
|
func splitStatusLineToLines(statusLine string, printedLineLength, realLineLength int) []string {
|
|
var statusLineSlice []string
|
|
// status line
|
|
var idxSt, idxEnd int
|
|
var nextLine bool
|
|
tab := " "
|
|
tabSize := len(tab)
|
|
for idxSt < len(statusLine) {
|
|
idxEnd = idxSt + printedLineLength
|
|
if nextLine {
|
|
idxEnd -= tabSize
|
|
}
|
|
|
|
if idxEnd > len(statusLine) {
|
|
idxEnd = len(statusLine)
|
|
}
|
|
str := statusLine[idxSt:idxEnd]
|
|
|
|
indent := " "
|
|
if nextLine {
|
|
indent += tab
|
|
}
|
|
statusLineSlice = append(statusLineSlice, highlightStatus(rightCutPadString(indent+str, realLineLength))+"\n")
|
|
idxSt += printedLineLength
|
|
nextLine = true
|
|
}
|
|
return statusLineSlice
|
|
}
|
|
|
|
// DrawStatusLine ...
|
|
func (i Item) DrawStatusLine(compactRendering bool, printedLineLength, realLineLength int) []string {
|
|
if i.isRaw {
|
|
return splitStatusLineToLines(i.CmdLine, printedLineLength, realLineLength)
|
|
}
|
|
secs := int64(i.time)
|
|
nsecs := int64((i.time - float64(secs)) * 1e9)
|
|
tm := time.Unix(secs, nsecs)
|
|
const timeFormat = "2006-01-02 15:04:05"
|
|
timeString := tm.Format(timeFormat)
|
|
|
|
pwdTilde := strings.Replace(i.pwd, i.home, "~", 1)
|
|
|
|
separator := " "
|
|
stLine := timeString + separator + i.host + ":" + pwdTilde + separator + i.CmdLine
|
|
return splitStatusLineToLines(stLine, printedLineLength, realLineLength)
|
|
}
|
|
|
|
// GetEmptyStatusLine .
|
|
func GetEmptyStatusLine(printedLineLength, realLineLength int) []string {
|
|
return splitStatusLineToLines("- no result selected -", printedLineLength, realLineLength)
|
|
}
|
|
|
|
// DrawItemColumns ...
|
|
func (i Item) DrawItemColumns(compactRendering bool, debug bool) ItemColumns {
|
|
if i.isRaw {
|
|
notAvailable := "n/a"
|
|
return ItemColumns{
|
|
Date: notAvailable + " ",
|
|
DateWithColor: notAvailable + " ",
|
|
// dateWithColor: highlightDate(notAvailable) + " ",
|
|
Host: "",
|
|
PwdTilde: notAvailable,
|
|
CmdLine: i.CmdLine,
|
|
CmdLineWithColor: i.CmdLineWithColor,
|
|
// score: i.score,
|
|
Key: i.Key,
|
|
}
|
|
}
|
|
|
|
// DISPLAY
|
|
// DISPLAY > date
|
|
secs := int64(i.time)
|
|
nsecs := int64((i.time - float64(secs)) * 1e9)
|
|
tm := time.Unix(secs, nsecs)
|
|
|
|
var date string
|
|
if compactRendering {
|
|
date = formatTimeRelativeShort(tm) + " "
|
|
} else {
|
|
date = formatTimeRelativeLong(tm) + " "
|
|
}
|
|
dateWithColor := highlightDate(date)
|
|
// DISPLAY > location
|
|
// DISPLAY > location > host
|
|
host := ""
|
|
if i.differentHost {
|
|
host += i.host
|
|
}
|
|
// DISPLAY > location > directory
|
|
pwdTilde := strings.Replace(i.pwd, i.home, "~", 1)
|
|
|
|
// DISPLAY > flags
|
|
flags := ""
|
|
flagsWithColor := ""
|
|
if debug {
|
|
hitsStr := fmt.Sprintf("%.1f", i.Score)
|
|
flags += " S" + hitsStr
|
|
flagsWithColor += " S" + hitsStr
|
|
}
|
|
if i.sameGitRepo {
|
|
flags += " G"
|
|
flagsWithColor += " " + highlightGit("G")
|
|
}
|
|
if i.exitCode != 0 {
|
|
flags += " E" + strconv.Itoa(i.exitCode)
|
|
flagsWithColor += " " + highlightWarn("E"+strconv.Itoa(i.exitCode))
|
|
}
|
|
// NOTE: you can debug arbitrary metadata like this
|
|
// flags += " <" + record.GitOriginRemote + ">"
|
|
// flagsWithColor += " <" + record.GitOriginRemote + ">"
|
|
return ItemColumns{
|
|
Date: date,
|
|
DateWithColor: dateWithColor,
|
|
Host: host,
|
|
PwdTilde: pwdTilde,
|
|
samePwd: i.samePwd,
|
|
differentHost: i.differentHost,
|
|
Flags: flags,
|
|
FlagsWithColor: flagsWithColor,
|
|
CmdLine: i.CmdLine,
|
|
CmdLineWithColor: i.CmdLineWithColor,
|
|
// score: i.score,
|
|
Key: i.Key,
|
|
}
|
|
}
|
|
|
|
func minInt(values ...int) int {
|
|
min := math.MaxInt32
|
|
for _, val := range values {
|
|
if val < min {
|
|
min = val
|
|
}
|
|
}
|
|
return min
|
|
}
|
|
|
|
func produceLocation(length int, host string, pwdTilde string, differentHost bool, samePwd bool, debug bool) string {
|
|
hostLen := len(host)
|
|
if hostLen <= 0 {
|
|
pwdWithColor := leftCutPadString(pwdTilde, length)
|
|
if samePwd {
|
|
pwdWithColor = highlightPwd(pwdWithColor)
|
|
}
|
|
return pwdWithColor
|
|
}
|
|
colonLen := 1
|
|
pwdLen := len(pwdTilde)
|
|
totalLen := hostLen + colonLen + pwdLen
|
|
|
|
newHostLen := hostLen
|
|
// only shrink if the location does not fit
|
|
if totalLen > length {
|
|
// how much we need to shrink/crop the location
|
|
shrinkFactor := float64(length) / float64(totalLen)
|
|
|
|
shrinkedHostLen := int(math.Ceil(float64(hostLen) * shrinkFactor))
|
|
halfLocationLen := length/2 - colonLen
|
|
|
|
newHostLen = minInt(hostLen, shrinkedHostLen, halfLocationLen)
|
|
}
|
|
// pwd length is the rest of the length
|
|
newPwdLen := length - colonLen - newHostLen
|
|
|
|
// adjust pwd length
|
|
if newPwdLen > pwdLen {
|
|
diff := newPwdLen - pwdLen
|
|
newHostLen += diff
|
|
newPwdLen -= diff
|
|
}
|
|
|
|
hostWithColor := rightCutLeftPadString(host, newHostLen)
|
|
if differentHost {
|
|
hostWithColor = highlightHost(hostWithColor)
|
|
}
|
|
pwdWithColor := leftCutPadString(pwdTilde, newPwdLen)
|
|
if samePwd {
|
|
pwdWithColor = highlightPwd(pwdWithColor)
|
|
}
|
|
return hostWithColor + ":" + pwdWithColor
|
|
}
|
|
|
|
// ProduceLine ...
|
|
func (ic ItemColumns) ProduceLine(dateLength int, locationLength int, flagsLength int, header bool, showDate bool, debug bool) (string, int, error) {
|
|
var err error
|
|
line := ""
|
|
if showDate {
|
|
line += strings.Repeat(" ", dateLength-len(ic.Date)) + ic.DateWithColor
|
|
}
|
|
// LOCATION
|
|
locationWithColor := produceLocation(locationLength, ic.Host, ic.PwdTilde, ic.differentHost, ic.samePwd, debug)
|
|
line += locationWithColor
|
|
|
|
// FLAGS
|
|
line += ic.FlagsWithColor
|
|
if flagsLength >= len(ic.Flags) {
|
|
line += strings.Repeat(" ", flagsLength-len(ic.Flags))
|
|
} else {
|
|
err = fmt.Errorf("actual flags are longer than dedicated flag space. actual: %v, space: %v", len(ic.Flags), flagsLength)
|
|
}
|
|
spacer := " "
|
|
if flagsLength > 5 || header {
|
|
// use shorter spacer
|
|
// because there is likely a long flag like E130 in the view
|
|
spacer = " "
|
|
}
|
|
line += spacer + ic.CmdLineWithColor
|
|
|
|
length := dateLength + locationLength + flagsLength + len(spacer) + len(ic.CmdLine)
|
|
return line, length, err
|
|
}
|
|
|
|
func rightCutLeftPadString(str string, newLen int) string {
|
|
if len(str) > newLen {
|
|
return rightCutPadString(str, newLen)
|
|
}
|
|
if len(str) < newLen {
|
|
return leftCutPadString(str, newLen)
|
|
}
|
|
return str
|
|
}
|
|
|
|
func leftCutPadString(str string, newLen int) string {
|
|
if newLen <= 0 {
|
|
return ""
|
|
}
|
|
utf8Str := utf8string.NewString(str)
|
|
strLen := utf8Str.RuneCount()
|
|
if newLen > strLen {
|
|
return strings.Repeat(" ", newLen-strLen) + str
|
|
} else if newLen < strLen {
|
|
return dots + utf8string.NewString(str).Slice(strLen-newLen+1, strLen)
|
|
}
|
|
return str
|
|
}
|
|
|
|
func rightCutPadString(str string, newLen int) string {
|
|
if newLen <= 0 {
|
|
return ""
|
|
}
|
|
utf8Str := utf8string.NewString(str)
|
|
strLen := utf8Str.RuneCount()
|
|
if newLen > strLen {
|
|
return str + strings.Repeat(" ", newLen-strLen)
|
|
} else if newLen < strLen {
|
|
return utf8Str.Slice(0, newLen-1) + dots
|
|
}
|
|
return str
|
|
}
|
|
|
|
// proper match for path is when whole directory is matched
|
|
// proper match for command is when term matches word delimited by whitespace
|
|
func properMatch(str, term, padChar string) bool {
|
|
return strings.Contains(padChar+str+padChar, padChar+term+padChar)
|
|
}
|
|
|
|
// NewItemFromRecordForQuery creates new item from record based on given query
|
|
//
|
|
// returns error if the query doesn't match the record
|
|
func NewItemFromRecordForQuery(record recordint.SearchApp, query Query, debug bool) (Item, error) {
|
|
// Use numbers that won't add up to same score for any number of query words
|
|
// query score weight 1.51
|
|
const hitScore = 1.517 // 1 * 1.51
|
|
const properMatchScore = 0.501 // 0.33 * 1.51
|
|
const hitScoreConsecutive = 0.00302 // 0.002 * 1.51
|
|
|
|
// context score weight 1
|
|
// Host penalty
|
|
var actualPwdScore = 0.9
|
|
var sameGitRepoScore = 0.8
|
|
var nonZeroExitCodeScorePenalty = 0.4
|
|
var differentHostScorePenalty = 0.2
|
|
|
|
reduceHostPenalty := false
|
|
if reduceHostPenalty {
|
|
actualPwdScore = 0.9
|
|
sameGitRepoScore = 0.7
|
|
nonZeroExitCodeScorePenalty = 0.4
|
|
differentHostScorePenalty = 0.1
|
|
}
|
|
|
|
const timeScoreCoef = 1e-13
|
|
// nonZeroExitCodeScorePenalty + differentHostScorePenalty
|
|
|
|
score := 0.0
|
|
anyHit := false
|
|
cmd := record.CmdLine
|
|
for _, term := range query.terms {
|
|
c := strings.Count(record.CmdLine, term)
|
|
if c > 0 {
|
|
anyHit = true
|
|
score += hitScore + hitScoreConsecutive*float64(c)
|
|
if properMatch(cmd, term, " ") {
|
|
score += properMatchScore
|
|
}
|
|
cmd = strings.ReplaceAll(cmd, term, highlightMatch(term))
|
|
}
|
|
}
|
|
// DISPLAY > cmdline
|
|
|
|
// cmd := "<" + strings.ReplaceAll(record.CmdLine, "\n", ";") + ">"
|
|
cmdLine := strings.ReplaceAll(record.CmdLine, "\n", ";")
|
|
cmdLineWithColor := strings.ReplaceAll(cmd, "\n", ";")
|
|
|
|
// KEY for deduplication
|
|
|
|
key := record.CmdLine
|
|
// NOTE: since we import standard history we need a compatible key without metadata
|
|
/*
|
|
unlikelySeparator := "|||||"
|
|
key := record.CmdLine + unlikelySeparator + record.Pwd + unlikelySeparator +
|
|
record.GitOriginRemote + unlikelySeparator + record.Host
|
|
*/
|
|
if record.IsRaw {
|
|
return Item{
|
|
isRaw: true,
|
|
|
|
CmdLine: cmdLine,
|
|
CmdLineWithColor: cmdLineWithColor,
|
|
Score: score,
|
|
Key: key,
|
|
}, nil
|
|
}
|
|
// actual pwd matches
|
|
// N terms can only produce:
|
|
// -> N matches against the command
|
|
// -> 1 extra match for the actual directory match
|
|
sameGitRepo := false
|
|
if query.gitOriginRemote != "" && query.gitOriginRemote == record.GitOriginRemote {
|
|
sameGitRepo = true
|
|
}
|
|
|
|
samePwd := false
|
|
if record.Pwd == query.pwd {
|
|
anyHit = true
|
|
samePwd = true
|
|
score += actualPwdScore
|
|
} else if sameGitRepo {
|
|
anyHit = true
|
|
score += sameGitRepoScore
|
|
}
|
|
|
|
differentHost := false
|
|
if record.Host != query.host {
|
|
differentHost = true
|
|
score -= differentHostScorePenalty
|
|
}
|
|
// errorExitStatus := false
|
|
if record.ExitCode != 0 {
|
|
// errorExitStatus = true
|
|
score -= nonZeroExitCodeScorePenalty
|
|
}
|
|
_ = anyHit
|
|
// if score <= 0 && !anyHit {
|
|
// return Item{}, errors.New("no match for given record and query")
|
|
// }
|
|
score += record.Time * timeScoreCoef
|
|
|
|
it := Item{
|
|
time: record.Time,
|
|
|
|
differentHost: differentHost,
|
|
host: record.Host,
|
|
home: record.Home,
|
|
samePwd: samePwd,
|
|
pwd: record.Pwd,
|
|
|
|
sameGitRepo: sameGitRepo,
|
|
exitCode: record.ExitCode,
|
|
CmdLine: cmdLine,
|
|
CmdLineWithColor: cmdLineWithColor,
|
|
Score: score,
|
|
Key: key,
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// GetHeader returns header columns
|
|
func GetHeader(compactRendering bool) ItemColumns {
|
|
date := "TIME "
|
|
host := "HOST"
|
|
dir := "DIRECTORY"
|
|
if compactRendering {
|
|
dir = "DIR"
|
|
}
|
|
flags := " FLAGS"
|
|
cmdLine := "COMMAND-LINE"
|
|
return ItemColumns{
|
|
Date: date,
|
|
DateWithColor: date,
|
|
Host: host,
|
|
PwdTilde: dir,
|
|
samePwd: false,
|
|
Flags: flags,
|
|
FlagsWithColor: flags,
|
|
CmdLine: cmdLine,
|
|
CmdLineWithColor: cmdLine,
|
|
// score: i.score,
|
|
Key: "_HEADERS_",
|
|
}
|
|
}
|
|
|
|
// RawItem is item for raw mode
|
|
type RawItem struct {
|
|
CmdLineWithColor string
|
|
CmdLine string
|
|
|
|
Score float64
|
|
|
|
Key string
|
|
// cmdLineRaw string
|
|
}
|
|
|
|
// NewRawItemFromRecordForQuery creates new item from record based on given query
|
|
//
|
|
// returns error if the query doesn't match the record
|
|
func NewRawItemFromRecordForQuery(record recordint.SearchApp, terms []string, debug bool) (RawItem, error) {
|
|
const hitScore = 1.0
|
|
const hitScoreConsecutive = 0.01
|
|
const properMatchScore = 0.3
|
|
|
|
const timeScoreCoef = 1e-13
|
|
|
|
score := 0.0
|
|
cmd := record.CmdLine
|
|
for _, term := range terms {
|
|
c := strings.Count(record.CmdLine, term)
|
|
if c > 0 {
|
|
score += hitScore + hitScoreConsecutive*float64(c)
|
|
if properMatch(cmd, term, " ") {
|
|
score += properMatchScore
|
|
}
|
|
cmd = strings.ReplaceAll(cmd, term, highlightMatch(term))
|
|
}
|
|
}
|
|
score += record.Time * timeScoreCoef
|
|
// KEY for deduplication
|
|
key := record.CmdLine
|
|
|
|
// DISPLAY > cmdline
|
|
|
|
// cmd := "<" + strings.ReplaceAll(record.CmdLine, "\n", ";") + ">"
|
|
cmdLine := strings.ReplaceAll(record.CmdLine, "\n", ";")
|
|
cmdLineWithColor := strings.ReplaceAll(cmd, "\n", ";")
|
|
|
|
it := RawItem{
|
|
CmdLine: cmdLine,
|
|
CmdLineWithColor: cmdLineWithColor,
|
|
Score: score,
|
|
Key: key,
|
|
}
|
|
return it, nil
|
|
}
|
|
|