mirror of
https://github.com/daylinmorgan/tsm.git
synced 2024-12-22 13:10:43 -06:00
initial mvp
This commit is contained in:
parent
f406d70143
commit
92b5f056c9
7 changed files with 468 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/src/tui
|
||||||
|
/dist
|
||||||
|
/bin
|
40
README.md
Normal file
40
README.md
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
# Tmux session manager (tsm)
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
There are pre-built binaries available in [Releases](https://github.com/daylinmorgan/tsm/releases/) including a [nightly](https://github.com/daylinmorgan/tsm/releases/tag/nightly) release.
|
||||||
|
|
||||||
|
w/`eget`:
|
||||||
|
```sh
|
||||||
|
eget daylinmorgan/tsm
|
||||||
|
eget daylinmorgan/tsm --pre-release # for nightly build
|
||||||
|
```
|
||||||
|
|
||||||
|
w/`nimble`:
|
||||||
|
```sh
|
||||||
|
nimble install https://github.com/daylinmorgan/tsm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To configure `tsm` export the environment variable `TSM_DIRS`, with a colon-delimited set of parent directories to find projects.
|
||||||
|
|
||||||
|
For example in your rc file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export TSM_DIRS="$HOME/projects/personal:$HOME/projects/work"
|
||||||
|
```
|
||||||
|
|
||||||
|
To make full use of `tsm` you should also add a new key binding to your `tmux.conf`.
|
||||||
|
For example you can bind the s key to show a popup with `tsm`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bind s display-popup \
|
||||||
|
-h 60% -w 60% \
|
||||||
|
-B -e FZF_DEFAULT_OPTS="${FZF_DEFAULT_OPTS} --height=100%" \
|
||||||
|
-E "tsm"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prior Art
|
||||||
|
|
||||||
|
- [ThePrimeagen](https://github.com/ThePrimeagen/.dotfiles/blob/master/bin/.local/scripts/tmux-sessionizer)
|
2
config.nims
Normal file
2
config.nims
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
task debugTui, "debug tui":
|
||||||
|
exec "nim -d:debug c -r src/tui.nim"
|
62
src/project.nim
Normal file
62
src/project.nim
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import std/[algorithm, os, osproc, strutils, tables, times]
|
||||||
|
|
||||||
|
|
||||||
|
proc listTmuxSessions*(): seq[string] =
|
||||||
|
let (output, _) = execCmdEx("tmux list-sessions -F '#S'")
|
||||||
|
return output.splitLines()
|
||||||
|
|
||||||
|
|
||||||
|
type
|
||||||
|
Project* = object
|
||||||
|
location*: string
|
||||||
|
updated*: Time
|
||||||
|
open*: bool
|
||||||
|
matched*: bool
|
||||||
|
|
||||||
|
proc newProject(path: string, sessions: seq[string]): Project =
|
||||||
|
result.location = path
|
||||||
|
result.updated = getLastModificationTime(path)
|
||||||
|
result.open = splitPath(path)[1].replace(".", "_") in sessions
|
||||||
|
|
||||||
|
proc name*(p: Project): string = splitPath(p.location)[1].replace(".", "_")
|
||||||
|
|
||||||
|
|
||||||
|
proc findProjects*(open: bool = false): tuple[header: string,
|
||||||
|
projects: OrderedTable[string, Project]] =
|
||||||
|
## get a table of possible project paths
|
||||||
|
|
||||||
|
let
|
||||||
|
tsmDirs = getEnv("TSM_DIRS")
|
||||||
|
sessions = listTmuxSessions()
|
||||||
|
|
||||||
|
if tsmDirs == "":
|
||||||
|
echo "Please set $TSM_DIRS to a colon-delimited list of paths"
|
||||||
|
quit 1
|
||||||
|
|
||||||
|
var projects: seq[Project]
|
||||||
|
for devDir in tsmDirs.split(":"):
|
||||||
|
for d in walkDir(devDir):
|
||||||
|
let p = newProject(d.path, sessions)
|
||||||
|
if open and p.open: projects.add p
|
||||||
|
else:
|
||||||
|
projects.add p
|
||||||
|
|
||||||
|
if len(projects) == 0:
|
||||||
|
echo "nothing to select"
|
||||||
|
quit 1
|
||||||
|
|
||||||
|
# TODO: use the input as a first filter?
|
||||||
|
|
||||||
|
# favor open projects then by update time
|
||||||
|
projects.sort do (x, y: Project) -> int:
|
||||||
|
result = cmp(y.open, x.open)
|
||||||
|
if result == 0:
|
||||||
|
result = cmp(y.updated, x.updated)
|
||||||
|
|
||||||
|
for p in projects:
|
||||||
|
result.projects[p.name] = p
|
||||||
|
|
||||||
|
if len(result.projects) != len(projects):
|
||||||
|
echo "there may be nonunique entries in the project names"
|
||||||
|
|
||||||
|
|
42
src/tsm.nim
Normal file
42
src/tsm.nim
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import std/[os, osproc, strformat, tables]
|
||||||
|
|
||||||
|
import tui, project
|
||||||
|
|
||||||
|
proc checkExe(names: varargs[string]) =
|
||||||
|
for name in names:
|
||||||
|
if findExe(name) == "":
|
||||||
|
echo "tsm requires " & name
|
||||||
|
|
||||||
|
proc tsm() =
|
||||||
|
checkExe "tmux"
|
||||||
|
|
||||||
|
let
|
||||||
|
project = selectProject()
|
||||||
|
selected = project.name
|
||||||
|
|
||||||
|
# TODO: refactor
|
||||||
|
if existsEnv("TMUX"):
|
||||||
|
if selected notin listTmuxSessions():
|
||||||
|
discard execCmd(&"tmux new-session -d -s {selected} -c {project.location}")
|
||||||
|
discard execCmd(&"tmux switch-client -t {selected}")
|
||||||
|
else:
|
||||||
|
if selected notin listTmuxSessions():
|
||||||
|
discard execCmd(&"tmux new-session -s {selected} -c {project.location}")
|
||||||
|
else:
|
||||||
|
discard execCmd(&"tmux attach -t {selected}")
|
||||||
|
|
||||||
|
when isMainModule:
|
||||||
|
import cligen
|
||||||
|
const vsn = staticExec "git describe --tags --always HEAD"
|
||||||
|
clCfg.version = vsn
|
||||||
|
|
||||||
|
if clCfg.helpAttr.len == 0:
|
||||||
|
clCfg.helpAttr = {"cmd": "\e[1;36m", "clDescrip": "", "clDflVal": "\e[33m",
|
||||||
|
"clOptKeys": "\e[32m", "clValType": "\e[31m", "args": "\e[3m"}.toTable
|
||||||
|
clCfg.helpAttrOff = {"cmd": "\e[m", "clDescrip": "\e[m", "clDflVal": "\e[m",
|
||||||
|
"clOptKeys": "\e[m", "clValType": "\e[m", "args": "\e[m"}.toTable
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
tsm,
|
||||||
|
short = {"version": 'v'},
|
||||||
|
)
|
268
src/tui.nim
Normal file
268
src/tui.nim
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
import std/[enumerate, os, sequtils, strformat, strutils, tables]
|
||||||
|
|
||||||
|
import illwill
|
||||||
|
import project
|
||||||
|
|
||||||
|
proc quitProc() {.noconv.} =
|
||||||
|
illwillDeinit()
|
||||||
|
showCursor()
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
proc exitProc() {.noconv.} =
|
||||||
|
illwillDeinit()
|
||||||
|
showCursor()
|
||||||
|
|
||||||
|
template withfgColor(ForegroundColor, body: untyped) =
|
||||||
|
var tb = state.buffer
|
||||||
|
tb.setForegroundColor(ForegroundColor, bright = true)
|
||||||
|
body
|
||||||
|
tb.setForegroundColor(fgWhite, bright = true)
|
||||||
|
|
||||||
|
type
|
||||||
|
Coord = object
|
||||||
|
x1, x2, y1, y2: int
|
||||||
|
|
||||||
|
Cursor = object
|
||||||
|
min, max, y: Natural
|
||||||
|
|
||||||
|
Window = object
|
||||||
|
coord: Coord
|
||||||
|
tooSmall: bool
|
||||||
|
|
||||||
|
State = object
|
||||||
|
buffer: TerminalBuffer
|
||||||
|
lastKey: Key
|
||||||
|
input: string
|
||||||
|
window: Window
|
||||||
|
cursor: Cursor
|
||||||
|
projectIdx: Natural
|
||||||
|
|
||||||
|
# TODO: don't need top level projects
|
||||||
|
let (_, projects) = findProjects()
|
||||||
|
var state = State()
|
||||||
|
|
||||||
|
proc values(c: Coord): (int, int, int, int) = (c.x1, c.x2, c.y1, c.y2)
|
||||||
|
|
||||||
|
proc height(w: Window): int = w.coord.y2 - (w.coord.y1)
|
||||||
|
proc width(w: Window): int = return w.coord.x2-w.coord.x1
|
||||||
|
|
||||||
|
proc scrollUp() =
|
||||||
|
if state.projectIdx > 0:
|
||||||
|
dec state.projectIdx
|
||||||
|
|
||||||
|
proc scrollDown() =
|
||||||
|
if (projects.len - state.projectIdx) > state.window.height + 1:
|
||||||
|
inc state.projectIdx
|
||||||
|
|
||||||
|
proc up() =
|
||||||
|
if state.cursor.y > state.cursor.min:
|
||||||
|
dec state.cursor.y
|
||||||
|
elif state.cursor.y == state.cursor.min:
|
||||||
|
scrollUp()
|
||||||
|
|
||||||
|
proc down() =
|
||||||
|
if state.cursor.y < state.cursor.max:
|
||||||
|
inc state.cursor.y
|
||||||
|
elif state.cursor.y == state.cursor.max:
|
||||||
|
scrollDown()
|
||||||
|
|
||||||
|
proc backspace(s: string): string =
|
||||||
|
if s != "": result = s[0..^2]
|
||||||
|
|
||||||
|
func toStr(k: Key): string = $chr(ord(k))
|
||||||
|
|
||||||
|
proc match(project: Project): Project =
|
||||||
|
result = project
|
||||||
|
result.matched = true
|
||||||
|
|
||||||
|
# TODO: convert this into a proper sorter
|
||||||
|
proc sortProjects(): seq[Project] =
|
||||||
|
|
||||||
|
var
|
||||||
|
priority: seq[Project]
|
||||||
|
rest: seq[Project]
|
||||||
|
|
||||||
|
if state.input != "":
|
||||||
|
for name, project in projects:
|
||||||
|
if project.name.startsWith(state.input):
|
||||||
|
priority &= project.match()
|
||||||
|
else:
|
||||||
|
rest &= project
|
||||||
|
return priority & rest
|
||||||
|
else:
|
||||||
|
return projects.values().toSeq()
|
||||||
|
|
||||||
|
proc getProject(): Project =
|
||||||
|
let projects = sortProjects()
|
||||||
|
var idx = state.cursor.y - state.cursor.min + state.projectIdx
|
||||||
|
return projects[idx]
|
||||||
|
|
||||||
|
|
||||||
|
proc clip(s: string): string =
|
||||||
|
let maxWidth = state.window.width - 2
|
||||||
|
result = if s.len > maxWidth:
|
||||||
|
s[0..^state.window.width]
|
||||||
|
else: s
|
||||||
|
|
||||||
|
proc displayProject(tb: var TerminalBuffer, x, y: int, project: Project) =
|
||||||
|
let
|
||||||
|
name = project.name.clip
|
||||||
|
input = state.input.clip
|
||||||
|
projectColor = if project.open: fgYellow else: fgWhite
|
||||||
|
|
||||||
|
if project.matched:
|
||||||
|
withfgColor fgRed:
|
||||||
|
tb.write(x, y, name)
|
||||||
|
withfgColor projectColor:
|
||||||
|
tb.write(x+input.len, y, name[input.len..^1])
|
||||||
|
else:
|
||||||
|
withfgColor projectColor:
|
||||||
|
tb.write(x, y, name)
|
||||||
|
|
||||||
|
proc displayProjects(tx, ty: int) =
|
||||||
|
let projects = sortProjects()
|
||||||
|
var
|
||||||
|
line = ty + 2
|
||||||
|
tb = state.buffer
|
||||||
|
|
||||||
|
for (i, project) in enumerate(projects):
|
||||||
|
if i < state.projectIdx:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tb.displayProject(tx, line, project)
|
||||||
|
if line > state.window.coord.y2-2: break
|
||||||
|
inc line
|
||||||
|
|
||||||
|
tb.write(tx-2, state.cursor.y, "> ")
|
||||||
|
|
||||||
|
|
||||||
|
when defined(debug):
|
||||||
|
proc `$`(c: Coord): string = &"(x1:{c.x1}, x2: {c.x2}, y1: {c.y1}, y2: {c.y2})"
|
||||||
|
proc debugInfo() =
|
||||||
|
var tb = state.buffer
|
||||||
|
let
|
||||||
|
(x, y) = (2, 1)
|
||||||
|
lines = @[
|
||||||
|
&"heights -> buffer: {tb.height}, window: {state.window.height}",
|
||||||
|
&"widths -> buffer: {tb.width}, window: {state.window.width}",
|
||||||
|
"project: " & getProject().name,
|
||||||
|
"state:",
|
||||||
|
"| last key -> " & $state.lastKey,
|
||||||
|
"| cursor -> " & "y:" & $state.cursor.y,
|
||||||
|
"| projectIdx -> " & $state.projectIdx,
|
||||||
|
"| window -> " & $state.window.coord,
|
||||||
|
]
|
||||||
|
for i, line in lines:
|
||||||
|
tb.write(x, y+i, line)
|
||||||
|
|
||||||
|
|
||||||
|
proc draw() =
|
||||||
|
var
|
||||||
|
tb = state.buffer
|
||||||
|
input = state.input
|
||||||
|
|
||||||
|
tb.setForegroundColor(fgWhite, bright = true)
|
||||||
|
|
||||||
|
let
|
||||||
|
(x1, x2, y1, _) = state.window.coord.values()
|
||||||
|
maxWidth = x2 - x1 - 4
|
||||||
|
|
||||||
|
when defined(debug):
|
||||||
|
debugInfo()
|
||||||
|
withfgColor fgRed:
|
||||||
|
tb.drawRect(x1, y1, x2, y2)
|
||||||
|
|
||||||
|
tb.drawHorizLine(x1+1, x2-1, y1+2)
|
||||||
|
|
||||||
|
if input.len > maxWidth:
|
||||||
|
input = "..." & input[^(maxWidth-3)..^1]
|
||||||
|
|
||||||
|
tb.write(x1+1, y1+1, "$ " & input)
|
||||||
|
displayProjects(x1+3, y1+1)
|
||||||
|
tb.display()
|
||||||
|
|
||||||
|
proc reset() = state.cursor.y = state.cursor.min
|
||||||
|
|
||||||
|
proc update(c: var Cursor, min, max: Natural) =
|
||||||
|
c.min = min
|
||||||
|
c.max = max
|
||||||
|
if c.y > max: c.y = max
|
||||||
|
elif c.y < min: c.y = min
|
||||||
|
|
||||||
|
proc getCoords(): Coord =
|
||||||
|
var width, height: Natural
|
||||||
|
let (termWidth, termHeight) = terminalSize()
|
||||||
|
|
||||||
|
|
||||||
|
width = if termWidth > 65: 65 else: termWidth
|
||||||
|
height = if termHeight > 20: 20 else: termHeight
|
||||||
|
|
||||||
|
|
||||||
|
# fullscreen type behavior
|
||||||
|
result.x1 = ((termWidth - width)/2).int
|
||||||
|
result.y1 = ((termHeight - height)/2).int
|
||||||
|
|
||||||
|
|
||||||
|
result.x2 = result.x1 + width
|
||||||
|
result.y2 = result.y1 + height
|
||||||
|
|
||||||
|
proc drawSizeWarning(tb: var TerminalBuffer) =
|
||||||
|
let (termWidth, termHeight) = terminalSize()
|
||||||
|
withfgColor fgRed:
|
||||||
|
tb.write(0, 0, "window is too small")
|
||||||
|
withfgColor fgYellow:
|
||||||
|
tb.write(0, 1, &"WxH: {termWidth}x{termHeight}")
|
||||||
|
tb.write(0, 2, "need 15x10")
|
||||||
|
tb.display()
|
||||||
|
|
||||||
|
proc newWindow(): Window =
|
||||||
|
state.buffer = newTerminalBuffer(terminalWidth(), terminalHeight())
|
||||||
|
result.coord = getCoords()
|
||||||
|
state.cursor.update(min = result.coord.y1+3, max = result.coord.y2-1)
|
||||||
|
result.tooSmall = (result.width < 15 or result.height < 10)
|
||||||
|
|
||||||
|
proc selectProject*(): Project =
|
||||||
|
|
||||||
|
illwillInit(fullscreen = true)
|
||||||
|
setControlCHook(quitProc)
|
||||||
|
hideCursor()
|
||||||
|
|
||||||
|
while true:
|
||||||
|
state.window = newWindow()
|
||||||
|
if state.window.tooSmall:
|
||||||
|
state.buffer.drawSizeWarning()
|
||||||
|
continue
|
||||||
|
var key = getKey()
|
||||||
|
case key
|
||||||
|
of Key.None: discard
|
||||||
|
of Key.Escape: quitProc()
|
||||||
|
of Key.Enter:
|
||||||
|
exitProc()
|
||||||
|
return getProject()
|
||||||
|
of Key.Up:
|
||||||
|
up()
|
||||||
|
of Key.Down:
|
||||||
|
down()
|
||||||
|
of Key.CtrlA..Key.CtrlL, Key.CtrlN..Key.CtrlZ, Key.CtrlRightBracket,
|
||||||
|
Key.CtrlBackslash, Key.Right..Key.F12:
|
||||||
|
state.lastKey = key
|
||||||
|
else:
|
||||||
|
state.lastKey = key
|
||||||
|
reset()
|
||||||
|
case key
|
||||||
|
of Key.Backspace:
|
||||||
|
state.input = state.input.backspace
|
||||||
|
of Key.Space..Key.Z:
|
||||||
|
state.input &= key.toStr
|
||||||
|
else:
|
||||||
|
state.input &= $key
|
||||||
|
|
||||||
|
draw()
|
||||||
|
sleep(10)
|
||||||
|
|
||||||
|
|
||||||
|
when isMainModule:
|
||||||
|
let selected = selectProject()
|
||||||
|
echo "selected project -> " & $selected.name
|
||||||
|
|
||||||
|
|
51
tsm.nimble
Normal file
51
tsm.nimble
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
# Package
|
||||||
|
|
||||||
|
version = "2023.1001"
|
||||||
|
author = "Daylin Morgan"
|
||||||
|
description = "tmux session manager"
|
||||||
|
license = "MIT"
|
||||||
|
srcDir = "src"
|
||||||
|
bin = @["tsm"]
|
||||||
|
binDir = "bin"
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
|
||||||
|
requires "nim >= 1.6.12",
|
||||||
|
"illwill",
|
||||||
|
"cligen"
|
||||||
|
|
||||||
|
taskRequires "release", "https://github.com/daylinmorgan/ccnz"
|
||||||
|
|
||||||
|
import strformat
|
||||||
|
const targets = [
|
||||||
|
"x86_64-linux-gnu",
|
||||||
|
"x86_64-linux-musl",
|
||||||
|
"x86_64-macos-none",
|
||||||
|
# "x86_64-windows-gnu" # no tsm on windows
|
||||||
|
]
|
||||||
|
|
||||||
|
task release, "build release assets":
|
||||||
|
mkdir "dist"
|
||||||
|
for target in targets:
|
||||||
|
let
|
||||||
|
ext = if target == "x86_64-windows-gnu": ".cmd" else: ""
|
||||||
|
outdir = &"dist/{target}/"
|
||||||
|
app = projectName()
|
||||||
|
exec &"ccnz cc --target {target} --nimble -- --out:{outdir}{app}{ext} -d:release src/{app}"
|
||||||
|
|
||||||
|
task bundle, "package build assets":
|
||||||
|
cd "dist"
|
||||||
|
for target in targets:
|
||||||
|
let
|
||||||
|
app = projectName()
|
||||||
|
cmd =
|
||||||
|
if target == "x86_64-windows-gnu":
|
||||||
|
&"7z a {app}-v{version}-{target}.zip {target}"
|
||||||
|
else:
|
||||||
|
&"tar czf {app}-v{version}-{target}.tar.gz {target}"
|
||||||
|
|
||||||
|
cpFile("../README.md", &"{target}/README.md")
|
||||||
|
exec cmd
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue