mirror of
https://github.com/ivuorinen/tsm.git
synced 2026-01-26 03:24:01 +00:00
chore: update main.go
This commit is contained in:
207
main.go
207
main.go
@@ -24,11 +24,23 @@ import (
|
||||
const (
|
||||
appName = "tsm"
|
||||
defaultTimeout = 6 * time.Second
|
||||
cfgDirName = "tsm"
|
||||
cfgBaseName = "config"
|
||||
pageStep = 5 // PgUp/PgDn step
|
||||
)
|
||||
|
||||
// ldflags-set at build time by goreleaser or Makefile
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
// ---------------- Options ----------------
|
||||
|
||||
type Options struct {
|
||||
ConfigPath string
|
||||
Print bool
|
||||
}
|
||||
|
||||
// ---------------- Config ----------------
|
||||
|
||||
type Config struct {
|
||||
@@ -39,7 +51,6 @@ type Config struct {
|
||||
}
|
||||
|
||||
func defaultExclude() []string {
|
||||
// Common build/vendor/cache dirs across ecosystems
|
||||
return []string{
|
||||
".git", "node_modules", "vendor", "dist", "build", "target", "out", "bin",
|
||||
".cache", ".next", ".nuxt", ".pnpm-store", ".yarn", ".yarn/cache",
|
||||
@@ -59,8 +70,8 @@ func loadConfig(explicit string) (Config, error) {
|
||||
home, _ := os.UserHomeDir()
|
||||
xdg = filepath.Join(home, ".config")
|
||||
}
|
||||
v.AddConfigPath(filepath.Join(xdg, cfgDirName))
|
||||
v.SetConfigName(cfgBaseName)
|
||||
v.AddConfigPath(filepath.Join(xdg, "tsm"))
|
||||
v.SetConfigName("config")
|
||||
}
|
||||
_ = v.ReadInConfig() // best-effort
|
||||
var cfg Config
|
||||
@@ -70,7 +81,7 @@ func loadConfig(explicit string) (Config, error) {
|
||||
cfg.Exclude = defaultExclude()
|
||||
}
|
||||
if cfg.MaxDepth == 0 {
|
||||
cfg.MaxDepth = 3 // default per request
|
||||
cfg.MaxDepth = 3
|
||||
}
|
||||
if len(cfg.ScanPaths) == 0 {
|
||||
if home, _ := os.UserHomeDir(); home != "" {
|
||||
@@ -89,7 +100,7 @@ func xdgConfigPath() (string, error) {
|
||||
}
|
||||
xdg = filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(xdg, cfgDirName, cfgBaseName+".yaml"), nil
|
||||
return filepath.Join(xdg, "tsm", "config.yaml"), nil
|
||||
}
|
||||
|
||||
func writeDefaultConfig(w io.Writer) error {
|
||||
@@ -118,7 +129,7 @@ func writeDefaultConfig(w io.Writer) error {
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(w, "Wrote default config → %s\n", path)
|
||||
_, _ = fmt.Fprintf(w, "Wrote default config → %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -167,12 +178,11 @@ func sanitize(base string) string {
|
||||
return out
|
||||
}
|
||||
|
||||
// sessionNameFromPath builds "<parent>_<base>" to avoid collisions.
|
||||
// e.g., /home/u/Code/ivuorinen/a -> "ivuorinen_a"
|
||||
// "<parent>_<base>" — /home/u/Code/ivuorinen/a -> "ivuorinen_a"
|
||||
func sessionNameFromPath(dir string) string {
|
||||
base := sanitize(filepath.Base(dir))
|
||||
parent := sanitize(filepath.Base(filepath.Dir(dir)))
|
||||
if parent == "" || parent == "." || parent == string(os.PathSeparator) {
|
||||
if parent == "" || parent == "." || parent == string(filepath.Separator) {
|
||||
return base
|
||||
}
|
||||
return parent + "_" + base
|
||||
@@ -185,21 +195,21 @@ type Shell interface {
|
||||
Run(ctx context.Context, name string, args ...string) error
|
||||
}
|
||||
|
||||
type ExecShell struct{}
|
||||
type execShell struct{}
|
||||
|
||||
func (ExecShell) Output(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
func (execShell) Output(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = os.Environ()
|
||||
return cmd.Output()
|
||||
}
|
||||
func (ExecShell) Run(ctx context.Context, name string, args ...string) error {
|
||||
func (execShell) Run(ctx context.Context, name string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
var shell Shell = ExecShell{}
|
||||
var shell Shell = execShell{}
|
||||
|
||||
func listTmuxSessions(ctx context.Context) []string {
|
||||
out, err := shell.Output(ctx, "tmux", "list-sessions", "-F", "#S")
|
||||
@@ -239,6 +249,8 @@ func createOrSwitchForDir(ctx context.Context, sess, dir string, inTmux bool) er
|
||||
return switchToSession(ctx, sess, inTmux)
|
||||
}
|
||||
|
||||
func isInTmux() bool { return os.Getenv("TMUX") != "" }
|
||||
|
||||
// ---------------- Discovery (concurrent) ----------------
|
||||
|
||||
func expandPath(p string) (string, bool) {
|
||||
@@ -321,9 +333,8 @@ func scanGitReposConcurrent(cfg Config) []string {
|
||||
return repos
|
||||
}
|
||||
|
||||
// ---------------- Simple fuzzy UI ----------------
|
||||
// ---------------- Fuzzy UI ----------------
|
||||
|
||||
// score: simple subsequence match, higher is better, prefer prefix
|
||||
func fuzzyScore(needle, hay string) int {
|
||||
if needle == "" {
|
||||
return 1
|
||||
@@ -331,7 +342,7 @@ func fuzzyScore(needle, hay string) int {
|
||||
ni, score, streak := 0, 0, 0
|
||||
for i := 0; i < len(hay) && ni < len(needle); i++ {
|
||||
if toLower(hay[i]) == toLower(needle[ni]) {
|
||||
score += 2 + streak // reward streaks
|
||||
score += 2 + streak
|
||||
ni++
|
||||
streak++
|
||||
} else {
|
||||
@@ -366,8 +377,7 @@ func filterAndRank(items []Item, q string, limit int) []viewItem {
|
||||
if it.Path != "" {
|
||||
key += " " + it.Path
|
||||
}
|
||||
s := fuzzyScore(q, key)
|
||||
if s >= 0 {
|
||||
if s := fuzzyScore(q, key); s >= 0 {
|
||||
out = append(out, viewItem{Item: it, score: s})
|
||||
}
|
||||
}
|
||||
@@ -383,8 +393,6 @@ func filterAndRank(items []Item, q string, limit int) []viewItem {
|
||||
return out
|
||||
}
|
||||
|
||||
// Terminal UI: minimal raw-mode UI for live filtering.
|
||||
// On Windows, we degrade: read query then print numbered list to select.
|
||||
func interactiveSelect(items []Item) (Item, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
fmt.Println("Query: ")
|
||||
@@ -403,7 +411,6 @@ func interactiveSelect(items []Item) (Item, error) {
|
||||
return cands[n-1].Item, nil
|
||||
}
|
||||
|
||||
// UNIX: ANSI raw-mode small TUI
|
||||
_, restore, err := enableRawMode()
|
||||
if err != nil {
|
||||
return promptOnce(items)
|
||||
@@ -416,7 +423,7 @@ func interactiveSelect(items []Item) (Item, error) {
|
||||
|
||||
render := func() {
|
||||
clearScreen()
|
||||
fmt.Printf("tsm — filter (↑/↓, Ctrl-N/P, Enter, Backspace, Ctrl-U clear, Tab preview, Home/End, PgUp/PgDn, Ctrl-C cancel)\n")
|
||||
fmt.Printf("tsm — %s (commit %s) — filter (↑/↓, Ctrl-N/P, Enter, Backspace, Ctrl-U, Tab, Home/End, PgUp/PgDn, Ctrl-C)\n", version, commit)
|
||||
fmt.Printf("> %s\n\n", query)
|
||||
cands := filterAndRank(items, query, 30)
|
||||
if idx >= len(cands) {
|
||||
@@ -463,50 +470,45 @@ func interactiveSelect(items []Item) (Item, error) {
|
||||
continue
|
||||
}
|
||||
return cands[idx].Item, nil
|
||||
case 21: // Ctrl-U: clear
|
||||
query = ""
|
||||
idx = 0
|
||||
case 9: // Tab: toggle preview
|
||||
case 21: // Ctrl-U
|
||||
query, idx = "", 0
|
||||
case 9: // Tab
|
||||
showPreview = !showPreview
|
||||
case 127, 8: // Backspace
|
||||
if len(query) > 0 {
|
||||
query = query[:len(query)-1]
|
||||
}
|
||||
case 27: // ESC… parse arrow/Home/End/PgUp/PgDn
|
||||
// Expect sequences like: ESC [ A/B (arrows), ESC [ H/F (home/end),
|
||||
// ESC [ 5 ~ (PgUp), ESC [ 6 ~ (PgDn), ESC [ 1 ~ (Home), ESC [ 4 ~ (End)
|
||||
case 27:
|
||||
b1, _ := readKey.ReadByte()
|
||||
if b1 != '[' {
|
||||
break
|
||||
}
|
||||
b2, _ := readKey.ReadByte()
|
||||
switch b2 {
|
||||
case 'A': // up
|
||||
case 'A':
|
||||
idx--
|
||||
case 'B': // down
|
||||
case 'B':
|
||||
idx++
|
||||
case 'H', '1': // Home
|
||||
case 'H', '1':
|
||||
if b2 == '1' {
|
||||
_, _ = readKey.ReadByte() // expect '~'
|
||||
_, _ = readKey.ReadByte()
|
||||
}
|
||||
idx = 0
|
||||
case 'F', '4': // End
|
||||
case 'F', '4':
|
||||
if b2 == '4' {
|
||||
_, _ = readKey.ReadByte() // expect '~'
|
||||
_, _ = readKey.ReadByte()
|
||||
}
|
||||
// set at end after we know candidate count in render
|
||||
cands := filterAndRank(items, query, 30)
|
||||
if len(cands) > 0 {
|
||||
idx = len(cands) - 1
|
||||
}
|
||||
case '5': // PgUp
|
||||
_, _ = readKey.ReadByte() // consume '~'
|
||||
case '5':
|
||||
_, _ = readKey.ReadByte()
|
||||
idx -= pageStep
|
||||
case '6': // PgDn
|
||||
_, _ = readKey.ReadByte() // consume '~'
|
||||
case '6':
|
||||
_, _ = readKey.ReadByte()
|
||||
idx += pageStep
|
||||
default:
|
||||
// consume any trailing '~' if present
|
||||
if b2 >= '0' && b2 <= '9' {
|
||||
_, _ = readKey.ReadByte()
|
||||
}
|
||||
@@ -516,7 +518,6 @@ func interactiveSelect(items []Item) (Item, error) {
|
||||
case 16: // Ctrl-P
|
||||
idx--
|
||||
default:
|
||||
// printable?
|
||||
if r >= 32 && r <= 126 {
|
||||
query += string(r)
|
||||
}
|
||||
@@ -542,12 +543,11 @@ func promptOnce(items []Item) (Item, error) {
|
||||
return cands[n-1].Item, nil
|
||||
}
|
||||
|
||||
// Raw mode helpers (unix)
|
||||
// Raw mode via stty (no extra deps)
|
||||
func enableRawMode() (bool, func(), error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return false, func() {}, nil
|
||||
}
|
||||
// very small shim using stty to avoid cgo/term deps
|
||||
cmd := exec.Command("stty", "-g")
|
||||
cmd.Stdin = os.Stdin
|
||||
old, err := cmd.Output()
|
||||
@@ -567,62 +567,23 @@ func enableRawMode() (bool, func(), error) {
|
||||
return true, restore, nil
|
||||
}
|
||||
|
||||
func clearScreen() {
|
||||
fmt.Print("\x1b[2J\x1b[H")
|
||||
}
|
||||
func clearScreen() { fmt.Print("\x1b[2J\x1b[H") }
|
||||
|
||||
// ---------------- Main ----------------
|
||||
// ---------------- Orchestrator ----------------
|
||||
|
||||
func isInTmux() bool { return os.Getenv("TMUX") != "" }
|
||||
|
||||
func main() {
|
||||
var (
|
||||
flagCfg string
|
||||
flagPrint bool
|
||||
flagInitCfg bool
|
||||
)
|
||||
flag.StringVar(&flagCfg, "config", "", "Explicit config file path")
|
||||
flag.BoolVar(&flagPrint, "print", false, "Print candidates and exit")
|
||||
flag.BoolVar(&flagInitCfg, "init-config", false, "Write default config to XDG path and exit")
|
||||
flag.Parse()
|
||||
|
||||
if flagInitCfg {
|
||||
if err := writeDefaultConfig(os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(flagCfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: config error: %v\n", appName, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Build candidates
|
||||
func buildItems(ctx context.Context, cfg Config) []Item {
|
||||
var items []Item
|
||||
|
||||
// Sessions
|
||||
for _, s := range listTmuxSessions(ctx) {
|
||||
items = append(items, Item{Kind: KindSession, Name: s})
|
||||
}
|
||||
// Scan repos concurrently
|
||||
repos := scanGitReposConcurrent(cfg)
|
||||
for _, r := range repos {
|
||||
for _, r := range scanGitReposConcurrent(cfg) {
|
||||
items = append(items, Item{Kind: KindGitRepo, Name: sessionNameFromPath(r), Path: r})
|
||||
}
|
||||
// Bookmarks
|
||||
for _, b := range cfg.Bookmarks {
|
||||
if p, ok := expandPath(b); ok {
|
||||
items = append(items, Item{Kind: KindBookmark, Name: sessionNameFromPath(p), Path: p})
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup: prefer sessions by name; for G/B use (name,path)
|
||||
seen := map[string]struct{}{}
|
||||
var uniq []Item
|
||||
for _, it := range items {
|
||||
@@ -638,28 +599,80 @@ func main() {
|
||||
seen[key] = struct{}{}
|
||||
uniq = append(uniq, it)
|
||||
}
|
||||
items = uniq
|
||||
return uniq
|
||||
}
|
||||
|
||||
if flagPrint {
|
||||
func Run(opts Options) error {
|
||||
if opts.Print && opts.ConfigPath == "__init__" {
|
||||
return writeDefaultConfig(os.Stdout)
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(opts.ConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: config error: %w", appName, err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
items := buildItems(ctx, cfg)
|
||||
if opts.Print {
|
||||
for _, it := range items {
|
||||
fmt.Printf("%s\t%s\t%s\n", it.Kind, it.Name, it.Path)
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "no candidates")
|
||||
return
|
||||
return errors.New("no candidates")
|
||||
}
|
||||
|
||||
selected, err := interactiveSelect(items)
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
inTmux := isInTmux()
|
||||
switch selected.Kind {
|
||||
case KindSession:
|
||||
_ = switchToSession(ctx, selected.Name, inTmux)
|
||||
return switchToSession(ctx, selected.Name, inTmux)
|
||||
case KindGitRepo, KindBookmark:
|
||||
_ = createOrSwitchForDir(ctx, selected.Name, selected.Path, inTmux)
|
||||
return createOrSwitchForDir(ctx, selected.Name, selected.Path, inTmux)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- main() ----------------
|
||||
|
||||
func main() {
|
||||
var (
|
||||
flagCfg string
|
||||
flagPrint bool
|
||||
flagInitCfg bool
|
||||
flagVersion bool
|
||||
)
|
||||
flag.StringVar(&flagCfg, "config", "", "Explicit config file path")
|
||||
flag.BoolVar(&flagPrint, "print", false, "Print candidates and exit")
|
||||
flag.BoolVar(&flagInitCfg, "init-config", false, "Write default config to XDG path and exit")
|
||||
flag.BoolVar(&flagVersion, "version", false, "Print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if flagVersion {
|
||||
fmt.Printf("tsm %s (commit %s, built %s)\n", version, commit, date)
|
||||
return
|
||||
}
|
||||
|
||||
if flagInitCfg {
|
||||
if err := writeDefaultConfig(os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := Run(Options{
|
||||
ConfigPath: flagCfg,
|
||||
Print: flagPrint,
|
||||
}); err != nil && err.Error() != "cancelled" {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user