Add list contexts, refactor display logics.

* Refactor formatting logic to better calculate needed padding between
  issues and sections.
* Add `list contexts` command to list all known contexts according to
  the contexts configuration and the contexts defined in issues.
* Be more intentional about when the default context is used. Don't
  override existing context values in issues when changing their state.
* `edit` now allows multiple issues to be edited.
* Change single-issue display to be more verbose, listing all the
  properties and tags on an issue.
This commit is contained in:
Jonathan Bernard 2018-05-29 14:20:40 -05:00
parent 29959a6a8d
commit 2b5f82203c
3 changed files with 122 additions and 76 deletions

View File

@ -11,5 +11,5 @@ bin = @["pit", "pit_api"]
# Dependencies # Dependencies
requires @[ "nim >= 0.18.0", "cliutils 0.3.5", "docopt 0.6.5", "jester 0.2.0", requires @[ "nim >= 0.18.0", "cliutils 0.4.1", "docopt 0.6.5", "jester 0.2.0",
"timeutils 0.3.0", "uuids 0.1.9" ] "timeutils 0.3.0", "uuids 0.1.9" ]

View File

@ -12,12 +12,13 @@ include "pitpkg/private/version.nim"
type type
CliContext = ref object CliContext = ref object
autoList, triggerPtk, verbose: bool
cfg*: PitConfig cfg*: PitConfig
contexts*: TableRef[string, string] contexts*: TableRef[string, string]
defaultContext*, tasksDir*: string defaultContext*: Option[string]
tasksDir*: string
issues*: TableRef[IssueState, seq[Issue]] issues*: TableRef[IssueState, seq[Issue]]
termWidth*: int termWidth*: int
triggerPtk*, verbose*: bool
proc initContext(args: Table[string, Value]): CliContext = proc initContext(args: Table[string, Value]): CliContext =
let pitCfg = loadConfig(args) let pitCfg = loadConfig(args)
@ -29,9 +30,10 @@ proc initContext(args: Table[string, Value]): CliContext =
let cliCfg = CombinedConfig(docopt: args, json: cliJson) let cliCfg = CombinedConfig(docopt: args, json: cliJson)
result = CliContext( result = CliContext(
autoList: cliJson.getOrDefault("autoList").getBool(false),
contexts: pitCfg.contexts, contexts: pitCfg.contexts,
defaultContext: cliJson.getOrDefault("defaultContext").getStr(""), defaultContext:
if not cliJson.hasKey("defaultContext"): none(string)
else: some(cliJson["defaultContext"].getStr()),
verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"], verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"],
issues: newTable[IssueState, seq[Issue]](), issues: newTable[IssueState, seq[Issue]](),
tasksDir: pitCfg.tasksDir, tasksDir: pitCfg.tasksDir,
@ -44,77 +46,93 @@ proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
else: return context.capitalize() else: return context.capitalize()
return ctx.contexts[context] return ctx.contexts[context]
proc writeIssue(ctx: CliContext, issue: Issue, width: int, indent = "", proc formatIssue(ctx: CliContext, issue: Issue): string =
verbose = false, topPadded = false) = result = ($issue.id).withColor(fgBlack, true) & "\n"&
var showDetails = not issue.details.isNilOrWhitespace and verbose issue.summary.withColor(fgWhite) & "\n"
if showDetails and not topPadded: stdout.writeLine("") if issue.tags.len > 0:
result &= "tags: ".withColor(fgMagenta) &
issue.tags.join(",").withColor(fgGreen, true) & "\n"
if issue.properties.len > 0:
result &= termColor(fgMagenta)
for k, v in issue.properties: result &= k & ": " & v & "\n"
result &= "--------".withColor(fgBlack, true) & "\n"
if not issue.details.isNilOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n"
proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
verbose = false): string =
result = ""
var showDetails = not issue.details.isNilOrWhitespace and verbose
# Wrap and write the summary. # Wrap and write the summary.
var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len) var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len)
wrappedSummary = wrappedSummary[(6 + indent.len)..^1] wrappedSummary = wrappedSummary[(6 + indent.len)..^1]
stdout.setForegroundColor(fgBlack, true)
stdout.write(indent & ($issue.id)[0..<6]) result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true)
stdout.setForegroundColor(fgWhite, false) result &= wrappedSummary.withColor(fgWhite)
stdout.write(wrappedSummary)
if issue.tags.len > 0: if issue.tags.len > 0:
stdout.setForegroundColor(fgGreen, false) let tagsStr = "(" & issue.tags.join(", ") & ")"
let tagsStr = "(" & issue.tags.join(",") & ")" if (result.splitLines[^1].len + tagsStr.len + 1) > (width - 2):
if (wrappedSummary.splitLines[^1].len + tagsStr.len + 1) < (width - 2): result &= "\n" & indent
stdout.writeLine(" " & tagsStr) result &= " " & tagsStr.withColor(fgGreen)
else:
stdout.writeLine("\n" & indent & " " & tagsStr)
else: stdout.writeLine("")
if issue.hasProp("pending"): if issue.hasProp("pending"):
let startIdx = "Pending: ".len let startIdx = "Pending: ".len
var pendingText = issue["pending"].wordWrap(width - startIdx - 2) var pendingText = issue["pending"].wordWrap(width - startIdx - 2)
.indent(startIdx) .indent(startIdx)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2) pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
stdout.setForegroundColor(fgCyan, false) result &= "\n" & pendingText.withColor(fgCyan)
stdout.writeLine(pendingText)
if showDetails: if showDetails:
stdout.setForegroundColor(fgCyan, false) result &= "\n" & issue.details.strip.indent(indent.len + 2).withColor(fgCyan)
stdout.writeLine(issue.details.indent(indent.len + 2))
stdout.resetAttributes result &= termReset
proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState, proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int,
indent = "", verbose = false) = indent: string, verbose: bool): string =
result = ""
var topPadded = true
for i in issues:
var issueText = ctx.formatSectionIssue(i, width, indent, verbose)
if issueText.splitLines.len > 1:
if topPadded: result &= issueText & "\n\n"
else: result &= "\n" & issueText & "\n\n"
topPadded = true
else:
result &= issueText & "\n"
topPadded = false
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false): string =
let innerWidth = ctx.termWidth - (indent.len * 2) let innerWidth = ctx.termWidth - (indent.len * 2)
stdout.setForegroundColor(fgBlue, true) result = termColor(fgBlue) &
stdout.writeLine(indent & ".".repeat(innerWidth)) (indent & ".".repeat(innerWidth)) & "\n" &
stdout.writeLine(state.displayName.center(ctx.termWidth)) state.displayName.center(ctx.termWidth) & "\n\n" &
stdout.writeLine("") termReset
stdout.resetAttributes
let issuesByContext = issues.groupBy("context") let issuesByContext = issues.groupBy("context")
var topPadded = true
if issues.len > 5 and issuesByContext.len > 1: if issues.len > 5 and issuesByContext.len > 1:
for context, ctxIssues in issuesByContext: for context, ctxIssues in issuesByContext:
topPadded = true
stdout.setForegroundColor(fgYellow, false)
stdout.writeLine(indent & ctx.getIssueContextDisplayName(context) & ":")
stdout.writeLine("")
stdout.resetAttributes
for i in ctxIssues: result &= termColor(fgYellow) &
ctx.writeIssue(i, innerWidth - 2, indent & " ", verbose, topPadded) indent & ctx.getIssueContextDisplayName(context) & ":" &
topPadded = not i.details.isNilOrWhitespace and verbose termReset & "\n\n"
if not topPadded: stdout.writeLine("") result &= ctx.formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
result &= "\n"
else: else: result &= ctx.formatSectionIssueList(issues, innerWidth, indent, verbose)
for i in issues:
ctx.writeIssue(i, innerWidth, indent, verbose, topPadded)
topPadded = not i.details.isNilOrWhitespace and verbose
stdout.writeLine("")
proc loadIssues(ctx: CliContext, state: IssueState) = proc loadIssues(ctx: CliContext, state: IssueState) =
ctx.issues[state] = loadIssues(ctx.tasksDir / $state) ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
@ -169,7 +187,7 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
if state.isSome: if state.isSome:
ctx.loadIssues(state.get) ctx.loadIssues(state.get)
if filter.isSome: ctx.filterIssues(filter.get) if filter.isSome: ctx.filterIssues(filter.get)
ctx.writeSection(ctx.issues[state.get], state.get, "", verbose) stdout.write ctx.formatSection(ctx.issues[state.get], state.get, "", verbose)
return return
ctx.loadAllIssues() ctx.loadAllIssues()
@ -183,14 +201,14 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
for s in [Current, TodoToday]: for s in [Current, TodoToday]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
ctx.writeSection(ctx.issues[s], s, indent, verbose) stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
if ctx.issues.hasKey(Done): if ctx.issues.hasKey(Done):
let doneIssues = ctx.issues[Done].filterIt( let doneIssues = ctx.issues[Done].filterIt(
it.hasProp("completed") and it.hasProp("completed") and
sameDay(getTime().local, it.getDateTime("completed"))) sameDay(getTime().local, it.getDateTime("completed")))
if doneIssues.len > 0: if doneIssues.len > 0:
ctx.writeSection(doneIssues, Done, indent, verbose) stdout.write ctx.formatSection(doneIssues, Done, indent, verbose)
# Future items # Future items
if future: if future:
@ -198,7 +216,7 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
for s in [Pending, Todo]: for s in [Pending, Todo]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
ctx.writeSection(ctx.issues[s], s, indent, verbose) stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
when isMainModule: when isMainModule:
@ -207,8 +225,8 @@ when isMainModule:
Usage: Usage:
pit ( new | add) <summary> [<state>] [options] pit ( new | add) <summary> [<state>] [options]
pit list [<listable>] [options] pit list [<listable>] [options]
pit ( start | done | pending | do-today | todo | suspend ) <id>... [options] pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <id> pit edit <id>...
pit ( delete | rm ) <id>... pit ( delete | rm ) <id>...
Options: Options:
@ -258,22 +276,20 @@ Options:
let ctx = initContext(args) let ctx = initContext(args)
var propertiesOption = none(TableRef[string,string]) var propertiesOption = none(TableRef[string,string])
var tagsOption = none(seq[string])
if args["--properties"] or args["--context"] or if args["--properties"] or args["--context"]:
not ctx.defaultContext.isNilOrWhitespace:
var props = var props =
if args["--properties"]: parsePropertiesOption($args["--properties"]) if args["--properties"]: parsePropertiesOption($args["--properties"])
else: newTable[string,string]() else: newTable[string,string]()
if args["--context"] and $args["--context"] != "all": if args["--context"]: props["context"] = $args["--context"]
props["context"] = $args["--context"]
elif not args["--context"] and not ctx.defaultContext.isNilOrWhitespace:
stderr.writeLine("Limiting to default context: " & ctx.defaultContext)
props["context"] = ctx.defaultContext
propertiesOption = some(props) propertiesOption = some(props)
if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip))
## Actual command runners ## Actual command runners
if args["new"] or args["add"]: if args["new"] or args["add"]:
let state = let state =
@ -282,6 +298,9 @@ Options:
var issueProps = propertiesOption.get(newTable[string,string]()) var issueProps = propertiesOption.get(newTable[string,string]())
if not issueProps.hasKey("created"): issueProps["created"] = getTime().local.formatIso8601 if not issueProps.hasKey("created"): issueProps["created"] = getTime().local.formatIso8601
if not issueProps.hasKey("context") and ctx.defaultContext.isSome():
stderr.writeLine("Using default context: " & ctx.defaultContext.get)
issueProps["context"] = ctx.defaultContext.get
var issue = Issue( var issue = Issue(
id: genUUID(), id: genUUID(),
@ -293,17 +312,18 @@ Options:
ctx.tasksDir.store(issue, state) ctx.tasksDir.store(issue, state)
stdout.writeLine ctx.formatIssue(issue)
elif args["edit"]: elif args["edit"]:
let issueId = $args["<id>"] for id in @(args["<id>"]):
edit(ctx.tasksDir.loadIssueById(id))
edit(ctx.tasksDir.loadIssueById(issueId)) elif args["start"] or args["todo-today"] or args["done"] or
elif args["start"] or args["do-today"] or args["done"] or
args["pending"] or args["todo"] or args["suspend"]: args["pending"] or args["todo"] or args["suspend"]:
var targetState: IssueState var targetState: IssueState
if args["done"]: targetState = Done if args["done"]: targetState = Done
elif args["do-today"]: targetState = TodoToday elif args["todo-today"]: targetState = TodoToday
elif args["pending"]: targetState = Pending elif args["pending"]: targetState = Pending
elif args["start"]: targetState = Current elif args["start"]: targetState = Current
elif args["todo"]: targetState = Todo elif args["todo"]: targetState = Todo
@ -314,6 +334,7 @@ Options:
if propertiesOption.isSome: if propertiesOption.isSome:
for k,v in propertiesOption.get: for k,v in propertiesOption.get:
issue[k] = v issue[k] = v
if targetState == Done: issue["completed"] = getTime().local.formatIso8601
issue.changeState(ctx.tasksDir, targetState) issue.changeState(ctx.tasksDir, targetState)
if ctx.triggerPtk: if ctx.triggerPtk:
@ -342,20 +363,49 @@ Options:
let filter = initFilter() let filter = initFilter()
var filterOption = none(IssueFilter) var filterOption = none(IssueFilter)
# Initialize filter with properties (if given)
if propertiesOption.isSome: if propertiesOption.isSome:
filter.properties = propertiesOption.get filter.properties = propertiesOption.get
filterOption = some(filter) filterOption = some(filter)
# If no "context" property is given, use the default (if we have one)
if ctx.defaultContext.isSome and not filter.properties.hasKey("context"):
stderr.writeLine("Limiting to default context: " & ctx.defaultContext.get)
filter.properties["context"] = ctx.defaultContext.get
filterOption = some(filter)
# Finally, if the "context" is "all", don't filter on context
if filter.properties.hasKey("context") and
filter.properties["context"] == "all":
filter.properties.del("context")
var listContexts = false
var stateOption = none(IssueState) var stateOption = none(IssueState)
var issueIdOption = none(string) var issueIdOption = none(string)
if args["<listable>"]: if args["<listable>"]:
try: stateOption = some(parseEnum[IssueState]($args["<listable>"])) if $args["<listable>"] == "contexts": listContexts = true
except: issueIdOption = some($args["<listable>"]) else:
try: stateOption = some(parseEnum[IssueState]($args["<listable>"]))
except: issueIdOption = some($args["<listable>"])
# List the known contexts
if listContexts:
var uniqContexts = toSeq(ctx.contexts.keys)
ctx.loadAllIssues()
for state, issueList in ctx.issues:
for issue in issueList:
if issue.hasProp("context") and not uniqContexts.contains(issue["context"]):
uniqContexts.add(issue["context"])
for c in uniqContexts: stdout.writeLine(c)
# List a specific issue # List a specific issue
if issueIdOption.isSome: elif issueIdOption.isSome:
let issue = ctx.tasksDir.loadIssueById(issueIdOption.get) let issue = ctx.tasksDir.loadIssueById(issueIdOption.get)
ctx.writeIssue(issue, ctx.termWidth, "", true, true) stdout.writeLine ctx.formatIssue(issue)
# List all issues # List all issues
else: else:
@ -364,10 +414,6 @@ Options:
showBoth or args["--future"], showBoth or args["--future"],
ctx.verbose) ctx.verbose)
if ctx.autoList and not args["list"]:
ctx.loadAllIssues()
ctx.list(none(IssueFilter), none(IssueState), true, true, false)
except: except:
fatal "pit: " & getCurrentExceptionMsg() fatal "pit: " & getCurrentExceptionMsg()
#raise getCurrentException() #raise getCurrentException()

View File

@ -1 +1 @@
const PIT_VERSION = "4.1.0" const PIT_VERSION = "4.2.0"