diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82f0815 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/src/tui +/dist +/bin diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d0540d --- /dev/null +++ b/README.md @@ -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) diff --git a/config.nims b/config.nims new file mode 100644 index 0000000..59eb228 --- /dev/null +++ b/config.nims @@ -0,0 +1,2 @@ +task debugTui, "debug tui": + exec "nim -d:debug c -r src/tui.nim" diff --git a/src/project.nim b/src/project.nim new file mode 100644 index 0000000..379d2f4 --- /dev/null +++ b/src/project.nim @@ -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" + + diff --git a/src/tsm.nim b/src/tsm.nim new file mode 100644 index 0000000..3ea129f --- /dev/null +++ b/src/tsm.nim @@ -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'}, + ) diff --git a/src/tui.nim b/src/tui.nim new file mode 100644 index 0000000..ad5044e --- /dev/null +++ b/src/tui.nim @@ -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 + + diff --git a/tsm.nimble b/tsm.nimble new file mode 100644 index 0000000..1d58648 --- /dev/null +++ b/tsm.nimble @@ -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 + + +