chore: update main.go

This commit is contained in:
2025-08-15 17:49:16 +03:00
committed by GitHub
parent e2fcb61ddb
commit f9b36a98ce

207
main.go
View File

@@ -24,11 +24,23 @@ import (
const ( const (
appName = "tsm" appName = "tsm"
defaultTimeout = 6 * time.Second defaultTimeout = 6 * time.Second
cfgDirName = "tsm"
cfgBaseName = "config"
pageStep = 5 // PgUp/PgDn step 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 ---------------- // ---------------- Config ----------------
type Config struct { type Config struct {
@@ -39,7 +51,6 @@ type Config struct {
} }
func defaultExclude() []string { func defaultExclude() []string {
// Common build/vendor/cache dirs across ecosystems
return []string{ return []string{
".git", "node_modules", "vendor", "dist", "build", "target", "out", "bin", ".git", "node_modules", "vendor", "dist", "build", "target", "out", "bin",
".cache", ".next", ".nuxt", ".pnpm-store", ".yarn", ".yarn/cache", ".cache", ".next", ".nuxt", ".pnpm-store", ".yarn", ".yarn/cache",
@@ -59,8 +70,8 @@ func loadConfig(explicit string) (Config, error) {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
xdg = filepath.Join(home, ".config") xdg = filepath.Join(home, ".config")
} }
v.AddConfigPath(filepath.Join(xdg, cfgDirName)) v.AddConfigPath(filepath.Join(xdg, "tsm"))
v.SetConfigName(cfgBaseName) v.SetConfigName("config")
} }
_ = v.ReadInConfig() // best-effort _ = v.ReadInConfig() // best-effort
var cfg Config var cfg Config
@@ -70,7 +81,7 @@ func loadConfig(explicit string) (Config, error) {
cfg.Exclude = defaultExclude() cfg.Exclude = defaultExclude()
} }
if cfg.MaxDepth == 0 { if cfg.MaxDepth == 0 {
cfg.MaxDepth = 3 // default per request cfg.MaxDepth = 3
} }
if len(cfg.ScanPaths) == 0 { if len(cfg.ScanPaths) == 0 {
if home, _ := os.UserHomeDir(); home != "" { if home, _ := os.UserHomeDir(); home != "" {
@@ -89,7 +100,7 @@ func xdgConfigPath() (string, error) {
} }
xdg = filepath.Join(home, ".config") 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 { 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 { if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
return err return err
} }
fmt.Fprintf(w, "Wrote default config → %s\n", path) _, _ = fmt.Fprintf(w, "Wrote default config → %s\n", path)
return nil return nil
} }
@@ -167,12 +178,11 @@ func sanitize(base string) string {
return out return out
} }
// sessionNameFromPath builds "<parent>_<base>" to avoid collisions. // "<parent>_<base>" — /home/u/Code/ivuorinen/a -> "ivuorinen_a"
// e.g., /home/u/Code/ivuorinen/a -> "ivuorinen_a"
func sessionNameFromPath(dir string) string { func sessionNameFromPath(dir string) string {
base := sanitize(filepath.Base(dir)) base := sanitize(filepath.Base(dir))
parent := sanitize(filepath.Base(filepath.Dir(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 base
} }
return parent + "_" + base return parent + "_" + base
@@ -185,21 +195,21 @@ type Shell interface {
Run(ctx context.Context, name string, args ...string) error 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 := exec.CommandContext(ctx, name, args...)
cmd.Env = os.Environ() cmd.Env = os.Environ()
return cmd.Output() 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 := exec.CommandContext(ctx, name, args...)
cmd.Env = os.Environ() cmd.Env = os.Environ()
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
return cmd.Run() return cmd.Run()
} }
var shell Shell = ExecShell{} var shell Shell = execShell{}
func listTmuxSessions(ctx context.Context) []string { func listTmuxSessions(ctx context.Context) []string {
out, err := shell.Output(ctx, "tmux", "list-sessions", "-F", "#S") 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) return switchToSession(ctx, sess, inTmux)
} }
func isInTmux() bool { return os.Getenv("TMUX") != "" }
// ---------------- Discovery (concurrent) ---------------- // ---------------- Discovery (concurrent) ----------------
func expandPath(p string) (string, bool) { func expandPath(p string) (string, bool) {
@@ -321,9 +333,8 @@ func scanGitReposConcurrent(cfg Config) []string {
return repos return repos
} }
// ---------------- Simple fuzzy UI ---------------- // ---------------- Fuzzy UI ----------------
// score: simple subsequence match, higher is better, prefer prefix
func fuzzyScore(needle, hay string) int { func fuzzyScore(needle, hay string) int {
if needle == "" { if needle == "" {
return 1 return 1
@@ -331,7 +342,7 @@ func fuzzyScore(needle, hay string) int {
ni, score, streak := 0, 0, 0 ni, score, streak := 0, 0, 0
for i := 0; i < len(hay) && ni < len(needle); i++ { for i := 0; i < len(hay) && ni < len(needle); i++ {
if toLower(hay[i]) == toLower(needle[ni]) { if toLower(hay[i]) == toLower(needle[ni]) {
score += 2 + streak // reward streaks score += 2 + streak
ni++ ni++
streak++ streak++
} else { } else {
@@ -366,8 +377,7 @@ func filterAndRank(items []Item, q string, limit int) []viewItem {
if it.Path != "" { if it.Path != "" {
key += " " + it.Path key += " " + it.Path
} }
s := fuzzyScore(q, key) if s := fuzzyScore(q, key); s >= 0 {
if s >= 0 {
out = append(out, viewItem{Item: it, score: s}) out = append(out, viewItem{Item: it, score: s})
} }
} }
@@ -383,8 +393,6 @@ func filterAndRank(items []Item, q string, limit int) []viewItem {
return out 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) { func interactiveSelect(items []Item) (Item, error) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
fmt.Println("Query: ") fmt.Println("Query: ")
@@ -403,7 +411,6 @@ func interactiveSelect(items []Item) (Item, error) {
return cands[n-1].Item, nil return cands[n-1].Item, nil
} }
// UNIX: ANSI raw-mode small TUI
_, restore, err := enableRawMode() _, restore, err := enableRawMode()
if err != nil { if err != nil {
return promptOnce(items) return promptOnce(items)
@@ -416,7 +423,7 @@ func interactiveSelect(items []Item) (Item, error) {
render := func() { render := func() {
clearScreen() 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) fmt.Printf("> %s\n\n", query)
cands := filterAndRank(items, query, 30) cands := filterAndRank(items, query, 30)
if idx >= len(cands) { if idx >= len(cands) {
@@ -463,50 +470,45 @@ func interactiveSelect(items []Item) (Item, error) {
continue continue
} }
return cands[idx].Item, nil return cands[idx].Item, nil
case 21: // Ctrl-U: clear case 21: // Ctrl-U
query = "" query, idx = "", 0
idx = 0 case 9: // Tab
case 9: // Tab: toggle preview
showPreview = !showPreview showPreview = !showPreview
case 127, 8: // Backspace case 127, 8: // Backspace
if len(query) > 0 { if len(query) > 0 {
query = query[:len(query)-1] query = query[:len(query)-1]
} }
case 27: // ESC… parse arrow/Home/End/PgUp/PgDn case 27:
// Expect sequences like: ESC [ A/B (arrows), ESC [ H/F (home/end),
// ESC [ 5 ~ (PgUp), ESC [ 6 ~ (PgDn), ESC [ 1 ~ (Home), ESC [ 4 ~ (End)
b1, _ := readKey.ReadByte() b1, _ := readKey.ReadByte()
if b1 != '[' { if b1 != '[' {
break break
} }
b2, _ := readKey.ReadByte() b2, _ := readKey.ReadByte()
switch b2 { switch b2 {
case 'A': // up case 'A':
idx-- idx--
case 'B': // down case 'B':
idx++ idx++
case 'H', '1': // Home case 'H', '1':
if b2 == '1' { if b2 == '1' {
_, _ = readKey.ReadByte() // expect '~' _, _ = readKey.ReadByte()
} }
idx = 0 idx = 0
case 'F', '4': // End case 'F', '4':
if b2 == '4' { if b2 == '4' {
_, _ = readKey.ReadByte() // expect '~' _, _ = readKey.ReadByte()
} }
// set at end after we know candidate count in render
cands := filterAndRank(items, query, 30) cands := filterAndRank(items, query, 30)
if len(cands) > 0 { if len(cands) > 0 {
idx = len(cands) - 1 idx = len(cands) - 1
} }
case '5': // PgUp case '5':
_, _ = readKey.ReadByte() // consume '~' _, _ = readKey.ReadByte()
idx -= pageStep idx -= pageStep
case '6': // PgDn case '6':
_, _ = readKey.ReadByte() // consume '~' _, _ = readKey.ReadByte()
idx += pageStep idx += pageStep
default: default:
// consume any trailing '~' if present
if b2 >= '0' && b2 <= '9' { if b2 >= '0' && b2 <= '9' {
_, _ = readKey.ReadByte() _, _ = readKey.ReadByte()
} }
@@ -516,7 +518,6 @@ func interactiveSelect(items []Item) (Item, error) {
case 16: // Ctrl-P case 16: // Ctrl-P
idx-- idx--
default: default:
// printable?
if r >= 32 && r <= 126 { if r >= 32 && r <= 126 {
query += string(r) query += string(r)
} }
@@ -542,12 +543,11 @@ func promptOnce(items []Item) (Item, error) {
return cands[n-1].Item, nil return cands[n-1].Item, nil
} }
// Raw mode helpers (unix) // Raw mode via stty (no extra deps)
func enableRawMode() (bool, func(), error) { func enableRawMode() (bool, func(), error) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return false, func() {}, nil return false, func() {}, nil
} }
// very small shim using stty to avoid cgo/term deps
cmd := exec.Command("stty", "-g") cmd := exec.Command("stty", "-g")
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
old, err := cmd.Output() old, err := cmd.Output()
@@ -567,62 +567,23 @@ func enableRawMode() (bool, func(), error) {
return true, restore, nil return true, restore, nil
} }
func clearScreen() { func clearScreen() { fmt.Print("\x1b[2J\x1b[H") }
fmt.Print("\x1b[2J\x1b[H")
}
// ---------------- Main ---------------- // ---------------- Orchestrator ----------------
func isInTmux() bool { return os.Getenv("TMUX") != "" } func buildItems(ctx context.Context, cfg Config) []Item {
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
var items []Item var items []Item
// Sessions
for _, s := range listTmuxSessions(ctx) { for _, s := range listTmuxSessions(ctx) {
items = append(items, Item{Kind: KindSession, Name: s}) items = append(items, Item{Kind: KindSession, Name: s})
} }
// Scan repos concurrently for _, r := range scanGitReposConcurrent(cfg) {
repos := scanGitReposConcurrent(cfg)
for _, r := range repos {
items = append(items, Item{Kind: KindGitRepo, Name: sessionNameFromPath(r), Path: r}) items = append(items, Item{Kind: KindGitRepo, Name: sessionNameFromPath(r), Path: r})
} }
// Bookmarks
for _, b := range cfg.Bookmarks { for _, b := range cfg.Bookmarks {
if p, ok := expandPath(b); ok { if p, ok := expandPath(b); ok {
items = append(items, Item{Kind: KindBookmark, Name: sessionNameFromPath(p), Path: p}) 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{}{} seen := map[string]struct{}{}
var uniq []Item var uniq []Item
for _, it := range items { for _, it := range items {
@@ -638,28 +599,80 @@ func main() {
seen[key] = struct{}{} seen[key] = struct{}{}
uniq = append(uniq, it) 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 { for _, it := range items {
fmt.Printf("%s\t%s\t%s\n", it.Kind, it.Name, it.Path) fmt.Printf("%s\t%s\t%s\n", it.Kind, it.Name, it.Path)
} }
return return nil
} }
if len(items) == 0 { if len(items) == 0 {
fmt.Fprintln(os.Stderr, "no candidates") return errors.New("no candidates")
return
} }
selected, err := interactiveSelect(items) selected, err := interactiveSelect(items)
if err != nil { if err != nil {
return return err
} }
inTmux := isInTmux() inTmux := isInTmux()
switch selected.Kind { switch selected.Kind {
case KindSession: case KindSession:
_ = switchToSession(ctx, selected.Name, inTmux) return switchToSession(ctx, selected.Name, inTmux)
case KindGitRepo, KindBookmark: 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)
} }
} }