From 59a8edd1bbea8c45122410c8adc84fa8dc9da4ff Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Tue, 12 May 2026 14:50:42 +0200 Subject: [PATCH 1/4] tui updates --- bump-version.sh | 24 - crates/ticgit-lib/src/query.rs | 93 +- crates/ticgit-lib/src/store.rs | 54 +- crates/ticgit-lib/src/ticket.rs | 5 + crates/ticgit/src/agent_help.rs | 325 +-- crates/ticgit/src/cli.rs | 55 +- crates/ticgit/src/commands/history.rs | 62 +- crates/ticgit/src/commands/import.rs | 26 +- crates/ticgit/src/commands/list.rs | 36 +- crates/ticgit/src/commands/mod.rs | 11 +- crates/ticgit/src/commands/new.rs | 5 +- crates/ticgit/src/commands/next.rs | 15 +- crates/ticgit/src/commands/pull.rs | 57 +- crates/ticgit/src/commands/setup.rs | 7 +- crates/ticgit/src/commands/stats.rs | 65 +- crates/ticgit/src/commands/subissue.rs | 7 +- crates/ticgit/src/commands/sync.rs | 3 +- crates/ticgit/src/commands/tui.rs | 2687 +++++++++++++++++++++--- crates/ticgit/src/commands/update.rs | 4 +- crates/ticgit/src/commands/users.rs | 37 +- crates/ticgit/src/commands/view.rs | 25 +- crates/ticgit/src/main.rs | 14 - crates/ticgit/src/render.rs | 14 +- crates/ticgit/src/session_state.rs | 12 + crates/ticgit/tests/cli.rs | 28 +- docs/schema/v1.json | 8 + 26 files changed, 2885 insertions(+), 794 deletions(-) delete mode 100755 bump-version.sh diff --git a/bump-version.sh b/bump-version.sh deleted file mode 100755 index 6ebb1e10..00000000 --- a/bump-version.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [ $# -ne 1 ]; then - echo "Usage: $0 " - echo "Example: $0 0.2.0" - exit 1 -fi - -NEW="$1" -ROOT="$(cd "$(dirname "$0")" && pwd)" - -# Update workspace version in Cargo.toml -sed -i '' "s/^version = \".*\"/version = \"${NEW}\"/" "$ROOT/Cargo.toml" - -# Update version strings in docs/index.html -sed -i '' "s/ticgit [0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*/ticgit ${NEW}/g" "$ROOT/docs/index.html" -sed -i '' "s/>[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$NEW, pub state: Option, pub tag: Option, + pub tags: Vec, + pub tag_match_all: bool, pub assigned: Option, pub only_tagged: bool, pub search: Option, @@ -160,8 +162,14 @@ pub fn apply(tickets: Vec, filter: &Filter) -> Vec { return false; } } - if let Some(tag) = &filter.tag { - if !t.tags.contains(tag) { + let tags = filter_tags(filter); + if !tags.is_empty() { + let matches = if filter.tag_match_all { + tags.iter().all(|tag| t.tags.contains(*tag)) + } else { + tags.iter().any(|tag| t.tags.contains(*tag)) + }; + if !matches { return false; } } @@ -206,6 +214,19 @@ pub fn apply(tickets: Vec, filter: &Filter) -> Vec { tickets } +fn filter_tags(filter: &Filter) -> Vec<&String> { + let mut tags = Vec::new(); + if let Some(tag) = &filter.tag { + tags.push(tag); + } + for tag in &filter.tags { + if !tags.contains(&tag) { + tags.push(tag); + } + } + tags +} + fn contains(haystack: &str, needle: &str) -> bool { haystack.to_ascii_lowercase().contains(needle) } @@ -288,6 +309,7 @@ mod tests { status, state, assigned: assigned.map(String::from), + closed_by: None, priority: None, points: None, milestone: None, @@ -377,6 +399,73 @@ mod tests { assert_eq!(out[0].title, "b"); } + #[test] + fn filter_by_any_tag() { + let mut bug = t( + "bug", + TicketStatus::Open, + TicketState::New, + Some("bug"), + None, + 1, + ); + bug.tags.insert("cli".into()); + let ui = t( + "ui", + TicketStatus::Open, + TicketState::New, + Some("ui"), + None, + 2, + ); + let docs = t( + "docs", + TicketStatus::Open, + TicketState::New, + Some("docs"), + None, + 3, + ); + let f = Filter { + tags: vec!["bug".into(), "ui".into()], + tag_match_all: false, + ..Default::default() + }; + let out = apply(vec![bug, ui, docs], &f); + assert_eq!(out.len(), 2); + assert_eq!(out[0].title, "ui"); + assert_eq!(out[1].title, "bug"); + } + + #[test] + fn filter_by_all_tags() { + let mut both = t( + "both", + TicketStatus::Open, + TicketState::New, + Some("bug"), + None, + 1, + ); + both.tags.insert("ui".into()); + let bug = t( + "bug", + TicketStatus::Open, + TicketState::New, + Some("bug"), + None, + 2, + ); + let f = Filter { + tags: vec!["bug".into(), "ui".into()], + tag_match_all: true, + ..Default::default() + }; + let out = apply(vec![both, bug], &f); + assert_eq!(out.len(), 1); + assert_eq!(out[0].title, "both"); + } + #[test] fn filter_by_assigned() { let input = vec![ diff --git a/crates/ticgit-lib/src/store.rs b/crates/ticgit-lib/src/store.rs index 9887082b..11e029fd 100644 --- a/crates/ticgit-lib/src/store.rs +++ b/crates/ticgit-lib/src/store.rs @@ -21,7 +21,12 @@ use crate::ticket::{Comment, CommentBody, NewTicketOpts, Ticket, TicketState, Ti fn validate_email(email: &str) -> Result<()> { let email = email.trim(); let parts: Vec<&str> = email.split('@').collect(); - if parts.len() == 2 && !parts[0].is_empty() && parts[1].contains('.') && !parts[1].starts_with('.') && !parts[1].ends_with('.') { + if parts.len() == 2 + && !parts[0].is_empty() + && parts[1].contains('.') + && !parts[1].starts_with('.') + && !parts[1].ends_with('.') + { Ok(()) } else { Err(Error::InvalidValue(format!( @@ -119,10 +124,7 @@ impl TicketStore { parent_id.to_string().as_str(), )?; // Denormalize: add child to parent's children set - p.set_add( - &keys::ticket_field(&parent_id, "children"), - &id.to_string(), - )?; + p.set_add(&keys::ticket_field(&parent_id, "children"), &id.to_string())?; } if let Some(body) = opts.comment { @@ -263,6 +265,27 @@ impl TicketStore { let p = self.project_handle(); p.set(&keys::ticket_field(id, "status"), status.as_str())?; p.set(&keys::ticket_field(id, "state"), state.as_str())?; + if status == TicketStatus::Closed { + p.set(&keys::ticket_field(id, "closed-by"), self.session.email())?; + } else { + p.remove(&keys::ticket_field(id, "closed-by"))?; + } + Ok(()) + } + + pub fn set_closed_by(&self, id: &Uuid, who: Option<&str>) -> Result<()> { + let p = self.project_handle(); + let key = keys::ticket_field(id, "closed-by"); + match who { + Some(w) if !w.is_empty() => { + let resolved = self.resolve_user(w)?; + validate_email(&resolved)?; + p.set(&key, resolved.as_str())?; + } + _ => { + p.remove(&key)?; + } + } Ok(()) } @@ -727,6 +750,7 @@ fn build_ticket(id: Uuid, fields: Vec<(String, MetaValue)>) -> Option { let mut legacy_status: Option = None; let mut legacy_state: Option = None; let mut assigned: Option = None; + let mut closed_by: Option = None; let mut priority: Option = None; let mut points: Option = None; let mut milestone: Option = None; @@ -771,19 +795,29 @@ fn build_ticket(id: Uuid, fields: Vec<(String, MetaValue)>) -> Option { } }, ("assigned", MetaValue::String(s)) => assigned = Some(s), + ("closed-by", MetaValue::String(s)) => closed_by = Some(s), ("priority", MetaValue::String(s)) => priority = s.parse().ok(), ("points", MetaValue::String(s)) => points = s.parse().ok(), ("milestone", MetaValue::String(s)) => milestone = Some(s), ("code", MetaValue::String(s)) => code = Some(s), ("parent", MetaValue::String(s)) => parent = Uuid::parse_str(&s).ok(), ("children", MetaValue::Set(members)) => { - children = members.iter().filter_map(|s| Uuid::parse_str(s).ok()).collect(); + children = members + .iter() + .filter_map(|s| Uuid::parse_str(s).ok()) + .collect(); } ("depends_on", MetaValue::Set(members)) => { - depends_on = members.iter().filter_map(|s| Uuid::parse_str(s).ok()).collect(); + depends_on = members + .iter() + .filter_map(|s| Uuid::parse_str(s).ok()) + .collect(); } ("blocks", MetaValue::Set(members)) => { - blocks = members.iter().filter_map(|s| Uuid::parse_str(s).ok()).collect(); + blocks = members + .iter() + .filter_map(|s| Uuid::parse_str(s).ok()) + .collect(); } ("tags", MetaValue::Set(members)) => tags = members, ("comments", MetaValue::List(entries)) => comments = decode_comments(entries), @@ -817,6 +851,7 @@ fn build_ticket(id: Uuid, fields: Vec<(String, MetaValue)>) -> Option { status, state, assigned, + closed_by, priority, points, milestone, @@ -908,6 +943,9 @@ mod tests { let loaded = store.load(&t.id).unwrap(); assert_eq!(loaded.status, TicketStatus::Closed); assert_eq!(loaded.state, TicketState::Resolved); + assert_eq!(loaded.closed_by.as_deref(), Some(store.email())); + store.set_state(&t.id, TicketState::New).unwrap(); + assert_eq!(store.load(&t.id).unwrap().closed_by, None); } #[test] diff --git a/crates/ticgit-lib/src/ticket.rs b/crates/ticgit-lib/src/ticket.rs index df61a01b..b975b625 100644 --- a/crates/ticgit-lib/src/ticket.rs +++ b/crates/ticgit-lib/src/ticket.rs @@ -211,6 +211,8 @@ pub struct Ticket { pub status: TicketStatus, pub state: TicketState, pub assigned: Option, + #[serde(default)] + pub closed_by: Option, pub priority: Option, pub points: Option, pub milestone: Option, @@ -361,6 +363,7 @@ mod tests { status: TicketStatus::Open, state: TicketState::New, assigned: None, + closed_by: None, priority: None, points: None, milestone: None, @@ -388,6 +391,7 @@ mod tests { status: TicketStatus::Open, state: TicketState::New, assigned: Some("jeff.welling@gmail.com".into()), + closed_by: None, priority: None, points: None, milestone: None, @@ -415,6 +419,7 @@ mod tests { status: TicketStatus::Open, state: TicketState::New, assigned: Some("jdoe".into()), + closed_by: None, priority: None, points: None, milestone: None, diff --git a/crates/ticgit/src/agent_help.rs b/crates/ticgit/src/agent_help.rs index 5a618a79..f7cbbf21 100644 --- a/crates/ticgit/src/agent_help.rs +++ b/crates/ticgit/src/agent_help.rs @@ -1,326 +1,5 @@ -pub const MARKDOWN: &str = r#"--- -name: ticgit -description: Use TicGit (`ti`) to track local-first, Git-native tickets in this repository. Use when creating, listing, editing, triaging, updating, syncing, or resolving tickets. ---- - -# TicGit Agent Guide - -TicGit stores tickets as Git metadata, so ticket changes travel with the repository through `ti sync`. Prefer Markdown output (`--markdown`) when reading ticket data for agent workflows; it includes the ticket data plus suggested next commands. - -## Core Workflow - -1. Inspect open work: - -```sh -ti list -ti list --markdown -ti recent -``` - -2. Select a ticket as current when you will run several commands against it: - -```sh -ti checkout -ti show -ti comment "progress update" -ti checkout --clear -``` - -3. Update the ticket as work progresses: - -```sh -ti state blocked -ti state open -ti state closed -ti close -``` - -## Create Tickets - -Create a simple ticket: - -```sh -ti new --title "Fix parser panic" --tags bug,parser --priority 3 -``` - -Create a ticket from a file. The first line is the title; remaining non-empty content is the description: - -```sh -ti new -F /tmp/ticket.md --tags feature,agent -``` - -Use `--markdown` when you need the created ticket details and suggested next commands: - -```sh -ti new -F /tmp/ticket.md --markdown -``` - -## Read Tickets - -List open tickets by default: - -```sh -ti list -``` - -Useful filters: - -```sh -ti list --all -ti list --status open -ti list --state blocked -ti list --tag bug -ti list --assigned alice@example.com -ti list --only-tagged -ti list --order created.desc -ti list --limit 50 -``` - -Search syntax: - -```sh -# Search title, description, and comments. -ti list --search parser - -# Search one field. -ti list --search title:parser -ti list --search description:recovery -ti list --search comments:failed -``` - -Show one ticket, or the current ticket if one is checked out: - -```sh -ti show -ti show -ti show --markdown -``` - -Extract a single JSON field: - -```sh -ti show --filter .title -ti show --filter .comments[0].body -``` - -## Machine Output Schema - -The stable JSON schema for `ti show --json`, JSON mutation commands, and `ti list --json` is published at: - -```text -https://ticgit.dev/schema/v1.json -docs/schema/v1.json -``` - -`ti show --json` emits a ticket object. `ti list --json` emits an array of ticket objects. Ticket metadata appears under `.meta` as a string-to-string object. - -Machine-mode guarantees for `--json`: - -- successful JSON commands write parseable JSON to stdout only -- diagnostic and error text goes to stderr -- JSON output does not include ANSI color escapes -- non-zero exit status means the command failed -- ticket ids may be full UUIDs or unique UUID prefixes -- ambiguous or missing prefixes fail with a non-zero exit status and stderr diagnostic - -`--porcelain` and `--format json` are not supported compatibility aliases today; use `--json` for schema-stable output. - -## Edit Tickets - -Edit title and description in `$EDITOR`: - -```sh -ti edit -``` - -Edit title and description from a file: - -```sh -ti edit -F /tmp/ticket.md -``` - -File format: - -```text -Updated title - -Updated description. -Additional description lines are preserved. -``` - -## Comments And Progress Notes - -Add a short comment: - -```sh -ti comment -t "reproduced locally" -``` - -Use the current ticket: - -```sh -ti checkout -ti comment "implemented parser guard; running tests next" -``` - -Open `$EDITOR` for longer comments: - -```sh -ti comment -t --edit -``` - -## State And Triage - -Tickets have a broad `status` and a specific `state`. - -```text -status=open states: new, assigned, in-progress, blocked, review -status=closed states: resolved, wontfix, duplicate, invalid -``` - -New tickets start as `open:new`. `ti state` and `ti status` accept either a status, a state, or an explicit `status:state` pair. - -```sh -ti state open -t # open:new -ti state blocked -t # open:blocked -ti state closed -t # closed:resolved -ti state closed:wontfix -t # closed:wontfix -ti status review -t # open:review -``` - -Use `blocked` for paused or blocked work. Use `review` when implementation is ready for review. Use `closed:resolved` when implementation is complete. -Use `ti close` as a shortcut for resolving a ticket; if the closed ticket is checked out, it also clears the checkout. - -## Priority - -Set an explicit priority on a ticket (lower integer = more important, e.g. 1 is highest priority). Priority is the dominant factor in `ti next` scoring. - -```sh -ti priority -t 3 -ti priority -t --clear -``` - -## Tags, Assignment, Estimates, Milestones - -Tags are comma- or space-separated: - -```sh -ti tag -t bug,parser -ti tag -t --remove bug -``` - -Set or clear ownership and planning fields: - -```sh -ti assign -t alice@example.com -ti assign -t --clear -ti points -t 3 -ti points -t --clear -ti milestone -t v1.0 -ti milestone -t --clear -``` - -## Metadata - -Store structured string metadata under a ticket. Metadata appears in `ti show --markdown` and under `.meta` in `ti show --json`. - -```sh -ti meta -t branch feature/parser-fix -ti meta -t test-command "cargo test -p ticgit" -ti meta -t notes -F /tmp/meta-value.txt -ti show --filter .meta.branch -``` - -## Saved Views - -Save the last `ti list` filters as a named view, then recall them: - -```sh -ti list --tag bug -ti views save bugs -ti list bugs -ti views -ti views delete bugs -``` - -## Sync - -Ticket metadata is separate from normal Git commits. Sync it explicitly when collaborating: - -```sh -ti sync -``` - -## Planning With Specs - -Use the `spec` field to write an implementation plan before starting work. The spec is a top-level ticket field separate from the description — the description says *what/why*, the spec says *how*. - -```sh -# Write a spec inline -ti spec -t "Use RS256 tokens with 24h expiry, rotate via cron" - -# Write a spec from a file (good for multi-line plans) -ti spec -t -F /tmp/plan.md - -# Open $EDITOR for the spec -ti spec -t - -# Read the spec -ti show --filter .spec - -# Clear the spec -ti spec -t --clear -``` - -When picking up a ticket, check if a spec exists. If not, write one before coding. A good spec covers: approach, files to change, edge cases, and how to verify. - -## Pick Next Work - -Use `ti next` to automatically select and check out the highest-priority open ticket: - -```sh -ti next -ti next --tag bug -ti next --assigned alice@example.com -ti next --markdown -``` - -`ti next` scores tickets by state (in-progress > assigned > new), assignment, points, and age. It skips sub-issues and tickets with unresolved dependencies. - -## Dependencies - -Mark tickets that must be completed before another can start: - -```sh -ti depends -t # depends on -ti depends -t --remove -ti depends --clear -t -``` - -Dependencies show as `Depends:` and `Blocks:` in `ti show`. Circular dependencies are rejected. `ti next` will not pick tickets with open dependencies. - -## Code URIs - -Link a ticket to the branch where work is happening: - -```sh -ti code https://github.com/owner/repo:feature-branch -t -ti code --clear -t -``` - -## Agent Practices - -- Prefer `--markdown` for commands that support it; use `--json` only when a script needs stable schema output. -- Use ticket ids or unique prefixes; ambiguous prefixes fail. -- Run `ti checkout ` before multi-step work so later commands can omit `-t `. -- Before starting work, read the spec (`ti show --filter .spec`). If none exists, write one with `ti spec`. -- Add comments for meaningful observations, plans, blockers, and results. -- Keep tags short and queryable, such as `bug`, `feature`, `docs`, `parser`, or `agent`. -- Use `ti next` to pick work rather than scanning the full list. -- Set dependencies with `ti depends` when tickets have ordering constraints. -- Resolve tickets only after code changes and relevant verification are complete. -"#; +pub const MARKDOWN: &str = include_str!("../../../docs/agents.md"); pub fn print() { - println!("{MARKDOWN}"); + print!("{MARKDOWN}"); } diff --git a/crates/ticgit/src/cli.rs b/crates/ticgit/src/cli.rs index 7a6b8444..d96f2fd5 100644 --- a/crates/ticgit/src/cli.rs +++ b/crates/ticgit/src/cli.rs @@ -15,9 +15,9 @@ use crate::commands; help_template = "\ {about} -{usage-heading} {usage} +\x1b[1;36mUsage:\x1b[0m {usage} -Create & Browse: +\x1b[1;36mCreate & Browse:\x1b[0m new Create a new ticket list, ls List tickets, with optional filters show Show one ticket and its comments @@ -26,7 +26,7 @@ Create & Browse: history Show change history for a ticket tui Browse open tickets in an interactive terminal UI -Work on Tickets: +\x1b[1;36mWork on Tickets:\x1b[0m checkout, co Select a ticket as \"current\" next Pick the next best ticket and check it out edit Edit a ticket's title and description @@ -34,7 +34,7 @@ Work on Tickets: state Change a ticket's lifecycle status/state close Close a ticket (shorthand for state resolved) -Ticket Fields: +\x1b[1;36mTicket Fields:\x1b[0m tag Add or remove a tag assign Set or clear assigned user priority Set or clear priority (lower = more important) @@ -45,29 +45,28 @@ Ticket Fields: depends Add or remove a dependency between tickets meta Set a custom metadata field -Views & Import: +\x1b[1;36mViews & Import:\x1b[0m views Manage saved views (save, delete, list) stats Show a ticket stats dashboard import Import tickets from external systems (e.g. GitHub) -Team: +\x1b[1;36mTeam:\x1b[0m users Manage user nick/email mappings (shared mailmap) mine List tickets assigned to you -Sync & Setup: +\x1b[1;36mSync & Setup:\x1b[0m sync Sync ticket metadata with a Git remote pull Pull tickets from a fork or remote URL init Initialise ticgit on the current repo setup Configure git-meta remote from .git-meta update Update ti to the latest release -Agents: - ti help --agent Markdown guide for AI agents - ti list --json Machine-readable ticket list - ti show --json Machine-readable ticket detail - ti show --markdown Ticket detail with next-step suggestions +\x1b[1;36mAgents:\x1b[0m + agent Markdown guide for AI agents + list --markdown Markdown ticket list + show --markdown Ticket detail with next-step suggestions -Examples: +\x1b[1;36mExamples:\x1b[0m ti new --title \"fix the parser\" --tags bug ti list --tag bug ti views save bugs @@ -77,14 +76,14 @@ Examples: ti state resolved --ticket a3f ti sync -Agent Examples: - ti help --agent - ti list --json | jq '.[].title' +\x1b[1;36mAgent Examples:\x1b[0m + ti agent + ti list --markdown ti show a3f --markdown - ti new --title \"fix bug\" --json - ti comment --ticket a3f \"done\" --json + ti new -F /tmp/ticket.md --markdown + ti comment --ticket a3f \"done\" -Options: +\x1b[1;36mOptions:\x1b[0m {options}" )] pub struct Cli { @@ -99,7 +98,6 @@ pub struct Cli { #[derive(Debug, Subcommand)] pub enum Command { // -- Create & browse -------------------------------------------------- - /// Create a new ticket. #[command(next_help_heading = "Create & Browse")] New(commands::new::Args), @@ -123,8 +121,10 @@ pub enum Command { /// Browse open tickets in an interactive terminal UI. Tui(commands::tui::Args), - // -- Work on tickets -------------------------------------------------- + /// Print a Markdown guide for AI agents. + Agent, + // -- Work on tickets -------------------------------------------------- /// Select a ticket as "current" for subsequent commands. #[command(visible_alias = "co", next_help_heading = "Work on Tickets")] Checkout(commands::checkout::Args), @@ -132,6 +132,9 @@ pub enum Command { /// Pick the next best ticket to work on and check it out. Next(commands::next::Args), + /// Assign a ticket to you and mark it assigned. + Claim(commands::claim::Args), + /// Edit a ticket's title and description in your editor. Edit(commands::edit::Args), @@ -148,7 +151,6 @@ pub enum Command { Close(commands::close::Args), // -- Ticket fields ---------------------------------------------------- - /// Add or remove a tag on a ticket. #[command(next_help_heading = "Ticket Fields")] Tag(commands::tag::Args), @@ -172,6 +174,7 @@ pub enum Command { Code(commands::code::Args), /// Add or remove a dependency between tickets. + #[command(visible_alias = "dep")] Depends(commands::depends::Args), /// Set or clear a ticket's implementation spec. @@ -181,7 +184,6 @@ pub enum Command { Meta(commands::meta::Args), // -- Views & import --------------------------------------------------- - /// Manage saved views (save, delete, list). #[command(next_help_heading = "Views & Import")] Views(commands::view::Args), @@ -193,13 +195,11 @@ pub enum Command { Import(commands::import::Args), // -- Team --------------------------------------------------------------- - /// Manage user nick → email mappings (shared mailmap). #[command(next_help_heading = "Team")] Users(commands::users::Args), // -- Sync & setup ----------------------------------------------------- - /// Sync ticket metadata with a Git remote (pull then push). #[command(next_help_heading = "Sync & Setup")] Sync(commands::sync::Args), @@ -227,6 +227,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { Some(Command::Show(args)) => commands::show::run(args), Some(Command::Checkout(args)) => commands::checkout::run(args), Some(Command::Next(args)) => commands::next::run(args), + Some(Command::Claim(args)) => commands::claim::run(args), Some(Command::Close(args)) => commands::close::run(args), Some(Command::Edit(args)) => commands::edit::run(args), Some(Command::Stats(args)) => commands::stats::run(args), @@ -248,6 +249,10 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { } Some(Command::History(args)) => commands::history::run(args), Some(Command::Tui(args)) => commands::tui::run(args), + Some(Command::Agent) => { + crate::agent_help::print(); + Ok(()) + } Some(Command::Tag(args)) => commands::tag::run(args), Some(Command::State(args)) => commands::state::run(args), Some(Command::Status(args)) => commands::state::run(args), diff --git a/crates/ticgit/src/commands/history.rs b/crates/ticgit/src/commands/history.rs index 1d418afc..3b0e6df3 100644 --- a/crates/ticgit/src/commands/history.rs +++ b/crates/ticgit/src/commands/history.rs @@ -63,12 +63,14 @@ fn db_path_for(git_dir: &std::path::Path) -> Result { Ok(path) } -fn query_history(db_path: &std::path::Path, ticket_id: &str, limit: Option) -> Result> { - let conn = rusqlite::Connection::open_with_flags( - db_path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, - ) - .context("opening git-meta database")?; +fn query_history( + db_path: &std::path::Path, + ticket_id: &str, + limit: Option, +) -> Result> { + let conn = + rusqlite::Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .context("opening git-meta database")?; let prefix = format!("ticgit:tickets:{ticket_id}:"); let limit_val = limit.unwrap_or(100) as i64; @@ -78,20 +80,17 @@ fn query_history(db_path: &std::path::Path, ticket_id: &str, limit: Option Vec { } fn primary_assignee(issue: &GhIssue) -> Option { - issue - .assignees - .iter() - .find_map(|assignee| { - non_empty(&assignee.login).map(|login| { - if login.contains('@') { - login.to_string() - } else { - format!("{login}@users.noreply.github.com") - } - }) + issue.assignees.iter().find_map(|assignee| { + non_empty(&assignee.login).map(|login| { + if login.contains('@') { + login.to_string() + } else { + format!("{login}@users.noreply.github.com") + } }) + }) } fn issue_description(issue: &GhIssue) -> String { @@ -558,6 +555,11 @@ fn linear_description(issue: &LinearIssue) -> String { desc.push_str(&format!("\nLinear state: {name}")); } } + if let Some(assignee) = &issue.assignee { + if let Some(name) = non_empty(&assignee.name) { + desc.push_str(&format!("\nLinear assignee: {name}")); + } + } if let Some(body) = issue.description.as_deref().and_then(non_empty) { desc.push_str("\n\n"); @@ -739,7 +741,7 @@ mod tests { fn linear_issue_description_includes_url_state_and_body() { assert_eq!( linear_description(&linear_issue()), - "Linear issue: https://linear.app/team/issue/ENG-123\nLinear state: In Progress\n\nUsers can't log in after token expiry" + "Linear issue: https://linear.app/team/issue/ENG-123\nLinear state: In Progress\nLinear assignee: Alice\n\nUsers can't log in after token expiry" ); } diff --git a/crates/ticgit/src/commands/list.rs b/crates/ticgit/src/commands/list.rs index 60af7e54..d6ba7ec2 100644 --- a/crates/ticgit/src/commands/list.rs +++ b/crates/ticgit/src/commands/list.rs @@ -25,7 +25,11 @@ pub struct Args { /// Show only tickets with this tag. #[arg(short = 'g', long = "tag")] - pub tag: Option, + pub tag: Vec, + + /// How multiple --tag filters combine: all or any. + #[arg(long = "tag-mode", default_value = "all")] + pub tag_mode: String, /// Show only tickets assigned to this user. #[arg(short = 'a', long = "assigned")] @@ -77,7 +81,8 @@ pub fn run(args: Args) -> Result<()> { state: saved.state.clone(), status: saved.status.clone(), all: saved.all, - tag: saved.tag.clone(), + tag: saved_tags(&saved), + tag_mode: if saved.tag_match_all { "all" } else { "any" }.to_string(), assigned: saved.assigned.clone(), only_tagged: saved.only_tagged, search: saved.search.clone(), @@ -114,11 +119,18 @@ pub fn run(args: Args) -> Result<()> { Some(spec) => Some(SearchFilter::parse(spec).map_err(|e| anyhow::anyhow!(e))?), None => None, }; + let tag_match_all = match args.tag_mode.as_str() { + "all" => true, + "any" | "either" => false, + other => anyhow::bail!("unknown tag mode `{other}` (expected `all` or `any`)"), + }; let filter = Filter { status, state, - tag: args.tag.clone(), + tag: args.tag.first().cloned(), + tags: args.tag.clone(), + tag_match_all, assigned: args.assigned.clone(), only_tagged: args.only_tagged, search, @@ -135,7 +147,9 @@ pub fn run(args: Args) -> Result<()> { let saved = SavedView { status: args.status.clone(), state: args.state.clone(), - tag: args.tag.clone(), + tag: args.tag.first().cloned(), + tags: args.tag.clone(), + tag_match_all, assigned: args.assigned.clone(), only_tagged: args.only_tagged, search: args.search.clone(), @@ -171,7 +185,19 @@ pub fn run(args: Args) -> Result<()> { let nicks = render::build_nick_map(&users); println!( "{}", - render::tickets_table_with_refs(&tickets, current.as_ref(), &open_ref_lengths, Some(&nicks)) + render::tickets_table_with_refs( + &tickets, + current.as_ref(), + &open_ref_lengths, + Some(&nicks) + ) ); Ok(()) } + +fn saved_tags(saved: &SavedView) -> Vec { + if !saved.tags.is_empty() { + return saved.tags.clone(); + } + saved.tag.iter().cloned().collect() +} diff --git a/crates/ticgit/src/commands/mod.rs b/crates/ticgit/src/commands/mod.rs index 6080af0e..fc228cea 100644 --- a/crates/ticgit/src/commands/mod.rs +++ b/crates/ticgit/src/commands/mod.rs @@ -3,10 +3,11 @@ pub mod assign; pub mod checkout; +pub mod claim; pub mod close; pub mod code; -pub mod depends; pub mod comment; +pub mod depends; pub mod edit; pub mod history; pub mod import; @@ -16,20 +17,20 @@ pub mod meta; pub mod milestone; pub mod new; pub mod next; -pub mod pull; -pub mod subissue; pub mod points; pub mod priority; +pub mod pull; pub mod recent; pub mod setup; pub mod show; pub mod spec; -pub mod stats; pub mod state; +pub mod stats; +pub mod subissue; pub mod sync; pub mod tag; -pub mod update; pub mod tui; +pub mod update; pub mod users; pub mod view; diff --git a/crates/ticgit/src/commands/new.rs b/crates/ticgit/src/commands/new.rs index 4a4bf84f..5e83851c 100644 --- a/crates/ticgit/src/commands/new.rs +++ b/crates/ticgit/src/commands/new.rs @@ -81,10 +81,7 @@ pub fn run(args: Args) -> Result<()> { let tags = parse_tags(args.tags.as_deref()); - let parent = args - .subissue - .map(|p| store.resolve_id(&p)) - .transpose()?; + let parent = args.subissue.map(|p| store.resolve_id(&p)).transpose()?; let opts = NewTicketOpts { comment, diff --git a/crates/ticgit/src/commands/next.rs b/crates/ticgit/src/commands/next.rs index 30eb96cb..ca55fea1 100644 --- a/crates/ticgit/src/commands/next.rs +++ b/crates/ticgit/src/commands/next.rs @@ -98,10 +98,17 @@ pub fn run(args: Args) -> Result<()> { } println!("Next: {} - {}", ticket.short_id(), ticket.title); - println!(" State: {} Priority: {} Points: {}", + println!( + " State: {} Priority: {} Points: {}", ticket.state.as_str(), - ticket.priority.map(|p| p.to_string()).unwrap_or_else(|| "-".into()), - ticket.points.map(|p| p.to_string()).unwrap_or_else(|| "-".into()), + ticket + .priority + .map(|p| p.to_string()) + .unwrap_or_else(|| "-".into()), + ticket + .points + .map(|p| p.to_string()) + .unwrap_or_else(|| "-".into()), ); if let Some(a) = &ticket.assigned { println!(" Assigned: {a}"); @@ -125,7 +132,7 @@ fn score(t: &Ticket, max_priority: i64) -> i64 { TicketState::InProgress => s += 100, TicketState::Assigned => s += 80, TicketState::Review => s += 60, - TicketState::Blocked => s -= 200, // skip blocked tickets + TicketState::Blocked => s -= 200, // skip blocked tickets TicketState::New => s += 40, _ => {} } diff --git a/crates/ticgit/src/commands/pull.rs b/crates/ticgit/src/commands/pull.rs index 9b267a1f..9d2aff7b 100644 --- a/crates/ticgit/src/commands/pull.rs +++ b/crates/ticgit/src/commands/pull.rs @@ -152,17 +152,10 @@ fn fetch_remote_tickets(url: &str) -> Result> { // Configure a remote with git-meta refspecs so session.pull() works. git_at(path, &["remote", "add", "origin", url])?; let namespace = meta_namespace()?; - let fetch_refspec = format!( - "+refs/{namespace}/main:refs/{namespace}/remotes/main" - ); + let fetch_refspec = format!("+refs/{namespace}/main:refs/{namespace}/remotes/main"); git_at( path, - &[ - "config", - "--add", - "remote.origin.fetch", - &fetch_refspec, - ], + &["config", "--add", "remote.origin.fetch", &fetch_refspec], )?; git_at(path, &["config", "remote.origin.meta", "true"])?; @@ -177,9 +170,7 @@ fn fetch_remote_tickets(url: &str) -> Result> { .pull(Some("origin")) .context("pulling metadata from remote")?; - remote_store - .list() - .context("listing tickets from remote") + remote_store.list().context("listing tickets from remote") } fn meta_namespace() -> Result { @@ -218,21 +209,30 @@ fn import_ticket(store: &TicketStore, remote: &Ticket) -> Result<()> { )?; if let Some(ref desc) = remote.description { - p.set(&ticgit_lib::keys::ticket_field(id, "description"), desc.as_str())?; + p.set( + &ticgit_lib::keys::ticket_field(id, "description"), + desc.as_str(), + )?; } if let Some(ref spec) = remote.spec { p.set(&ticgit_lib::keys::ticket_field(id, "spec"), spec.as_str())?; } if let Some(ref assigned) = remote.assigned { - p.set(&ticgit_lib::keys::ticket_field(id, "assigned"), assigned.as_str())?; + p.set( + &ticgit_lib::keys::ticket_field(id, "assigned"), + assigned.as_str(), + )?; } - if let Some(points) = remote.points { - let pts = points.to_string(); + if let Some(ref closed_by) = remote.closed_by { p.set( - &ticgit_lib::keys::ticket_field(id, "points"), - pts.as_str(), + &ticgit_lib::keys::ticket_field(id, "closed-by"), + closed_by.as_str(), )?; } + if let Some(points) = remote.points { + let pts = points.to_string(); + p.set(&ticgit_lib::keys::ticket_field(id, "points"), pts.as_str())?; + } if let Some(ref milestone) = remote.milestone { p.set( &ticgit_lib::keys::ticket_field(id, "milestone"), @@ -244,10 +244,7 @@ fn import_ticket(store: &TicketStore, remote: &Ticket) -> Result<()> { } if let Some(ref parent_id) = remote.parent { let pid = parent_id.to_string(); - p.set( - &ticgit_lib::keys::ticket_field(id, "parent"), - pid.as_str(), - )?; + p.set(&ticgit_lib::keys::ticket_field(id, "parent"), pid.as_str())?; } for tag in &remote.tags { @@ -263,7 +260,10 @@ fn import_ticket(store: &TicketStore, remote: &Ticket) -> Result<()> { } for (key, value) in &remote.meta { - p.set(&ticgit_lib::keys::ticket_meta_field(id, key), value.as_str())?; + p.set( + &ticgit_lib::keys::ticket_meta_field(id, key), + value.as_str(), + )?; } // Comments: push each one as raw JSON to preserve authorship and timestamp. @@ -309,6 +309,12 @@ fn merge_ticket(store: &TicketStore, local: &Ticket, remote: &Ticket) -> Result< store.set_assigned(id, remote.assigned.as_deref())?; changed = true; } + if remote.closed_by != local.closed_by + || (remote.status == ticgit_lib::TicketStatus::Closed && remote.closed_by.is_none()) + { + store.set_closed_by(id, remote.closed_by.as_deref())?; + changed = true; + } if remote.points != local.points { store.set_points(id, remote.points)?; changed = true; @@ -343,10 +349,7 @@ fn merge_ticket(store: &TicketStore, local: &Ticket, remote: &Ticket) -> Result< let parent_id = remote.parent.unwrap(); let p = store.session().target(&ticgit_lib::Target::project()); let pid = parent_id.to_string(); - p.set( - &ticgit_lib::keys::ticket_field(id, "parent"), - pid.as_str(), - )?; + p.set(&ticgit_lib::keys::ticket_field(id, "parent"), pid.as_str())?; changed = true; } diff --git a/crates/ticgit/src/commands/setup.rs b/crates/ticgit/src/commands/setup.rs index c50981cd..e522bfdc 100644 --- a/crates/ticgit/src/commands/setup.rs +++ b/crates/ticgit/src/commands/setup.rs @@ -93,7 +93,12 @@ fn has_meta_remote() -> Result { for remote in remotes { let output = Command::new("git") - .args(["config", "--bool", "--get", &format!("remote.{remote}.meta")]) + .args([ + "config", + "--bool", + "--get", + &format!("remote.{remote}.meta"), + ]) .output() .context("checking remote.*.meta config")?; diff --git a/crates/ticgit/src/commands/stats.rs b/crates/ticgit/src/commands/stats.rs index 9920a5f0..e15a853e 100644 --- a/crates/ticgit/src/commands/stats.rs +++ b/crates/ticgit/src/commands/stats.rs @@ -57,9 +57,7 @@ pub fn run(args: Args) -> Result<()> { let mut states: BTreeMap = BTreeMap::new(); let mut tags: BTreeMap = BTreeMap::new(); let mut assignees: BTreeMap = BTreeMap::new(); - let nick_map = crate::render::build_nick_map( - &store.list_users().unwrap_or_default(), - ); + let nick_map = crate::render::build_nick_map(&store.list_users().unwrap_or_default()); // Collect recently closed tickets (by created_at as proxy for activity). let mut recently_closed: Vec<(String, String)> = Vec::new(); // (short_id, title) @@ -69,10 +67,7 @@ pub fn run(args: Args) -> Result<()> { ticgit_lib::TicketStatus::Open => open += 1, ticgit_lib::TicketStatus::Closed => { closed += 1; - recently_closed.push(( - t.id.to_string().chars().take(6).collect(), - t.title.clone(), - )); + recently_closed.push((t.id.to_string().chars().take(6).collect(), t.title.clone())); } } *states.entry(t.state.as_str().to_string()).or_default() += 1; @@ -228,25 +223,47 @@ pub fn run(args: Args) -> Result<()> { // Right column budget: tags header(1) + tags + gap(1) + assignees header(1) + assignees + gap(1) + closed header(1) + closed let right_fixed = 2 + assignee_vec.len().min(3) + 1; let closed_budget = 4; // header + up to 3 titles - avail.saturating_sub(right_fixed + closed_budget).min(tag_vec.len()).min(6) + avail + .saturating_sub(right_fixed + closed_budget) + .min(tag_vec.len()) + .min(6) } else { - avail.saturating_sub(state_vec.len() + 8).min(tag_vec.len()).min(5) + avail + .saturating_sub(state_vec.len() + 8) + .min(tag_vec.len()) + .min(5) }; let assignee_limit = assignee_vec.len().min(3); // Recently closed: show if there's vertical room. let recently_closed_limit = if two_col { - let left_rows = state_vec.len() + 1 - + if created_7d > 0 || closed_7d > 0 { 4 } else { 0 }; - let right_rows = 1 + tag_limit + let left_rows = state_vec.len() + + 1 + + if created_7d > 0 || closed_7d > 0 { + 4 + } else { + 0 + }; + let right_rows = 1 + + tag_limit + (if tag_vec.len() > tag_limit { 1 } else { 0 }) + 1 - + if !assignee_vec.is_empty() { 1 + assignee_limit + 1 } else { 0 }; + + if !assignee_vec.is_empty() { + 1 + assignee_limit + 1 + } else { + 0 + }; let used = left_rows.max(right_rows); - avail.saturating_sub(used + 1).min(recently_closed.len()).min(5) + avail + .saturating_sub(used + 1) + .min(recently_closed.len()) + .min(5) } else { - avail.saturating_sub(state_vec.len() + tag_limit + 10).min(recently_closed.len()).min(3) + avail + .saturating_sub(state_vec.len() + tag_limit + 10) + .min(recently_closed.len()) + .min(3) }; // ── Build output ── @@ -303,9 +320,7 @@ pub fn run(args: Args) -> Result<()> { )); } if closed_7d > 0 { - left_lines.push(format!( - " {RED}-{closed_7d}{RESET}{DIM} closed{RESET}" - )); + left_lines.push(format!(" {RED}-{closed_7d}{RESET}{DIM} closed{RESET}")); } left_lines.push(String::new()); } @@ -352,9 +367,7 @@ pub fn run(args: Args) -> Result<()> { } else { title.clone() }; - right_lines.push(format!( - " {DIM}{id}{RESET} {display_title}" - )); + right_lines.push(format!(" {DIM}{id}{RESET} {display_title}")); } let remaining = recently_closed.len().saturating_sub(recently_closed_limit); if remaining > 0 { @@ -382,10 +395,7 @@ pub fn run(args: Args) -> Result<()> { for (state, count) in &state_vec { let bar = colored_bar(*count, max_count, bar_width, BG_CYAN); let color = state_color(state); - println!( - " {color}{:<14}{RESET}{:>3} {bar}", - state, count - ); + println!(" {color}{:<14}{RESET}{:>3} {bar}", state, count); } println!(); } @@ -395,10 +405,7 @@ pub fn run(args: Args) -> Result<()> { let max_count = tag_vec.first().map(|(_, c)| *c).unwrap_or(1); for (tag, count) in tag_vec.iter().take(tag_limit) { let bar = colored_bar(*count, max_count, bar_width, BG_MAGENTA); - println!( - " {MAGENTA}{:<14}{RESET}{:>3} {bar}", - tag, count - ); + println!(" {MAGENTA}{:<14}{RESET}{:>3} {bar}", tag, count); } let remaining = tag_vec.len().saturating_sub(tag_limit); if remaining > 0 { diff --git a/crates/ticgit/src/commands/subissue.rs b/crates/ticgit/src/commands/subissue.rs index 784e4320..5ec3b260 100644 --- a/crates/ticgit/src/commands/subissue.rs +++ b/crates/ticgit/src/commands/subissue.rs @@ -54,7 +54,12 @@ pub fn run(args: Args) -> Result<()> { Some(pid) => { let parent = store.load(&pid)?; let short: String = pid.to_string().chars().take(6).collect(); - println!("{} sub-issue of: {} {}", ticket.short_id(), short, parent.title); + println!( + "{} sub-issue of: {} {}", + ticket.short_id(), + short, + parent.title + ); } None => println!("{} sub-issue: (none)", ticket.short_id()), } diff --git a/crates/ticgit/src/commands/sync.rs b/crates/ticgit/src/commands/sync.rs index 6795493f..d593b22c 100644 --- a/crates/ticgit/src/commands/sync.rs +++ b/crates/ticgit/src/commands/sync.rs @@ -25,7 +25,7 @@ pub fn run_sync(args: Args) -> Result<()> { .transpose()? .unwrap_or_else(|| "(none)".to_string()); - if let Some(remote) = remote { + if let Some(remote) = &remote { println!("Remote: {remote}"); } println!("Ref: refs/{namespace}/main"); @@ -72,7 +72,6 @@ pub fn run_sync(args: Args) -> Result<()> { let total = after.len(); println!("Push: {total} ticket(s) synced."); - println!("Done."); Ok(()) } diff --git a/crates/ticgit/src/commands/tui.rs b/crates/ticgit/src/commands/tui.rs index 2d81e706..303222a4 100644 --- a/crates/ticgit/src/commands/tui.rs +++ b/crates/ticgit/src/commands/tui.rs @@ -1,7 +1,11 @@ +use std::collections::BTreeSet; use std::io::{self, Stdout}; -use std::time::Duration; +use std::process::Command; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::{Duration, Instant}; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use crossterm::execute; @@ -12,12 +16,47 @@ use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; +use ratatui::widgets::{ + Block, Borders, Clear, Gauge, HighlightSpacing, List, ListItem, ListState, Paragraph, Wrap, +}; use ratatui::{Frame, Terminal}; -use ticgit_lib::{query, Filter, NewTicketOpts, Ticket, TicketState, TicketStatus, TicketStore}; -use time::format_description::well_known::Rfc3339; - -use crate::commands::open_store; +use ticgit_lib::{ + query, Comment, Filter, NewTicketOpts, SortOrder, Ticket, TicketLifecycle, TicketState, + TicketStatus, TicketStore, +}; +use time::OffsetDateTime; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +const HIGHLIGHT_SYMBOL: &str = "> "; +const LIST_ID_WIDTH: usize = 3; +const LIST_STATE_WIDTH: usize = 2; +const LIST_AGE_WIDTH: usize = 3; +const LIST_PRIORITY_WIDTH: usize = 3; +const ANSI_TAG_COLORS: [Color; 12] = [ + Color::Blue, + Color::Cyan, + Color::Green, + Color::Yellow, + Color::Magenta, + Color::LightBlue, + Color::LightCyan, + Color::LightGreen, + Color::LightYellow, + Color::LightMagenta, + Color::LightRed, + Color::Gray, +]; +const BOARD_STATES: [TicketState; 5] = [ + TicketState::New, + TicketState::Assigned, + TicketState::InProgress, + TicketState::Blocked, + TicketState::Review, +]; + +use crate::commands::{open_store, SessionGitDir}; +use crate::editor; +use crate::session_state::{SavedView, State}; #[derive(Debug, Parser)] pub struct Args {} @@ -69,23 +108,91 @@ fn init_terminal() -> Result>> { Ok(terminal) } +fn suspend_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} + +fn resume_terminal(terminal: &mut Terminal>) -> Result<()> { + enable_raw_mode()?; + execute!(terminal.backend_mut(), EnterAlternateScreen)?; + terminal.hide_cursor()?; + terminal.clear()?; + Ok(()) +} + struct App { store: TicketStore, tickets: Vec, visible: Vec, list_state: ListState, + board_column: usize, + board_rows: [usize; BOARD_STATES.len()], + view: ViewMode, + active_view_name: Option, + saved_view_state: ListState, + pending_delete_view: Option, + base_status: Option, + base_state: Option, + assigned_filter: Option, + only_tagged: bool, + hide_subissues: bool, + sort_order: Option, filter: String, + tag_filter: BTreeSet, + tag_filter_match_all: bool, + tag_picker_state: ListState, mode: Mode, input: String, new_ticket: NewTicketDraft, detail: Option, + comments_mode: bool, + comment_state: ListState, + show_help: bool, + show_quit_hint: bool, status: Option, + sync: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ViewMode { + List, + Board, +} + +struct SyncState { + receiver: Receiver>, + selected_id: Option, + started_at: Instant, +} + +struct SyncResult { + summary: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ViewKind { + BuiltIn, + Saved, +} + +#[derive(Debug, Clone)] +struct ViewEntry { + name: String, + view: SavedView, + kind: ViewKind, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Mode { Normal, Filter, + Tags, + SavedViews, + ConfirmDeleteView, + SaveView, Input(InputKind), State, Create, @@ -93,9 +200,9 @@ enum Mode { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputKind { - Title, - Description, Comment, + Priority, + Points, AddTags, RemoveTags, } @@ -125,12 +232,32 @@ impl App { tickets: Vec::new(), visible: Vec::new(), list_state: ListState::default(), + board_column: 0, + board_rows: [0; BOARD_STATES.len()], + view: ViewMode::List, + active_view_name: None, + saved_view_state: ListState::default(), + pending_delete_view: None, + base_status: Some(TicketStatus::Open), + base_state: None, + assigned_filter: None, + only_tagged: false, + hide_subissues: false, + sort_order: None, filter: String::new(), + tag_filter: BTreeSet::new(), + tag_filter_match_all: true, + tag_picker_state: ListState::default(), mode: Mode::Normal, input: String::new(), new_ticket: NewTicketDraft::default(), detail: None, + comments_mode: false, + comment_state: ListState::default(), + show_help: false, + show_quit_hint: false, status: None, + sync: None, }; app.reload(None)?; Ok(app) @@ -140,10 +267,18 @@ impl App { self.tickets = query::apply( self.store.list()?, &Filter { - status: Some(TicketStatus::Open), + status: self.base_status, + state: self.base_state, + assigned: self.assigned_filter.clone(), + only_tagged: self.only_tagged, + hide_subissues: self.hide_subissues, + order: self.sort_order, ..Default::default() }, ); + if self.sort_order.is_none() { + self.tickets.sort_by(compare_tui_tickets); + } self.apply_filter(); if let Some(id) = preferred_id { @@ -158,14 +293,17 @@ impl App { } } else if self.detail.is_some() { self.detail = None; + self.comments_mode = false; } } + self.sync_comment_selection(); Ok(()) } fn run(&mut self, terminal: &mut Terminal>) -> Result<()> { loop { + self.poll_sync()?; terminal.draw(|frame| self.draw(frame))?; if !event::poll(Duration::from_millis(250))? { @@ -178,7 +316,7 @@ impl App { if key.kind != KeyEventKind::Press { continue; } - if self.handle_key(key)? { + if self.handle_key(key, terminal)? { return Ok(()); } } @@ -186,211 +324,417 @@ impl App { fn draw(&mut self, frame: &mut Frame<'_>) { let area = frame.area(); + let constraints = if self.sync.is_some() { + vec![ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ] + } else { + vec![Constraint::Min(1), Constraint::Length(1)] + }; let outer = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(1)]) + .constraints(constraints) .split(area); - self.draw_filter(frame, outer[0]); if self.detail.is_some() { let panes = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) - .split(outer[1]); - self.draw_list(frame, panes[0]); - self.draw_detail(frame, panes[1]); + .split(outer[0]); + if self.comments_mode { + self.draw_comments_list(frame, panes[0]); + self.draw_comment_detail(frame, panes[1]); + } else { + self.draw_list(frame, panes[0]); + self.draw_detail(frame, panes[1]); + } } else { - self.draw_list(frame, outer[1]); + match self.view { + ViewMode::List => self.draw_list(frame, outer[0]), + ViewMode::Board => self.draw_board(frame, outer[0]), + } + } + if self.sync.is_some() { + self.draw_sync_progress(frame, outer[1]); + self.draw_menu_bar(frame, outer[2]); + } else { + self.draw_menu_bar(frame, outer[1]); } match self.mode { + Mode::Tags => self.draw_tags_modal(frame), + Mode::SavedViews => self.draw_saved_views_modal(frame), + Mode::ConfirmDeleteView => self.draw_delete_view_confirm_modal(frame), + Mode::SaveView => self.draw_save_view_modal(frame), Mode::Input(kind) => self.draw_input_modal(frame, kind), Mode::State => self.draw_state_modal(frame), Mode::Create => self.draw_create_modal(frame), _ => {} } + if self.show_help { + self.draw_help_modal(frame); + } + if self.show_quit_hint { + self.draw_quit_hint_modal(frame); + } } - fn draw_filter(&self, frame: &mut Frame<'_>, area: Rect) { + fn draw_menu_bar(&self, frame: &mut Frame<'_>, area: Rect) { let prompt = match self.mode { Mode::Filter => Line::from(vec![ + Span::styled( + " ti tui ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(" "), Span::styled("filter ", Style::default().fg(Color::Yellow)), Span::raw("/"), Span::styled(self.filter.as_str(), Style::default().fg(Color::Cyan)), Span::raw(" type to filter, Enter/Esc to finish"), ]), + Mode::Tags => Line::from(vec![ + Span::styled( + " ti tui ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(" "), + Span::styled("tags ", Style::default().fg(Color::Yellow)), + Span::raw("j/k move Space toggle a all/any c clear Enter/Esc finish"), + ]), + Mode::SavedViews => Line::from(vec![ + Span::styled( + " ti tui ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(" "), + Span::styled("views ", Style::default().fg(Color::Yellow)), + Span::raw("j/k move Enter apply d default Esc cancel"), + ]), + Mode::SaveView => Line::from(vec![ + Span::styled( + " ti tui ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(" "), + Span::styled("save view ", Style::default().fg(Color::Yellow)), + Span::raw(self.input.as_str()), + Span::raw(" Enter save, Esc cancel"), + ]), Mode::Input(kind) => Line::from(vec![ + Span::styled( + " ti tui ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(" "), Span::styled("editing ", Style::default().fg(Color::Yellow)), Span::raw(kind.label()), Span::raw(" Enter apply, Esc cancel"), ]), Mode::State => Line::from(vec![ + Span::styled( + " ti tui ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(" "), Span::styled("editing ", Style::default().fg(Color::Yellow)), Span::raw("state choose in modal, Esc cancel"), ]), Mode::Create => Line::from(vec![ + Span::styled( + " ti tui ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(" "), Span::styled("new ", Style::default().fg(Color::Yellow)), Span::raw("Tab/↑/↓ fields Enter create Esc cancel"), ]), Mode::Normal => { - let status = self.status.as_deref().unwrap_or( - "n new / filter Enter details t title d desc c comment s state +/- tags q quit", - ); + let status = self.status.as_deref().unwrap_or_else(|| { + if self.comments_mode { + "j/k comments c add comment Esc details ? help q quit" + } else if self.view == ViewMode::Board && self.detail.is_none() { + "b list h/l columns j/k tickets Enter details e edit i spec s state c comment ? help q quit" + } else { + "b board n new / search g tags v views V save view e edit i spec C claim Enter details S sync ? help q quit" + } + }); Line::from(vec![ + Span::styled( + " ti tui ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(" "), Span::styled("normal ", Style::default().fg(Color::Yellow)), Span::raw(status), ]) } }; - let paragraph = - Paragraph::new(prompt).block(Block::default().borders(Borders::ALL).title("ti tui")); + let paragraph = Paragraph::new(prompt).style(Style::default().bg(Color::DarkGray)); frame.render_widget(paragraph, area); } + fn draw_sync_progress(&self, frame: &mut Frame<'_>, area: Rect) { + let Some(sync) = &self.sync else { + return; + }; + let elapsed = sync.started_at.elapsed().as_millis() as u64; + let ratio = ((elapsed / 80) % 100) as f64 / 100.0; + let gauge = Gauge::default() + .gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray)) + .label("syncing tickets") + .ratio(ratio); + frame.render_widget(gauge, area); + } + fn draw_list(&mut self, frame: &mut Frame<'_>, area: Rect) { let count = self.visible.len(); - let title = if self.filter.is_empty() { - format!("Open tickets ({count})") + let filter = self.active_filter_display(); + let scope = if self.base_status == Some(TicketStatus::Open) && self.base_state.is_none() { + "Open tickets" } else { - format!("Open tickets matching \"{}\" ({count})", self.filter) + "Tickets" + }; + let title = if filter.is_empty() { + format!("{scope} ({count})") + } else { + format!("{scope} matching {filter} ({count})") }; + let block = Block::default().borders(Borders::ALL).title(title); + let row_width = usize::from(block.inner(area).width) + .saturating_sub(UnicodeWidthStr::width(HIGHLIGHT_SYMBOL)); + let compact = self.detail.is_some(); + let items: Vec> = self .visible .iter() .map(|&idx| { let ticket = &self.tickets[idx]; - let tags = if ticket.tags.is_empty() { - String::new() - } else { - format!( - " [{}]", - ticket.tags.iter().cloned().collect::>().join(",") - ) - }; - ListItem::new(Line::from(vec![ - Span::styled(ticket.short_id(), Style::default().fg(Color::DarkGray)), - Span::raw(" "), - Span::raw(ticket.title.as_str()), - Span::styled(tags, Style::default().fg(Color::Blue)), - ])) + ListItem::new(ticket_list_line( + ticket, + row_width, + compact, + self.store.email(), + )) }) .collect(); let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title(title)) + .block(block) .highlight_style( Style::default() - .bg(Color::Blue) + .bg(Color::Rgb(0, 0, 95)) .fg(Color::White) .add_modifier(Modifier::BOLD), ) - .highlight_symbol("> "); + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); frame.render_stateful_widget(list, area, &mut self.list_state); } + fn draw_board(&mut self, frame: &mut Frame<'_>, area: Rect) { + let constraints = vec![Constraint::Percentage(20); BOARD_STATES.len()]; + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints(constraints) + .split(area); + + for (column_idx, state) in BOARD_STATES.iter().enumerate() { + let tickets = self.board_column_tickets(column_idx); + let title = format!("{} ({})", state.as_str(), tickets.len()); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(if column_idx == self.board_column { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }); + let row_width = usize::from(block.inner(columns[column_idx]).width) + .saturating_sub(UnicodeWidthStr::width(HIGHLIGHT_SYMBOL)); + let items: Vec> = tickets + .iter() + .map(|&&idx| { + let ticket = &self.tickets[idx]; + ListItem::new(board_ticket_line(ticket, row_width)) + }) + .collect(); + let mut state = ListState::default(); + if column_idx == self.board_column && !tickets.is_empty() { + let selected = self.board_rows[column_idx].min(tickets.len() - 1); + self.board_rows[column_idx] = selected; + state.select(Some(selected)); + } + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + frame.render_stateful_widget(list, columns[column_idx], &mut state); + } + } + fn draw_detail(&self, frame: &mut Frame<'_>, area: Rect) { let Some(idx) = self.detail else { return; }; let ticket = &self.tickets[idx]; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area); - let mut detail_lines = vec![ - field_line("Title", ticket.title.as_str()), - field_line("Id", &ticket.id.to_string()), + Line::from(Span::styled( + ticket.id.to_string(), + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + ticket.title.clone(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), field_line( "Created", &format!( - "{} by {}", - ticket.created_at.format(&Rfc3339).unwrap_or_default(), + "{} ago by {}", + relative_date(ticket.created_at, OffsetDateTime::now_utc()), created_by_display(ticket) ), ), - field_line("Status", ticket.status.as_str()), - field_line("State", ticket.state.as_str()), + status_state_line(ticket), ]; + if !ticket.tags.is_empty() { + detail_lines.push(tags_field_line(&ticket.tags)); + } if let Some(assigned) = &ticket.assigned { detail_lines.push(field_line("Assigned", assigned)); } + if let Some(closed_by) = &ticket.closed_by { + detail_lines.push(field_line("Closed by", closed_by)); + } + if let Some(priority) = ticket.priority { + detail_lines.push(field_line("Priority", &priority.to_string())); + } if let Some(points) = ticket.points { detail_lines.push(field_line("Points", &points.to_string())); } if let Some(milestone) = &ticket.milestone { detail_lines.push(field_line("Milestone", milestone)); } - if !ticket.tags.is_empty() { - detail_lines.push(field_line( - "Tags", - &ticket.tags.iter().cloned().collect::>().join(", "), - )); + if let Some(spec) = &ticket.spec { + detail_lines.push(field_line("Spec", first_spec_line(spec))); } - detail_lines.push(Line::raw("")); - detail_lines.push(Line::from(Span::styled( - "Description", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ))); if let Some(description) = &ticket.description { + detail_lines.push(Line::raw("")); for line in description.lines() { detail_lines.push(Line::from(Span::raw(line.to_string()))); } - } else { + } + if !ticket.comments.is_empty() { + detail_lines.push(Line::raw("")); detail_lines.push(Line::from(Span::styled( - "(none)", - Style::default().fg(Color::DarkGray), + "Comments", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), ))); + let width = usize::from(area.width).saturating_sub(2); + for comment in &ticket.comments { + detail_lines.push(comment_summary_line(comment, width)); + } } let detail = Paragraph::new(detail_lines) .block(Block::default().borders(Borders::ALL).title("Details")) .wrap(Wrap { trim: false }); - frame.render_widget(detail, chunks[0]); + frame.render_widget(detail, area); + } - let comment_lines = if ticket.comments.is_empty() { - vec![Line::from(Span::styled( - "(no comments)", + fn draw_comments_list(&mut self, frame: &mut Frame<'_>, area: Rect) { + let Some(idx) = self.detail else { + return; + }; + let ticket = &self.tickets[idx]; + let title = truncate_display(&ticket.title, usize::from(area.width).saturating_sub(14)); + let block = Block::default() + .borders(Borders::ALL) + .title(format!("Comments: {title}")); + let width = usize::from(block.inner(area).width) + .saturating_sub(UnicodeWidthStr::width(HIGHLIGHT_SYMBOL)); + let items: Vec> = if ticket.comments.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No comments. Press c to add one.", Style::default().fg(Color::DarkGray), - ))] + )))] } else { ticket .comments .iter() - .flat_map(|comment| { - let header = Line::from(vec![ - Span::styled(comment.author.clone(), Style::default().fg(Color::Cyan)), - Span::raw(" "), - Span::styled( - comment.at.format(&Rfc3339).unwrap_or_default(), - Style::default().fg(Color::DarkGray), - ), - ]); - let mut lines = vec![header]; - lines.extend(comment.body.lines().map(|line| Line::raw(line.to_string()))); - lines.push(Line::raw("")); - lines - }) + .map(|comment| ListItem::new(comment_summary_line(comment, width))) .collect() }; - let comments = Paragraph::new(comment_lines) - .block(Block::default().borders(Borders::ALL).title("Comments")) + + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + frame.render_stateful_widget(list, area, &mut self.comment_state); + } + + fn draw_comment_detail(&self, frame: &mut Frame<'_>, area: Rect) { + let Some(ticket) = self.detail.map(|idx| &self.tickets[idx]) else { + return; + }; + let lines = if let Some(comment) = self.selected_comment(ticket) { + let mut lines = vec![ + Line::from(vec![ + Span::styled( + relative_date(comment.at, OffsetDateTime::now_utc()), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + ), + Span::raw(" "), + Span::styled(comment.author.clone(), Style::default().fg(Color::Cyan)), + ]), + Line::raw(""), + ]; + lines.extend(comment.body.lines().map(|line| Line::raw(line.to_string()))); + lines + } else { + vec![Line::from(Span::styled( + "No comments yet. Press c to add one.", + Style::default().fg(Color::DarkGray), + ))] + }; + let comments = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title("Comment")) .wrap(Wrap { trim: false }); - frame.render_widget(comments, chunks[1]); + frame.render_widget(comments, area); } fn draw_input_modal(&self, frame: &mut Frame<'_>, kind: InputKind) { let area = centered_rect(70, kind.modal_height(), frame.area()); let title = format!("Edit {}", kind.label()); let help = match kind { - InputKind::Title => "Enter a new ticket title.", - InputKind::Description => "Enter a new description. Empty clears it.", - InputKind::Comment => "Enter a comment to append.", - InputKind::AddTags => "Enter comma- or space-separated tags to add.", - InputKind::RemoveTags => "Enter comma- or space-separated tags to remove.", + InputKind::Comment => "Enter a comment to append.".to_string(), + InputKind::Priority => format!( + "Enter priority. Lower is more important. Empty clears it. {}", + self.priority_range_display() + ), + InputKind::Points => "Enter points estimate. Empty clears it.".to_string(), + InputKind::AddTags => "Enter comma- or space-separated tags to add.".to_string(), + InputKind::RemoveTags => "Enter comma- or space-separated tags to remove.".to_string(), }; let lines = vec![ Line::from(Span::styled(help, Style::default().fg(Color::DarkGray))), @@ -409,6 +753,208 @@ impl App { frame.render_widget(modal, area); } + fn draw_tags_modal(&mut self, frame: &mut Frame<'_>) { + let area = centered_rect(64, 20, frame.area()); + let tags = self.available_tags(); + let selected = self + .tag_picker_state + .selected() + .unwrap_or(0) + .min(tags.len().saturating_sub(1)); + if tags.is_empty() { + self.tag_picker_state.select(None); + } else { + self.tag_picker_state.select(Some(selected)); + } + + let items: Vec> = if tags.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No tags on open tickets.", + Style::default().fg(Color::DarkGray), + )))] + } else { + tags.iter() + .map(|(tag, count)| { + let checked = if self.tag_filter.contains(tag) { + "[x]" + } else { + "[ ]" + }; + ListItem::new(Line::from(vec![ + Span::styled(checked, Style::default().fg(Color::Yellow)), + Span::raw(" "), + Span::styled(tag.clone(), Style::default().fg(tag_color(tag))), + Span::styled(format!(" ({count})"), Style::default().fg(Color::DarkGray)), + ])) + }) + .collect() + }; + let mode = if self.tag_filter_match_all { + "all selected tags" + } else { + "any selected tag" + }; + let title = format!("Tag Filter: {mode}"); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + frame.render_widget(Clear, area); + frame.render_stateful_widget(list, area, &mut self.tag_picker_state); + } + + fn draw_saved_views_modal(&mut self, frame: &mut Frame<'_>) { + let area = centered_rect(78, 20, frame.area()); + let views = self.view_entries(); + let selected = self + .saved_view_state + .selected() + .unwrap_or(0) + .min(views.len().saturating_sub(1)); + if views.is_empty() { + self.saved_view_state.select(None); + } else { + self.saved_view_state.select(Some(selected)); + } + + let width = usize::from(area.width).saturating_sub(6); + let items: Vec> = if views.is_empty() { + Vec::new() + } else { + views + .iter() + .map(|entry| { + let active = self.active_view_name.as_deref() == Some(entry.name.as_str()); + let marker = if active { "*" } else { " " }; + let desc = crate::commands::view::describe_view(&entry.view); + let kind = match entry.kind { + ViewKind::BuiltIn => "built-in", + ViewKind::Saved => "saved", + }; + let name_width = 18; + let kind_width = 8; + let desc_width = width.saturating_sub(name_width + kind_width + 5); + ListItem::new(Line::from(vec![ + Span::styled(marker, Style::default().fg(Color::Yellow)), + Span::raw(" "), + Span::styled( + fit_display(&entry.name, name_width), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + fit_display(kind, kind_width), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), + Span::styled( + truncate_display(&desc, desc_width), + Style::default().fg(Color::Cyan), + ), + ])) + }) + .collect() + }; + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Views")) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + frame.render_widget(Clear, area); + frame.render_stateful_widget(list, area, &mut self.saved_view_state); + } + + fn draw_delete_view_confirm_modal(&self, frame: &mut Frame<'_>) { + let area = centered_rect(56, 7, frame.area()); + let name = self + .pending_delete_view + .as_deref() + .unwrap_or(""); + let name = truncate_display(name, usize::from(area.width).saturating_sub(24)); + let lines = vec![ + Line::from(Span::styled( + format!("Delete saved view `{name}`?"), + Style::default().fg(Color::LightRed), + )), + Line::raw(""), + Line::from(Span::styled( + "y delete n/Esc cancel", + Style::default().fg(Color::Yellow), + )), + ]; + let modal = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title("Delete View")) + .wrap(Wrap { trim: false }); + frame.render_widget(Clear, area); + frame.render_widget(modal, area); + } + + fn draw_save_view_modal(&self, frame: &mut Frame<'_>) { + let area = centered_rect(64, 9, frame.area()); + let filter = self.active_filter_display(); + let lines = vec![ + Line::from(Span::styled( + if filter.is_empty() { + "Saving current open-ticket view with no extra filters.".to_string() + } else { + format!("Saving current view: {filter}") + }, + Style::default().fg(Color::DarkGray), + )), + Line::raw(""), + Line::from(self.input.as_str()), + Line::raw(""), + Line::from(Span::styled( + "Enter save Esc cancel", + Style::default().fg(Color::Yellow), + )), + ]; + let modal = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title("Save View")) + .wrap(Wrap { trim: false }); + frame.render_widget(Clear, area); + frame.render_widget(modal, area); + } + + fn draw_quit_hint_modal(&self, frame: &mut Frame<'_>) { + let area = centered_rect(42, 7, frame.area()); + let lines = vec![ + Line::from(Span::styled( + "Esc backs out of views and filters.", + Style::default().fg(Color::Cyan), + )), + Line::raw(""), + Line::from(vec![ + Span::raw("Hit "), + Span::styled( + "q", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" to quit."), + ]), + ]; + let modal = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title("Quit")) + .wrap(Wrap { trim: false }); + frame.render_widget(Clear, area); + frame.render_widget(modal, area); + } + fn draw_state_modal(&self, frame: &mut Frame<'_>) { let area = centered_rect(48, 15, frame.area()); let lines = vec![ @@ -505,63 +1051,339 @@ impl App { frame.render_widget(modal, area); } - fn handle_key(&mut self, key: KeyEvent) -> Result { - let quit = match self.mode { + fn draw_help_modal(&self, frame: &mut Frame<'_>) { + let area = centered_rect(88, 22, frame.area()); + let mut lines = Vec::new(); + + help_section(&mut lines, "General"); + lines.push(help_columns( + ("?", "toggle help"), + Some(("Esc", "back / cancel")), + )); + lines.push(help_columns(("q", "quit"), Some(("S", "sync tickets")))); + + match self.mode { Mode::Filter => { - self.handle_filter_key(key); - false + help_section(&mut lines, "Search Filter"); + lines.push(help_columns( + ("type", "search text"), + Some(("Backspace", "delete char")), + )); + lines.push(help_columns(("Enter", "apply"), Some(("Esc", "finish")))); } - Mode::Input(_) => { - self.handle_input_key(key)?; - false + Mode::Tags => { + help_section(&mut lines, "Tag Filter"); + lines.push(help_columns( + ("j/k", "move tag"), + Some(("Space", "check tag")), + )); + lines.push(help_columns( + ("a", "all / any mode"), + Some(("c", "clear tags")), + )); + lines.push(help_columns(("Enter", "apply"), Some(("Esc", "finish")))); } - Mode::State => { - self.handle_state_key(key)?; - false + Mode::SavedViews => { + help_section(&mut lines, "Views"); + lines.push(help_columns( + ("j/k", "move view"), + Some(("Enter", "apply view")), + )); + lines.push(help_columns( + ("d", "default view"), + Some(("D", "delete saved view")), + )); + lines.push(help_columns(("Esc", "cancel"), None)); } - Mode::Create => { - self.handle_create_key(key)?; - false + Mode::ConfirmDeleteView => { + help_section(&mut lines, "Delete View"); + lines.push(help_columns(("y", "delete"), Some(("n/Esc", "cancel")))); } - Mode::Normal => self.handle_normal_key(key), - }; - Ok(quit) - } - - fn handle_normal_key(&mut self, key: KeyEvent) -> bool { - self.status = None; - match key.code { - KeyCode::Char('q') | KeyCode::Esc => true, - KeyCode::Char('/') => { - self.mode = Mode::Filter; - false + Mode::SaveView => { + help_section(&mut lines, "Save View"); + lines.push(help_columns( + ("type", "view name"), + Some(("Backspace", "delete char")), + )); + lines.push(help_columns(("Enter", "save"), Some(("Esc", "cancel")))); } - KeyCode::Char('n') => { - self.begin_create(); - false + Mode::Input(kind) => { + help_section(&mut lines, &format!("Editing {}", kind.label())); + lines.push(help_columns( + ("type", "new value"), + Some(("Backspace", "delete char")), + )); + lines.push(help_columns(("Enter", "apply"), Some(("Esc", "cancel")))); } - KeyCode::Down | KeyCode::Char('j') => { - self.next(); - false + Mode::State => { + help_section(&mut lines, "Open States"); + lines.push(help_columns(("n", "new"), Some(("a", "assigned")))); + lines.push(help_columns(("p", "in progress"), Some(("b", "blocked")))); + lines.push(help_columns(("v", "review"), None)); + + help_section(&mut lines, "Closed States"); + lines.push(help_columns(("r", "resolved"), Some(("w", "wontfix")))); + lines.push(help_columns(("u", "duplicate"), Some(("i", "invalid")))); } - KeyCode::Up | KeyCode::Char('k') => { - self.previous(); - false + Mode::Create => { + help_section(&mut lines, "New Ticket"); + lines.push(help_columns( + ("Tab/Down", "next field"), + Some(("Shift-Tab/Up", "previous field")), + )); + lines.push(help_columns(("Enter", "create"), Some(("Esc", "cancel")))); } - KeyCode::Enter => { - self.open_selected(); - false + Mode::Normal if self.comments_mode => { + help_section(&mut lines, "Comments"); + lines.push(help_columns( + ("j/k", "move comment"), + Some(("c", "add comment")), + )); + lines.push(help_columns(("Esc", "detail view"), None)); } - KeyCode::Char('t') => { - self.begin_input(InputKind::Title); - false + Mode::Normal if self.view == ViewMode::Board && self.detail.is_none() => { + help_section(&mut lines, "Navigation"); + lines.push(help_columns( + ("h/l", "move columns"), + Some(("j/k", "move tickets")), + )); + lines.push(help_columns( + ("Left/Right", "move columns"), + Some(("Up/Down", "move tickets")), + )); + lines.push(help_columns(("Enter", "details"), Some(("b", "list view")))); + + help_section(&mut lines, "Edit Ticket"); + lines.push(help_columns(("C", "claim"), Some(("s", "state")))); + lines.push(help_columns( + ("e", "edit title/body"), + Some(("i", "edit spec")), + )); + lines.push(help_columns(("c", "comment"), None)); + lines.push(help_columns(("p", "priority"), Some(("o", "points")))); + lines.push(help_columns(("+/-", "edit tags"), None)); } - KeyCode::Char('d') => { - self.begin_input(InputKind::Description); - false + Mode::Normal => { + help_section(&mut lines, "Navigation"); + lines.push(help_columns( + ("j/k", "move tickets"), + Some(("Up/Down", "move tickets")), + )); + lines.push(help_columns( + ("Enter", "details"), + Some(("b", "board view")), + )); + lines.push(help_columns(("m", "comments"), Some(("n", "new ticket")))); + + help_section(&mut lines, "Views & Filters"); + lines.push(help_columns( + ("/", "search text"), + Some(("g", "tag picker")), + )); + lines.push(help_columns(("v", "saved views"), Some(("V", "save view")))); + + help_section(&mut lines, "Edit Ticket"); + lines.push(help_columns(("C", "claim"), Some(("s", "state")))); + lines.push(help_columns( + ("e", "edit title/body"), + Some(("i", "edit spec")), + )); + lines.push(help_columns(("c", "comment"), None)); + lines.push(help_columns(("p", "priority"), Some(("o", "points")))); + lines.push(help_columns(("+/-", "edit tags"), None)); } - KeyCode::Char('c') => { - self.begin_input(InputKind::Comment); + } + + lines.push(Line::raw("")); + lines.push(help_note("Close help with Esc, ?, Enter, or q.")); + + let modal = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title("Help")) + .wrap(Wrap { trim: false }); + frame.render_widget(Clear, area); + frame.render_widget(modal, area); + } + + fn handle_key( + &mut self, + key: KeyEvent, + terminal: &mut Terminal>, + ) -> Result { + if self.show_quit_hint { + return match key.code { + KeyCode::Char('q') => Ok(true), + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => { + self.show_quit_hint = false; + Ok(false) + } + _ => Ok(false), + }; + } + if self.show_help { + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') | KeyCode::Char('q') => { + self.show_help = false; + } + _ => {} + } + return Ok(false); + } + if key.code == KeyCode::Char('?') { + self.show_help = true; + return Ok(false); + } + + let quit = match self.mode { + Mode::Filter => { + self.handle_filter_key(key); + false + } + Mode::Tags => { + self.handle_tags_key(key); + false + } + Mode::SavedViews => { + self.handle_saved_views_key(key)?; + false + } + Mode::ConfirmDeleteView => { + self.handle_delete_view_confirm_key(key)?; + false + } + Mode::SaveView => { + self.handle_save_view_key(key)?; + false + } + Mode::Input(_) => { + self.handle_input_key(key)?; + false + } + Mode::State => { + self.handle_state_key(key)?; + false + } + Mode::Create => { + self.handle_create_key(key)?; + false + } + Mode::Normal => self.handle_normal_key(key, terminal)?, + }; + Ok(quit) + } + + fn handle_normal_key( + &mut self, + key: KeyEvent, + terminal: &mut Terminal>, + ) -> Result { + self.status = None; + let quit = match key.code { + KeyCode::Char('q') => true, + KeyCode::Esc => { + if self.comments_mode { + self.comments_mode = false; + false + } else if self.detail.is_some() { + self.detail = None; + self.comments_mode = false; + false + } else if self.view == ViewMode::Board { + self.view = ViewMode::List; + false + } else if self.has_active_view_filters() { + self.clear_view_filters()?; + false + } else { + self.show_quit_hint = true; + false + } + } + KeyCode::Char('/') => { + self.mode = Mode::Filter; + false + } + KeyCode::Char('g') => { + self.begin_tag_filter(); + false + } + KeyCode::Char('v') => { + self.begin_saved_views(); + false + } + KeyCode::Char('V') => { + self.begin_save_view(); + false + } + KeyCode::Char('b') => { + self.toggle_view(); + false + } + KeyCode::Char('n') => { + self.begin_create(); + false + } + KeyCode::Down | KeyCode::Char('j') => { + if self.comments_mode { + self.next_comment(); + } else if self.view == ViewMode::Board && self.detail.is_none() { + self.next_board_ticket(); + } else { + self.next(); + } + false + } + KeyCode::Up | KeyCode::Char('k') => { + if self.comments_mode { + self.previous_comment(); + } else if self.view == ViewMode::Board && self.detail.is_none() { + self.previous_board_ticket(); + } else { + self.previous(); + } + false + } + KeyCode::Right | KeyCode::Char('l') => { + if self.view == ViewMode::Board && self.detail.is_none() { + self.next_board_column(); + } + false + } + KeyCode::Left | KeyCode::Char('h') => { + if self.view == ViewMode::Board && self.detail.is_none() { + self.previous_board_column(); + } + false + } + KeyCode::Enter => { + self.open_selected(); + false + } + KeyCode::Char('e') => { + self.edit_ticket_in_editor(terminal)?; + false + } + KeyCode::Char('i') => { + self.edit_spec_in_editor(terminal)?; + false + } + KeyCode::Char('c') => { + self.begin_input(InputKind::Comment); + false + } + KeyCode::Char('C') => { + self.claim_selected()?; + false + } + KeyCode::Char('m') => { + self.enter_comments_mode(); + false + } + KeyCode::Char('p') => { + self.begin_input(InputKind::Priority); + false + } + KeyCode::Char('o') => { + self.begin_input(InputKind::Points); false } KeyCode::Char('+') | KeyCode::Char('=') => { @@ -580,8 +1402,13 @@ impl App { } false } + KeyCode::Char('S') => { + self.start_sync(); + false + } _ => false, - } + }; + Ok(quit) } fn handle_filter_key(&mut self, key: KeyEvent) { @@ -601,6 +1428,88 @@ impl App { } } + fn handle_tags_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Enter | KeyCode::Esc => { + self.mode = Mode::Normal; + } + KeyCode::Down | KeyCode::Char('j') => self.next_tag_filter(), + KeyCode::Up | KeyCode::Char('k') => self.previous_tag_filter(), + KeyCode::Char(' ') => self.toggle_selected_tag_filter(), + KeyCode::Char('a') => { + self.tag_filter_match_all = !self.tag_filter_match_all; + self.apply_filter(); + } + KeyCode::Char('c') => { + self.tag_filter.clear(); + self.apply_filter(); + } + _ => {} + } + } + + fn handle_saved_views_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Enter => { + self.apply_selected_saved_view()?; + self.mode = Mode::Normal; + } + KeyCode::Esc => { + self.mode = Mode::Normal; + } + KeyCode::Down | KeyCode::Char('j') => self.next_saved_view(), + KeyCode::Up | KeyCode::Char('k') => self.previous_saved_view(), + KeyCode::Char('d') => { + self.clear_view_filters()?; + self.mode = Mode::Normal; + } + KeyCode::Char('D') => self.begin_delete_saved_view(), + _ => {} + } + Ok(()) + } + + fn handle_delete_view_confirm_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => self.delete_pending_saved_view()?, + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.pending_delete_view = None; + self.mode = Mode::SavedViews; + self.status = Some("Cancelled.".to_string()); + } + _ => {} + } + Ok(()) + } + + fn handle_save_view_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { + self.mode = Mode::Normal; + self.input.clear(); + self.status = Some("Cancelled.".to_string()); + } + KeyCode::Enter => { + let name = self.input.trim().to_string(); + if name.is_empty() { + self.status = Some("View name cannot be empty.".to_string()); + return Ok(()); + } + self.save_current_view(&name)?; + self.mode = Mode::Normal; + self.input.clear(); + } + KeyCode::Backspace => { + self.input.pop(); + } + KeyCode::Char(c) => { + self.input.push(c); + } + _ => {} + } + Ok(()) + } + fn handle_input_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { @@ -694,111 +1603,512 @@ impl App { self.mode = Mode::Create; } - fn begin_input(&mut self, kind: InputKind) { - let Some(ticket) = self.selected_ticket() else { - self.status = Some("Select a ticket first.".to_string()); - return; - }; + fn begin_tag_filter(&mut self) { + let tags = self.available_tags(); + if tags.is_empty() { + self.tag_picker_state.select(None); + } else { + let selected = self + .tag_picker_state + .selected() + .unwrap_or(0) + .min(tags.len() - 1); + self.tag_picker_state.select(Some(selected)); + } + self.mode = Mode::Tags; + } - self.input = match kind { - InputKind::Title => ticket.title.clone(), - InputKind::Description => ticket.description.clone().unwrap_or_default(), - InputKind::Comment | InputKind::AddTags | InputKind::RemoveTags => String::new(), - }; - self.mode = Mode::Input(kind); + fn begin_saved_views(&mut self) { + let views = self.view_entries(); + let selected = self + .saved_view_state + .selected() + .unwrap_or(0) + .min(views.len().saturating_sub(1)); + self.saved_view_state.select(Some(selected)); + self.mode = Mode::SavedViews; } - fn submit_input(&mut self) -> Result { - let Some(ticket) = self.selected_ticket() else { - self.status = Some("Select a ticket first.".to_string()); - return Ok(false); - }; - let id = ticket.id; - let Mode::Input(kind) = self.mode else { - return Ok(false); + fn begin_delete_saved_view(&mut self) { + let views = self.view_entries(); + let Some(entry) = self + .saved_view_state + .selected() + .and_then(|selected| views.get(selected)) + else { + self.status = Some("No view selected.".to_string()); + return; }; - - match kind { - InputKind::Title => { - let title = self.input.trim(); - if title.is_empty() { - self.status = Some("Title cannot be empty.".to_string()); - return Ok(false); - } - self.store.set_title(&id, title)?; - self.status = Some("Updated title.".to_string()); - } - InputKind::Description => { - let description = self.input.trim(); - self.store.set_description( - &id, - if description.is_empty() { - None - } else { - Some(description) - }, - )?; - self.status = Some("Updated description.".to_string()); - } - InputKind::Comment => { - let body = self.input.trim(); - if body.is_empty() { - self.status = Some("Comment cannot be empty.".to_string()); - return Ok(false); - } - self.store.add_comment(&id, body)?; - self.status = Some("Added comment.".to_string()); - } - InputKind::AddTags => { - let tags = split_tags(&self.input); - if tags.is_empty() { - self.status = Some("Enter at least one tag.".to_string()); - return Ok(false); - } - for tag in tags { - self.store.add_tag(&id, &tag)?; - } - self.status = Some("Added tag(s).".to_string()); - } - InputKind::RemoveTags => { - let tags = split_tags(&self.input); - if tags.is_empty() { - self.status = Some("Enter at least one tag.".to_string()); - return Ok(false); - } - for tag in tags { - self.store.remove_tag(&id, &tag)?; - } - self.status = Some("Removed tag(s).".to_string()); - } + if entry.kind != ViewKind::Saved { + self.status = Some("Built-in views cannot be deleted.".to_string()); + return; } + self.pending_delete_view = Some(entry.name.clone()); + self.mode = Mode::ConfirmDeleteView; + } - self.reload(Some(id))?; - Ok(true) + fn begin_save_view(&mut self) { + self.input.clear(); + self.mode = Mode::SaveView; } - fn set_lifecycle(&mut self, status: TicketStatus, state: TicketState) -> Result<()> { - let Some(ticket) = self.selected_ticket() else { - self.status = Some("Select a ticket first.".to_string()); - self.mode = Mode::Normal; + fn save_current_view(&mut self, name: &str) -> Result<()> { + let git_dir = self.store.session().repo_git_dir(); + let mut state = State::load().unwrap_or_default(); + let view = self.current_saved_view(); + let desc = crate::commands::view::describe_view(&view); + state.set_last_filters(&git_dir, view.clone()); + state.save_view(&git_dir, name, view); + state.save()?; + self.status = Some(format!("Saved view `{name}`: {desc}")); + Ok(()) + } + + fn delete_pending_saved_view(&mut self) -> Result<()> { + let Some(name) = self.pending_delete_view.take() else { + self.status = Some("No view selected.".to_string()); + self.mode = Mode::SavedViews; return Ok(()); }; - let id = ticket.id; - self.store.set_lifecycle(&id, status, state)?; - self.status = Some(format!("Changed lifecycle to {status}:{state}.")); - self.mode = Mode::Normal; - self.reload(Some(id))?; + let git_dir = self.store.session().repo_git_dir(); + let mut state = State::load().unwrap_or_default(); + if state.delete_view(&git_dir, &name) { + state.save()?; + if self.active_view_name.as_deref() == Some(name.as_str()) { + self.active_view_name = None; + } + let views = self.view_entries(); + if views.is_empty() { + self.saved_view_state.select(None); + } else { + let selected = self + .saved_view_state + .selected() + .unwrap_or(0) + .min(views.len().saturating_sub(1)); + self.saved_view_state.select(Some(selected)); + } + self.status = Some(format!("Deleted view `{name}`.")); + } else { + self.status = Some(format!("No view named `{name}`.")); + } + self.mode = Mode::SavedViews; Ok(()) } - fn create_ticket(&mut self) -> Result { - let title = self.new_ticket.title.trim(); - if title.is_empty() { - self.status = Some("Title cannot be empty.".to_string()); - return Ok(false); + fn current_saved_view(&self) -> SavedView { + let tags = self.tag_filter.iter().cloned().collect::>(); + SavedView { + created_at: None, + status: self.base_status.map(|status| status.as_str().to_string()), + state: self.base_state.map(|state| state.as_str().to_string()), + tag: (tags.len() == 1).then(|| tags[0].clone()), + tags, + tag_match_all: self.tag_filter_match_all, + assigned: self.assigned_filter.clone(), + only_tagged: self.only_tagged, + search: optional_trimmed(&self.filter).map(ToString::to_string), + order: self.sort_order.map(|_| "created.desc".to_string()), + all: self.base_status.is_none() && self.base_state.is_none(), + subissues: !self.hide_subissues, + limit: 0, } + } - let ticket = self.store.create( + fn view_entries(&self) -> Vec { + let builtins = builtin_views(self.store.email()); + let git_dir = self.store.session().repo_git_dir(); + let mut saved: Vec<_> = State::load() + .map(|state| state.list_views(&git_dir)) + .unwrap_or_default() + .into_iter() + .map(|(name, view)| ViewEntry { + name, + view, + kind: ViewKind::Saved, + }) + .collect(); + if saved.is_empty() { + builtins + } else { + saved.extend(builtins); + saved + } + } + + fn next_saved_view(&mut self) { + let views = self.view_entries(); + let selected = self.saved_view_state.selected().unwrap_or(0); + self.saved_view_state + .select(Some((selected + 1) % views.len())); + } + + fn previous_saved_view(&mut self) { + let views = self.view_entries(); + let selected = self.saved_view_state.selected().unwrap_or(0); + let previous = selected + .checked_sub(1) + .unwrap_or_else(|| views.len().saturating_sub(1)); + self.saved_view_state.select(Some(previous)); + } + + fn apply_selected_saved_view(&mut self) -> Result<()> { + let views = self.view_entries(); + let Some(entry) = self + .saved_view_state + .selected() + .and_then(|selected| views.get(selected)) + else { + self.status = Some("No view selected.".to_string()); + return Ok(()); + }; + self.apply_saved_view(&entry.name, &entry.view) + } + + fn apply_saved_view(&mut self, name: &str, view: &SavedView) -> Result<()> { + self.active_view_name = Some(name.to_string()); + self.base_status = if view.all || view.state.is_some() { + None + } else if let Some(status) = view.status.as_deref() { + Some(TicketStatus::parse(status)?) + } else { + Some(TicketStatus::Open) + }; + self.base_state = None; + if let Some(state) = view.state.as_deref() { + let lifecycle = TicketLifecycle::parse(state)?; + self.base_status = Some(lifecycle.status); + if TicketStatus::parse(state).is_err() { + self.base_state = Some(lifecycle.state); + } + } + self.assigned_filter = view.assigned.clone(); + self.only_tagged = view.only_tagged; + self.hide_subissues = !view.subissues; + self.filter = view.search.clone().unwrap_or_default(); + self.tag_filter = saved_view_tags(view).into_iter().collect(); + self.tag_filter_match_all = view.tag_match_all; + self.sort_order = match view.order.as_deref() { + Some(spec) => Some( + SortOrder::parse(spec) + .ok_or_else(|| anyhow::anyhow!("unknown sort order `{spec}`"))?, + ), + None => None, + }; + self.detail = None; + self.comments_mode = false; + self.view = ViewMode::List; + self.reload(None)?; + self.status = Some(format!("Loaded view `{name}`.")); + Ok(()) + } + + fn clear_view_filters(&mut self) -> Result<()> { + self.active_view_name = None; + self.base_status = Some(TicketStatus::Open); + self.base_state = None; + self.assigned_filter = None; + self.only_tagged = false; + self.hide_subissues = false; + self.sort_order = None; + self.filter.clear(); + self.tag_filter.clear(); + self.tag_filter_match_all = true; + self.detail = None; + self.comments_mode = false; + self.reload(None)?; + self.status = Some("Cleared to default view.".to_string()); + Ok(()) + } + + fn has_active_view_filters(&self) -> bool { + self.active_view_name.is_some() + || self.base_status != Some(TicketStatus::Open) + || self.base_state.is_some() + || self.assigned_filter.is_some() + || self.only_tagged + || self.hide_subissues + || self.sort_order.is_some() + || !self.filter.is_empty() + || !self.tag_filter.is_empty() + || !self.tag_filter_match_all + } + + fn edit_ticket_in_editor( + &mut self, + terminal: &mut Terminal>, + ) -> Result<()> { + let Some(ticket) = self.selected_ticket() else { + self.status = Some("Select a ticket first.".to_string()); + return Ok(()); + }; + let id = ticket.id; + let initial = ticket_edit_body(ticket); + + suspend_terminal(terminal)?; + let edited = editor::capture_with_initial( + "Edit the title on the first line. Remaining non-comment lines become the description.", + &initial, + ); + resume_terminal(terminal)?; + + match edited? { + Some(edited) => { + let (title, description) = editor::parse_ticket_edit(&edited)?; + self.store.set_title(&id, &title)?; + self.store.set_description(&id, description.as_deref())?; + self.status = Some("Updated ticket.".to_string()); + } + _ => { + self.status = Some("Cancelled.".to_string()); + } + } + + self.reload(Some(id))?; + Ok(()) + } + + fn edit_spec_in_editor( + &mut self, + terminal: &mut Terminal>, + ) -> Result<()> { + let Some(ticket) = self.selected_ticket() else { + self.status = Some("Select a ticket first.".to_string()); + return Ok(()); + }; + let id = ticket.id; + let initial = ticket.spec.clone().unwrap_or_default(); + + suspend_terminal(terminal)?; + let edited = editor::capture_with_initial( + "Write the implementation spec below. Lines starting with # are ignored.", + &initial, + ); + resume_terminal(terminal)?; + + match edited? { + Some(spec) if !spec.trim().is_empty() => { + self.store.set_spec(&id, Some(spec.trim()))?; + self.status = Some("Updated spec.".to_string()); + } + _ => { + self.store.set_spec(&id, None)?; + self.status = Some("Cleared spec.".to_string()); + } + } + + self.reload(Some(id))?; + Ok(()) + } + + fn start_sync(&mut self) { + if self.sync.is_some() { + self.status = Some("Sync already running.".to_string()); + return; + } + + let selected_id = self.selected_ticket().map(|ticket| ticket.id); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let result = run_ti_sync_command(); + let _ = sender.send(result); + }); + + self.sync = Some(SyncState { + receiver, + selected_id, + started_at: Instant::now(), + }); + self.status = Some("Syncing tickets...".to_string()); + } + + fn poll_sync(&mut self) -> Result<()> { + let completed = match &self.sync { + Some(sync) => match sync.receiver.try_recv() { + Ok(result) => Some((result, sync.selected_id)), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(( + Err(anyhow::anyhow!( + "sync worker stopped before reporting a result" + )), + sync.selected_id, + )), + }, + None => None, + }; + + let Some((result, selected_id)) = completed else { + return Ok(()); + }; + + self.sync = None; + match result { + Ok(result) => { + self.reload(selected_id)?; + self.status = Some(result.summary); + } + Err(err) => { + self.status = Some(format!("Sync failed: {err}")); + } + } + Ok(()) + } + + fn begin_input(&mut self, kind: InputKind) { + let Some(ticket) = self.selected_ticket() else { + self.status = Some("Select a ticket first.".to_string()); + return; + }; + + self.input = match kind { + InputKind::Priority => String::new(), + InputKind::Points => ticket + .points + .map(|value| value.to_string()) + .unwrap_or_default(), + InputKind::Comment | InputKind::AddTags | InputKind::RemoveTags => String::new(), + }; + self.mode = Mode::Input(kind); + } + + fn priority_range_display(&self) -> String { + let mut priorities = self + .visible + .iter() + .filter_map(|idx| self.tickets[*idx].priority); + let Some(first) = priorities.next() else { + return "No priorities set.".to_string(); + }; + let (min, max) = priorities.fold((first, first), |(min, max), priority| { + (min.min(priority), max.max(priority)) + }); + if min == max { + format!("Current priority: {min}.") + } else { + format!("Current priority range: {min}-{max}.") + } + } + + fn submit_input(&mut self) -> Result { + let Some(ticket) = self.selected_ticket() else { + self.status = Some("Select a ticket first.".to_string()); + return Ok(false); + }; + let id = ticket.id; + let Mode::Input(kind) = self.mode else { + return Ok(false); + }; + + match kind { + InputKind::Comment => { + let body = self.input.trim(); + if body.is_empty() { + self.status = Some("Comment cannot be empty.".to_string()); + return Ok(false); + } + self.store.add_comment(&id, body)?; + self.status = Some("Added comment.".to_string()); + } + InputKind::Priority => { + let priority = match parse_optional_i64(&self.input, "priority") { + Ok(priority) => priority, + Err(err) => { + self.status = Some(err.to_string()); + return Ok(false); + } + }; + self.store.set_priority(&id, priority)?; + self.status = Some(match priority { + Some(value) => format!("Set priority to {value}."), + None => "Cleared priority.".to_string(), + }); + } + InputKind::Points => { + let points = match parse_optional_i64(&self.input, "points") { + Ok(points) => points, + Err(err) => { + self.status = Some(err.to_string()); + return Ok(false); + } + }; + self.store.set_points(&id, points)?; + self.status = Some(match points { + Some(value) => format!("Set points to {value}."), + None => "Cleared points.".to_string(), + }); + } + InputKind::AddTags => { + let tags = split_tags(&self.input); + if tags.is_empty() { + self.status = Some("Enter at least one tag.".to_string()); + return Ok(false); + } + for tag in tags { + self.store.add_tag(&id, &tag)?; + } + self.status = Some("Added tag(s).".to_string()); + } + InputKind::RemoveTags => { + let tags = split_tags(&self.input); + if tags.is_empty() { + self.status = Some("Enter at least one tag.".to_string()); + return Ok(false); + } + for tag in tags { + self.store.remove_tag(&id, &tag)?; + } + self.status = Some("Removed tag(s).".to_string()); + } + } + + self.reload(Some(id))?; + if kind == InputKind::Comment { + if let Some(ticket) = self.selected_ticket() { + if !ticket.comments.is_empty() { + self.comment_state.select(Some(ticket.comments.len() - 1)); + } + } + } + Ok(true) + } + + fn set_lifecycle(&mut self, status: TicketStatus, state: TicketState) -> Result<()> { + let Some(ticket) = self.selected_ticket() else { + self.status = Some("Select a ticket first.".to_string()); + self.mode = Mode::Normal; + return Ok(()); + }; + let id = ticket.id; + self.store.set_lifecycle(&id, status, state)?; + self.status = Some(format!("Changed lifecycle to {status}:{state}.")); + self.mode = Mode::Normal; + self.reload(Some(id))?; + Ok(()) + } + + fn claim_selected(&mut self) -> Result<()> { + let Some(ticket) = self.selected_ticket() else { + self.status = Some("Select a ticket first.".to_string()); + return Ok(()); + }; + let id = ticket.id; + let email = self.store.email().to_string(); + self.store.set_assigned(&id, Some(&email))?; + self.store + .set_lifecycle(&id, TicketStatus::Open, TicketState::Assigned)?; + self.status = Some(format!("Claimed ticket as {email}.")); + self.reload(Some(id))?; + Ok(()) + } + + fn create_ticket(&mut self) -> Result { + let title = self.new_ticket.title.trim(); + if title.is_empty() { + self.status = Some("Title cannot be empty.".to_string()); + return Ok(false); + } + + let ticket = self.store.create( title, NewTicketOpts { comment: None, @@ -820,6 +2130,98 @@ impl App { Ok(true) } + fn active_filter_display(&self) -> String { + let mut parts = Vec::new(); + if let Some(name) = &self.active_view_name { + parts.push(format!("view: {name}")); + } + if let Some(state) = self.base_state { + parts.push(format!("state: {}", state.as_str())); + } else if let Some(status) = self.base_status { + if status != TicketStatus::Open { + parts.push(format!("status: {}", status.as_str())); + } + } else { + parts.push("all".to_string()); + } + if let Some(assigned) = &self.assigned_filter { + parts.push(format!("assigned: {assigned}")); + } + if self.only_tagged { + parts.push("tagged only".to_string()); + } + if self.hide_subissues { + parts.push("no subissues".to_string()); + } + if !self.filter.is_empty() { + parts.push(format!("\"{}\"", self.filter)); + } + if !self.tag_filter.is_empty() { + let mode = if self.tag_filter_match_all { + "all" + } else { + "any" + }; + let tags = self + .tag_filter + .iter() + .cloned() + .collect::>() + .join(", "); + parts.push(format!("{mode} tags: {tags}")); + } + parts.join("; ") + } + + fn available_tags(&self) -> Vec<(String, usize)> { + let mut counts = std::collections::BTreeMap::::new(); + for ticket in &self.tickets { + for tag in &ticket.tags { + *counts.entry(tag.clone()).or_default() += 1; + } + } + counts.into_iter().collect() + } + + fn next_tag_filter(&mut self) { + let tags = self.available_tags(); + if tags.is_empty() { + self.tag_picker_state.select(None); + return; + } + let selected = self.tag_picker_state.selected().unwrap_or(0); + self.tag_picker_state + .select(Some((selected + 1) % tags.len())); + } + + fn previous_tag_filter(&mut self) { + let tags = self.available_tags(); + if tags.is_empty() { + self.tag_picker_state.select(None); + return; + } + let selected = self.tag_picker_state.selected().unwrap_or(0); + let previous = selected + .checked_sub(1) + .unwrap_or_else(|| tags.len().saturating_sub(1)); + self.tag_picker_state.select(Some(previous)); + } + + fn toggle_selected_tag_filter(&mut self) { + let tags = self.available_tags(); + let Some((tag, _)) = self + .tag_picker_state + .selected() + .and_then(|selected| tags.get(selected)) + else { + return; + }; + if !self.tag_filter.remove(tag) { + self.tag_filter.insert(tag.clone()); + } + self.apply_filter(); + } + fn apply_filter(&mut self) { let needle = self.filter.to_ascii_lowercase(); self.visible = self @@ -827,7 +2229,13 @@ impl App { .iter() .enumerate() .filter_map(|(idx, ticket)| { - if needle.is_empty() || ticket_matches(ticket, &needle) { + if (needle.is_empty() || ticket_matches(ticket, &needle)) + && ticket_matches_tag_filter( + ticket, + &self.tag_filter, + self.tag_filter_match_all, + ) + { Some(idx) } else { None @@ -836,6 +2244,7 @@ impl App { .collect(); self.detail = self.detail.filter(|idx| self.visible.contains(idx)); + self.sync_board_selection(); if self.visible.is_empty() { self.list_state.select(None); } else { @@ -855,6 +2264,7 @@ impl App { let selected = self.list_state.selected().unwrap_or(0); let next = (selected + 1) % self.visible.len(); self.list_state.select(Some(next)); + self.sync_open_detail(); } fn previous(&mut self) { @@ -866,11 +2276,25 @@ impl App { .checked_sub(1) .unwrap_or_else(|| self.visible.len().saturating_sub(1)); self.list_state.select(Some(previous)); + self.sync_open_detail(); + } + + fn toggle_view(&mut self) { + self.view = match self.view { + ViewMode::List => ViewMode::Board, + ViewMode::Board => ViewMode::List, + }; + self.sync_board_to_list_selection(); } fn open_selected(&mut self) { - if let Some(selected) = self.list_state.selected() { - self.detail = self.visible.get(selected).copied(); + if let Some(idx) = self.selected_ticket_index() { + self.detail = Some(idx); + if let Some(visible_pos) = self.visible.iter().position(|visible| *visible == idx) { + self.list_state.select(Some(visible_pos)); + } + self.comments_mode = false; + self.sync_comment_selection(); } } @@ -882,14 +2306,151 @@ impl App { { self.list_state.select(Some(visible_pos)); self.detail = self.visible.get(visible_pos).copied(); + self.comments_mode = false; + self.sync_comment_selection(); + } + } + + fn sync_open_detail(&mut self) { + if self.detail.is_some() { + self.open_selected(); + } + } + + fn board_column_tickets(&self, column: usize) -> Vec<&usize> { + let state = BOARD_STATES[column]; + self.visible + .iter() + .filter(|idx| self.tickets[**idx].state == state) + .collect() + } + + fn next_board_column(&mut self) { + self.board_column = (self.board_column + 1) % BOARD_STATES.len(); + self.sync_board_to_list_selection(); + } + + fn previous_board_column(&mut self) { + self.board_column = self + .board_column + .checked_sub(1) + .unwrap_or_else(|| BOARD_STATES.len() - 1); + self.sync_board_to_list_selection(); + } + + fn next_board_ticket(&mut self) { + let len = self.board_column_tickets(self.board_column).len(); + if len == 0 { + return; + } + self.board_rows[self.board_column] = (self.board_rows[self.board_column] + 1) % len; + self.sync_board_to_list_selection(); + } + + fn previous_board_ticket(&mut self) { + let len = self.board_column_tickets(self.board_column).len(); + if len == 0 { + return; + } + self.board_rows[self.board_column] = self.board_rows[self.board_column] + .checked_sub(1) + .unwrap_or_else(|| len.saturating_sub(1)); + self.sync_board_to_list_selection(); + } + + fn sync_board_selection(&mut self) { + for column in 0..BOARD_STATES.len() { + let len = self.board_column_tickets(column).len(); + if len == 0 { + self.board_rows[column] = 0; + } else { + self.board_rows[column] = self.board_rows[column].min(len - 1); + } + } + } + + fn sync_board_to_list_selection(&mut self) { + if let Some(idx) = self.selected_ticket_index() { + if let Some(visible_pos) = self.visible.iter().position(|visible| *visible == idx) { + self.list_state.select(Some(visible_pos)); + } + } + } + + fn enter_comments_mode(&mut self) { + if self.detail.is_none() { + self.status = Some("Open ticket details first.".to_string()); + return; + } + self.comments_mode = true; + self.sync_comment_selection(); + } + + fn next_comment(&mut self) { + let Some(ticket) = self.detail.map(|idx| &self.tickets[idx]) else { + return; + }; + if ticket.comments.is_empty() { + return; + } + let selected = self.comment_state.selected().unwrap_or(0); + self.comment_state + .select(Some((selected + 1) % ticket.comments.len())); + } + + fn previous_comment(&mut self) { + let Some(ticket) = self.detail.map(|idx| &self.tickets[idx]) else { + return; + }; + if ticket.comments.is_empty() { + return; } + let selected = self.comment_state.selected().unwrap_or(0); + let previous = selected + .checked_sub(1) + .unwrap_or_else(|| ticket.comments.len().saturating_sub(1)); + self.comment_state.select(Some(previous)); + } + + fn sync_comment_selection(&mut self) { + let Some(ticket) = self.detail.map(|idx| &self.tickets[idx]) else { + self.comments_mode = false; + self.comment_state.select(None); + return; + }; + if ticket.comments.is_empty() { + self.comment_state.select(None); + return; + } + let selected = self + .comment_state + .selected() + .unwrap_or(0) + .min(ticket.comments.len() - 1); + self.comment_state.select(Some(selected)); + } + + fn selected_comment<'a>(&self, ticket: &'a Ticket) -> Option<&'a Comment> { + self.comment_state + .selected() + .and_then(|idx| ticket.comments.get(idx)) } fn selected_ticket(&self) -> Option<&Ticket> { + self.selected_ticket_index().map(|idx| &self.tickets[idx]) + } + + fn selected_ticket_index(&self) -> Option { + if self.view == ViewMode::Board && self.detail.is_none() { + let tickets = self.board_column_tickets(self.board_column); + return tickets + .get(self.board_rows[self.board_column]) + .map(|idx| **idx); + } self.list_state .selected() .and_then(|selected| self.visible.get(selected)) - .map(|idx| &self.tickets[*idx]) + .copied() } } @@ -925,9 +2486,9 @@ impl NewTicketDraft { impl InputKind { fn label(self) -> &'static str { match self { - InputKind::Title => "title", - InputKind::Description => "description", InputKind::Comment => "comment", + InputKind::Priority => "priority", + InputKind::Points => "points", InputKind::AddTags => "add tags", InputKind::RemoveTags => "remove tags", } @@ -935,10 +2496,316 @@ impl InputKind { fn modal_height(self) -> u16 { match self { - InputKind::Description | InputKind::Comment => 14, - InputKind::Title | InputKind::AddTags | InputKind::RemoveTags => 9, + InputKind::Comment => 14, + InputKind::Priority + | InputKind::Points + | InputKind::AddTags + | InputKind::RemoveTags => 9, + } + } +} + +fn parse_optional_i64(raw: &str, label: &str) -> Result> { + let Some(value) = optional_trimmed(raw) else { + return Ok(None); + }; + value + .parse::() + .map(Some) + .map_err(|_| anyhow::anyhow!("{label} must be an integer")) +} + +fn compare_tui_tickets(a: &Ticket, b: &Ticket) -> std::cmp::Ordering { + priority_sort_key(a.priority) + .cmp(&priority_sort_key(b.priority)) + .then_with(|| b.created_at.cmp(&a.created_at)) + .then_with(|| a.id.cmp(&b.id)) +} + +fn priority_sort_key(priority: Option) -> (u8, i64) { + match priority { + Some(value) => (0, value), + None => (1, 0), + } +} + +fn run_ti_sync_command() -> Result { + let exe = std::env::current_exe().context("locating current ti executable")?; + let output = Command::new(exe) + .arg("sync") + .output() + .context("running ti sync")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + let message = first_non_empty_line(&stderr) + .or_else(|| first_non_empty_line(&stdout)) + .unwrap_or("ti sync failed"); + anyhow::bail!("{message}"); + } + + Ok(SyncResult { + summary: sync_summary(&stdout), + }) +} + +fn sync_summary(stdout: &str) -> String { + let pull = stdout + .lines() + .find(|line| line.starts_with("Pull:")) + .unwrap_or("Pull complete."); + let push = stdout + .lines() + .find(|line| line.starts_with("Push:")) + .unwrap_or("Push complete."); + format!("{pull} {push}") +} + +fn first_non_empty_line(value: &str) -> Option<&str> { + value.lines().map(str::trim).find(|line| !line.is_empty()) +} + +fn ticket_list_line( + ticket: &Ticket, + width: usize, + compact: bool, + current_user: &str, +) -> Line<'static> { + let short_id = ticket + .short_id() + .chars() + .take(LIST_ID_WIDTH) + .collect::(); + let meta = list_meta_display(ticket); + + ticket_list_line_from_parts( + &short_id, + &flatten_display(&ticket.title), + &meta, + if compact { None } else { Some(&ticket.tags) }, + ticket.assigned.as_deref() == Some(current_user), + width, + ) +} + +fn board_ticket_line(ticket: &Ticket, width: usize) -> Line<'static> { + let meta = priority_points_display(ticket); + let meta_width = meta + .as_deref() + .map(UnicodeWidthStr::width) + .unwrap_or_default(); + let gap_width = usize::from(meta_width > 0); + let title_width = width.saturating_sub(meta_width + gap_width); + let mut spans = Vec::new(); + if let Some(meta) = meta { + spans.push(Span::styled(meta, Style::default().fg(Color::Magenta))); + spans.push(Span::raw(" ".repeat(gap_width))); + } + spans.push(Span::raw(truncate_display( + &flatten_display(&ticket.title), + title_width, + ))); + Line::from(spans) +} + +fn priority_points_display(ticket: &Ticket) -> Option { + let mut parts = Vec::new(); + if let Some(priority) = ticket.priority { + parts.push(format!("p{priority}")); + } + if let Some(points) = ticket.points { + parts.push(points.to_string()); + } + (!parts.is_empty()).then(|| parts.join("/")) +} + +fn ticket_list_line_from_parts( + short_id: &str, + title: &str, + meta: &[(String, Style)], + tags: Option<&BTreeSet>, + assigned_to_current_user: bool, + width: usize, +) -> Line<'static> { + let id = truncate_display(short_id, width); + let id_width = UnicodeWidthStr::width(id.as_str()); + let star = if assigned_to_current_user { "*" } else { " " }; + let star_width = width + .saturating_sub(id_width) + .min(UnicodeWidthStr::width(star)); + let gap_width = width.saturating_sub(id_width + star_width).min(1); + let gap = " ".repeat(gap_width); + let mut leading = vec![ + Span::styled(id, Style::default().fg(Color::DarkGray)), + Span::styled( + truncate_display(star, star_width), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(gap), + ]; + let meta_width = push_meta_spans( + &mut leading, + meta, + width.saturating_sub(id_width + star_width + gap_width), + ); + let meta_gap_width = if meta_width > 0 { + width + .saturating_sub(id_width + star_width + gap_width + meta_width) + .min(1) + } else { + 0 + }; + let meta_gap = " ".repeat(meta_gap_width); + let content_width = + width.saturating_sub(id_width + star_width + gap_width + meta_width + meta_gap_width); + + if meta_width > 0 { + leading.push(Span::raw(meta_gap)); + } + + let Some(tags) = tags.filter(|tags| !tags.is_empty()) else { + leading.push(Span::raw(truncate_display(title, content_width))); + return Line::from(leading); + }; + + let title_full_width = UnicodeWidthStr::width(title); + let tag_count_width = tag_count_width(tags.len()); + let (title_budget, tag_budget) = if title_full_width + tag_count_width <= content_width { + ( + title_full_width, + content_width.saturating_sub(title_full_width), + ) + } else if tag_count_width < content_width { + (content_width - tag_count_width, tag_count_width) + } else { + (content_width, 0) + }; + let tag_spans = tag_spans(tags, tag_budget); + let tags_width = spans_width(&tag_spans); + let title = truncate_display(title, title_budget); + let title_width = UnicodeWidthStr::width(title.as_str()); + let padding_width = content_width.saturating_sub(title_width + tags_width); + + leading.push(Span::raw(title)); + leading.push(Span::raw(" ".repeat(padding_width))); + leading.extend(tag_spans); + Line::from(leading) +} + +fn list_meta_display(ticket: &Ticket) -> Vec<(String, Style)> { + vec![ + ( + fit_display(state_abbrev(ticket.state), LIST_STATE_WIDTH), + state_abbrev_style(ticket.state), + ), + ( + fit_display( + &relative_date(ticket.created_at, OffsetDateTime::now_utc()), + LIST_AGE_WIDTH, + ), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + ), + ( + fit_display( + &ticket + .priority + .map(|priority| format!("p{priority}")) + .unwrap_or_default(), + LIST_PRIORITY_WIDTH, + ), + Style::default().fg(Color::Magenta), + ), + ] +} + +fn state_abbrev(state: TicketState) -> &'static str { + match state { + TicketState::New => "NW", + TicketState::Assigned => "AS", + TicketState::InProgress => "IP", + TicketState::Blocked => "BL", + TicketState::Review => "RV", + TicketState::Resolved => "RS", + TicketState::Wontfix => "WF", + TicketState::Duplicate => "DP", + TicketState::Invalid => "IV", + } +} + +fn state_abbrev_style(state: TicketState) -> Style { + let color = match state { + TicketState::InProgress => Color::LightYellow, + TicketState::Blocked => Color::LightRed, + TicketState::Review => Color::LightBlue, + TicketState::Resolved => Color::LightGreen, + TicketState::Wontfix | TicketState::Duplicate | TicketState::Invalid => Color::DarkGray, + TicketState::New | TicketState::Assigned => Color::Gray, + }; + Style::default().fg(color).add_modifier(Modifier::BOLD) +} + +fn push_meta_spans( + spans: &mut Vec>, + meta: &[(String, Style)], + max_width: usize, +) -> usize { + let mut used = 0; + for (value, style) in meta { + if used > 0 { + if used >= max_width { + break; + } + spans.push(Span::raw(" ")); + used += 1; + } + + let value = truncate_display(value, max_width.saturating_sub(used)); + let value_width = UnicodeWidthStr::width(value.as_str()); + spans.push(Span::styled(value, *style)); + used += value_width; + } + used +} + +fn fit_display(value: &str, width: usize) -> String { + let truncated = truncate_display(value, width); + let padding = width.saturating_sub(UnicodeWidthStr::width(truncated.as_str())); + format!("{truncated}{}", " ".repeat(padding)) +} + +fn truncate_display(value: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(value) <= max_width { + return value.to_string(); + } + + let ellipsis = if max_width > 3 { "..." } else { "." }; + let ellipsis_width = UnicodeWidthStr::width(ellipsis); + let content_width = max_width.saturating_sub(ellipsis_width); + let mut out = String::new(); + let mut width = 0; + + for ch in value.chars() { + let char_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + char_width > content_width { + break; } + out.push(ch); + width += char_width; } + out.push_str(ellipsis); + out +} + +fn flatten_display(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") } fn ticket_matches(ticket: &Ticket, needle: &str) -> bool { @@ -951,6 +2818,37 @@ fn ticket_matches(ticket: &Ticket, needle: &str) -> bool { .contains(needle) } +fn ticket_edit_body(ticket: &Ticket) -> String { + let mut body = ticket.title.clone(); + if let Some(description) = &ticket.description { + body.push_str("\n\n"); + body.push_str(description); + } + body +} + +fn first_spec_line(spec: &str) -> &str { + spec.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("") +} + +fn ticket_matches_tag_filter( + ticket: &Ticket, + tags: &BTreeSet, + tag_filter_match_all: bool, +) -> bool { + if tags.is_empty() { + return true; + } + if tag_filter_match_all { + tags.iter().all(|tag| ticket.tags.contains(tag)) + } else { + tags.iter().any(|tag| ticket.tags.contains(tag)) + } +} + fn split_tags(raw: &str) -> Vec { raw.split(|c: char| c == ',' || c.is_whitespace()) .map(|s| s.trim().to_string()) @@ -958,6 +2856,60 @@ fn split_tags(raw: &str) -> Vec { .collect() } +fn saved_view_tags(view: &SavedView) -> Vec { + if !view.tags.is_empty() { + return view.tags.clone(); + } + view.tag.iter().cloned().collect() +} + +fn builtin_views(current_user: &str) -> Vec { + vec![ + ViewEntry { + name: "Default".to_string(), + view: SavedView { + status: Some("open".to_string()), + subissues: true, + tag_match_all: true, + ..Default::default() + }, + kind: ViewKind::BuiltIn, + }, + ViewEntry { + name: "Mine".to_string(), + view: SavedView { + status: Some("open".to_string()), + assigned: Some(current_user.to_string()), + subissues: true, + tag_match_all: true, + ..Default::default() + }, + kind: ViewKind::BuiltIn, + }, + ViewEntry { + name: "Recently Closed".to_string(), + view: SavedView { + status: Some("closed".to_string()), + order: Some("created.desc".to_string()), + subissues: true, + tag_match_all: true, + ..Default::default() + }, + kind: ViewKind::BuiltIn, + }, + ViewEntry { + name: "All Tickets".to_string(), + view: SavedView { + all: true, + subissues: true, + tag_match_all: true, + ..Default::default() + }, + kind: ViewKind::BuiltIn, + }, + ] +} + fn optional_trimmed(raw: &str) -> Option<&str> { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -983,6 +2935,75 @@ fn github_author(description: &str) -> Option<&str> { }) } +fn comment_summary_line(comment: &Comment, width: usize) -> Line<'static> { + let date = relative_date(comment.at, OffsetDateTime::now_utc()); + let author = truncate_display(&comment.author, 14); + let prefix_width = + UnicodeWidthStr::width(date.as_str()) + 2 + UnicodeWidthStr::width(author.as_str()) + 2; + let body_width = width.saturating_sub(prefix_width); + let body = truncate_display(&flatten_display(&comment.body), body_width); + + Line::from(vec![ + Span::styled( + date, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + ), + Span::raw(" "), + Span::styled(author, Style::default().fg(Color::Cyan)), + Span::raw(" "), + Span::raw(body), + ]) +} + +fn help_heading(label: &str) -> Line<'static> { + Line::from(Span::styled( + label.to_string(), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )) +} + +fn help_section(lines: &mut Vec>, label: &str) { + if !lines.is_empty() { + lines.push(Line::raw("")); + } + lines.push(help_heading(label)); +} + +fn help_columns(left: (&str, &str), right: Option<(&str, &str)>) -> Line<'static> { + let mut spans = Vec::new(); + help_cell(&mut spans, left.0, left.1); + if let Some(right) = right { + spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray))); + help_cell(&mut spans, right.0, right.1); + } + Line::from(spans) +} + +fn help_cell(spans: &mut Vec>, keys: &str, description: &str) { + spans.push(Span::styled( + fit_display(keys, 12), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + fit_display(description, 20), + Style::default().fg(Color::Cyan), + )); +} + +fn help_note(text: &str) -> Line<'static> { + Line::from(Span::styled( + text.to_string(), + Style::default().fg(Color::DarkGray), + )) +} + fn field_line(label: &str, value: &str) -> Line<'static> { Line::from(vec![ Span::styled( @@ -996,6 +3017,192 @@ fn field_line(label: &str, value: &str) -> Line<'static> { ]) } +fn tags_field_line(tags: &BTreeSet) -> Line<'static> { + let mut spans = vec![ + Span::styled( + format!("{:<10}", "Tags"), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" : ", Style::default().fg(Color::DarkGray)), + ]; + let mut first = true; + for tag in tags { + if !first { + spans.push(Span::raw(", ")); + } + first = false; + spans.push(Span::styled( + tag.clone(), + Style::default().fg(tag_color(tag)), + )); + } + Line::from(spans) +} + +fn tag_spans(tags: &BTreeSet, max_width: usize) -> Vec> { + if max_width == 0 || tags.is_empty() { + return Vec::new(); + } + + let tag_values = tags.iter().collect::>(); + for keep in (1..=tag_values.len()).rev() { + let hidden = tag_values.len() - keep; + let spans = tag_list_spans(&tag_values[..keep], hidden); + if spans_width(&spans) <= max_width { + return spans; + } + } + + let spans = tag_count_spans(tags.len()); + if spans_width(&spans) <= max_width { + spans + } else { + Vec::new() + } +} + +fn tag_list_spans(tags: &[&String], hidden: usize) -> Vec> { + let mut spans = vec![Span::raw("[")]; + for (idx, tag) in tags.iter().enumerate() { + if idx > 0 { + spans.push(Span::raw(",")); + } + spans.push(Span::styled( + (*tag).clone(), + Style::default().fg(tag_color(tag)), + )); + } + if hidden > 0 { + if !tags.is_empty() { + spans.push(Span::raw(",")); + } + spans.push(Span::styled( + format!("+{hidden}"), + Style::default().fg(Color::DarkGray), + )); + } + spans.push(Span::raw("]")); + spans +} + +fn tag_count_spans(count: usize) -> Vec> { + vec![ + Span::raw("["), + Span::styled(count.to_string(), Style::default().fg(Color::DarkGray)), + Span::raw("]"), + ] +} + +fn tag_count_width(count: usize) -> usize { + spans_width(&tag_count_spans(count)) +} + +fn spans_width(spans: &[Span<'_>]) -> usize { + spans + .iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum() +} + +fn tag_color(tag: &str) -> Color { + let hash = stable_hash(tag); + if supports_truecolor() { + let hue = (hash % 360) as f32; + let (r, g, b) = hsl_to_rgb(hue, 0.68, 0.62); + Color::Rgb(r, g, b) + } else { + ANSI_TAG_COLORS[(hash as usize) % ANSI_TAG_COLORS.len()] + } +} + +fn supports_truecolor() -> bool { + std::env::var("COLORTERM") + .map(|value| { + let value = value.to_ascii_lowercase(); + value.contains("truecolor") || value.contains("24bit") + }) + .unwrap_or(false) + || std::env::var("TERM") + .map(|value| value.to_ascii_lowercase().contains("direct")) + .unwrap_or(false) +} + +fn stable_hash(value: &str) -> u64 { + let mut hash = 14_695_981_039_346_656_037u64; + for byte in value.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(1_099_511_628_211); + } + hash +} + +fn hsl_to_rgb(hue: f32, saturation: f32, lightness: f32) -> (u8, u8, u8) { + let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; + let hue_prime = hue / 60.0; + let x = chroma * (1.0 - (hue_prime % 2.0 - 1.0).abs()); + let (r1, g1, b1) = match hue_prime as u8 { + 0 => (chroma, x, 0.0), + 1 => (x, chroma, 0.0), + 2 => (0.0, chroma, x), + 3 => (0.0, x, chroma), + 4 => (x, 0.0, chroma), + _ => (chroma, 0.0, x), + }; + let m = lightness - chroma / 2.0; + ( + ((r1 + m) * 255.0).round() as u8, + ((g1 + m) * 255.0).round() as u8, + ((b1 + m) * 255.0).round() as u8, + ) +} + +fn status_state_line(ticket: &Ticket) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!("{:<10}", "Status"), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" : ", Style::default().fg(Color::DarkGray)), + Span::styled( + ticket.status.as_str().to_string(), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled( + "State", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" : ", Style::default().fg(Color::DarkGray)), + Span::styled( + ticket.state.as_str().to_string(), + Style::default().fg(Color::Green), + ), + ]) +} + +fn relative_date(then: OffsetDateTime, now: OffsetDateTime) -> String { + let seconds = (now - then).whole_seconds().max(0); + if seconds < 60 * 60 { + return "0d".to_string(); + } + if seconds < 60 * 60 * 24 { + return format!("{}h", seconds / (60 * 60)); + } + if seconds < 60 * 60 * 24 * 30 { + return format!("{}d", seconds / (60 * 60 * 24)); + } + if seconds < 60 * 60 * 24 * 365 { + return format!("{}mo", seconds / (60 * 60 * 24 * 30)); + } + format!("{}y", seconds / (60 * 60 * 24 * 365)) +} + fn new_ticket_field_line( field: NewTicketField, active: NewTicketField, diff --git a/crates/ticgit/src/commands/update.rs b/crates/ticgit/src/commands/update.rs index 0852913f..ad712b56 100644 --- a/crates/ticgit/src/commands/update.rs +++ b/crates/ticgit/src/commands/update.rs @@ -30,9 +30,7 @@ pub fn run(args: Args) -> Result<()> { } let target = detect_target()?; - let url = format!( - "https://github.com/{REPO}/releases/latest/download/ticgit-{target}.tar.gz" - ); + let url = format!("https://github.com/{REPO}/releases/latest/download/ticgit-{target}.tar.gz"); println!("Downloading from: {url}"); diff --git a/crates/ticgit/src/commands/users.rs b/crates/ticgit/src/commands/users.rs index 848d248b..0ac53b9d 100644 --- a/crates/ticgit/src/commands/users.rs +++ b/crates/ticgit/src/commands/users.rs @@ -55,10 +55,13 @@ pub fn run(args: Args) -> Result<()> { store.add_user_email(&add.nick, &add.email)?; if args.json { let emails = store.get_user(&add.nick)?; - println!("{}", serde_json::json!({ - "nick": add.nick, - "emails": emails, - })); + println!( + "{}", + serde_json::json!({ + "nick": add.nick, + "emails": emails, + }) + ); } else { println!("Added {} to user {}", add.email, add.nick); } @@ -68,20 +71,26 @@ pub fn run(args: Args) -> Result<()> { store.remove_user_email(&rm.nick, email)?; if args.json { let emails = store.get_user(&rm.nick)?; - println!("{}", serde_json::json!({ - "nick": rm.nick, - "emails": emails, - })); + println!( + "{}", + serde_json::json!({ + "nick": rm.nick, + "emails": emails, + }) + ); } else { println!("Removed {} from user {}", email, rm.nick); } } else { store.remove_user(&rm.nick)?; if args.json { - println!("{}", serde_json::json!({ - "nick": rm.nick, - "removed": true, - })); + println!( + "{}", + serde_json::json!({ + "nick": rm.nick, + "removed": true, + }) + ); } else { println!("Removed user {}", rm.nick); } @@ -93,9 +102,7 @@ pub fn run(args: Args) -> Result<()> { if args.json { let json: serde_json::Value = users .iter() - .map(|(nick, emails)| { - (nick.clone(), serde_json::json!(emails)) - }) + .map(|(nick, emails)| (nick.clone(), serde_json::json!(emails))) .collect(); println!("{}", serde_json::to_string_pretty(&json)?); return Ok(()); diff --git a/crates/ticgit/src/commands/view.rs b/crates/ticgit/src/commands/view.rs index 36a83c87..d624dc39 100644 --- a/crates/ticgit/src/commands/view.rs +++ b/crates/ticgit/src/commands/view.rs @@ -58,10 +58,9 @@ fn run_save(args: SaveArgs) -> Result<()> { let store = open_store()?; let git_dir = store.session().repo_git_dir(); let mut state = State::load().unwrap_or_default(); - let last = state - .last_filters_for(&git_dir) - .cloned() - .ok_or_else(|| anyhow::anyhow!("no recent `ti list` filters to save — run `ti list` with filters first"))?; + let last = state.last_filters_for(&git_dir).cloned().ok_or_else(|| { + anyhow::anyhow!("no recent `ti list` filters to save — run `ti list` with filters first") + })?; let desc = describe_view(&last); state.save_view(&git_dir, &args.name, last); state.save()?; @@ -95,8 +94,15 @@ pub fn describe_view(v: &SavedView) -> String { if let Some(s) = &v.state { parts.push(format!("--state {s}")); } - if let Some(t) = &v.tag { - parts.push(format!("--tag {t}")); + let tags = saved_tags(v); + for tag in &tags { + parts.push(format!("--tag {tag}")); + } + if tags.len() > 1 { + parts.push(format!( + "--tag-mode {}", + if v.tag_match_all { "all" } else { "any" } + )); } if let Some(a) = &v.assigned { parts.push(format!("--assigned {a}")); @@ -122,3 +128,10 @@ pub fn describe_view(v: &SavedView) -> String { parts.join(" ") } } + +fn saved_tags(v: &SavedView) -> Vec { + if !v.tags.is_empty() { + return v.tags.clone(); + } + v.tag.iter().cloned().collect() +} diff --git a/crates/ticgit/src/main.rs b/crates/ticgit/src/main.rs index a85d18ca..bf532142 100644 --- a/crates/ticgit/src/main.rs +++ b/crates/ticgit/src/main.rs @@ -8,20 +8,6 @@ mod render; mod session_state; fn main() -> anyhow::Result<()> { - if requested_agent_help() { - agent_help::print(); - return Ok(()); - } - let args = cli::Cli::parse(); cli::run(args) } - -fn requested_agent_help() -> bool { - let mut args = std::env::args_os(); - let _bin = args.next(); - matches!( - (args.next(), args.next(), args.next()), - (Some(command), Some(flag), None) if command == "help" && flag == "--agent" - ) -} diff --git a/crates/ticgit/src/render.rs b/crates/ticgit/src/render.rs index 05afab16..37f7e2dd 100644 --- a/crates/ticgit/src/render.rs +++ b/crates/ticgit/src/render.rs @@ -475,6 +475,12 @@ fn ticket_details_markdown(t: &Ticket) -> String { optional_inline(t.assigned.as_deref()) ) .unwrap(); + writeln!( + out, + "- Closed by: {}", + optional_inline(t.closed_by.as_deref()) + ) + .unwrap(); writeln!( out, "- Priority: {}", @@ -497,12 +503,7 @@ fn ticket_details_markdown(t: &Ticket) -> String { optional_inline(t.milestone.as_deref()) ) .unwrap(); - writeln!( - out, - "- Code: {}", - optional_inline(t.code.as_deref()) - ) - .unwrap(); + writeln!(out, "- Code: {}", optional_inline(t.code.as_deref())).unwrap(); writeln!(out, "- Tags: {}", tags_inline(t)).unwrap(); out.trim_end().to_string() } @@ -889,6 +890,7 @@ mod tests { status: state.status(), state, assigned: None, + closed_by: None, priority: None, points: None, milestone: None, diff --git a/crates/ticgit/src/session_state.rs b/crates/ticgit/src/session_state.rs index 26699ed4..855807ec 100644 --- a/crates/ticgit/src/session_state.rs +++ b/crates/ticgit/src/session_state.rs @@ -37,6 +37,10 @@ pub struct SavedView { pub state: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub tag_match_all: bool, #[serde(skip_serializing_if = "Option::is_none")] pub assigned: Option, #[serde(default, skip_serializing_if = "is_false")] @@ -57,6 +61,14 @@ fn is_false(v: &bool) -> bool { !v } +fn is_true(v: &bool) -> bool { + *v +} + +fn default_true() -> bool { + true +} + fn is_zero(v: &usize) -> bool { *v == 0 } diff --git a/crates/ticgit/tests/cli.rs b/crates/ticgit/tests/cli.rs index 67402156..548ccd8e 100644 --- a/crates/ticgit/tests/cli.rs +++ b/crates/ticgit/tests/cli.rs @@ -117,9 +117,9 @@ fn init_is_idempotent() { } #[test] -fn help_agent_prints_markdown_guide() { +fn agent_prints_markdown_guide() { let mut cmd = assert_cmd::Command::cargo_bin("ti").expect("ti binary"); - cmd.args(["help", "--agent"]) + cmd.arg("agent") .assert() .success() .stdout(predicate::str::contains("---")) @@ -128,7 +128,8 @@ fn help_agent_prints_markdown_guide() { .stdout(predicate::str::contains("ti new -F /tmp/ticket.md")) .stdout(predicate::str::contains("ti list --markdown")) .stdout(predicate::str::contains("Prefer `--markdown`")) - .stdout(predicate::str::contains("ti state closed")); + .stdout(predicate::str::contains("ti close -t ")) + .stdout(predicate::str::contains("--json").not()); } #[test] @@ -153,6 +154,7 @@ fn machine_output_schema_is_published_and_matches_cli_contract() { "status".to_string(), "state".to_string(), "assigned".to_string(), + "closed_by".to_string(), "priority".to_string(), "points".to_string(), "milestone".to_string(), @@ -620,6 +622,7 @@ fn mutating_commands_update_ticket() { assert_eq!(json["status"], "closed"); assert_eq!(json["state"], "resolved"); assert_eq!(json["assigned"], "tester@example.com"); + assert_eq!(json["closed_by"], "tester@example.com"); assert_eq!(json["points"], 5); assert_eq!(json["milestone"], "v1"); assert_eq!(json["tags"].as_array().unwrap().len(), 2); @@ -709,6 +712,20 @@ fn ticket_mutations_support_json_output() { let json: Value = serde_json::from_slice(&output).unwrap(); assert_eq!(json["status"], "open"); assert_eq!(json["state"], "blocked"); + assert_eq!(json["closed_by"], Value::Null); + + let output = repo + .ti() + .args(["claim", "-t", &id, "--json"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let json: Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(json["assigned"], "tester@example.com"); + assert_eq!(json["status"], "open"); + assert_eq!(json["state"], "assigned"); let output = repo .ti() @@ -917,10 +934,7 @@ fn list_filters_and_saved_views_work() { .stdout(predicate::str::contains("docs ticket").not()); // Save the last list filters as a view. - repo.ti() - .args(["views", "save", "bugs"]) - .assert() - .success(); + repo.ti().args(["views", "save", "bugs"]).assert().success(); repo.ti() .args(["views"]) diff --git a/docs/schema/v1.json b/docs/schema/v1.json index 6cd0a9c0..e0644885 100644 --- a/docs/schema/v1.json +++ b/docs/schema/v1.json @@ -29,6 +29,7 @@ "status", "state", "assigned", + "closed_by", "priority", "points", "milestone", @@ -90,6 +91,13 @@ "null" ] }, + "closed_by": { + "type": [ + "string", + "null" + ], + "description": "User email that last closed the ticket." + }, "priority": { "type": [ "integer", From d9606c7261bffa5090f37c7f0c660df6a80f2afb Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Tue, 12 May 2026 16:03:58 +0200 Subject: [PATCH 2/4] tui updates --- Cargo.lock | 1230 ++++++++++++++++++---- crates/ticgit-lib/src/query.rs | 6 + crates/ticgit-lib/src/store.rs | 2 +- crates/ticgit-lib/src/test_support.rs | 2 +- crates/ticgit/src/commands/claim.rs | 43 + crates/ticgit/src/commands/list.rs | 2 +- crates/ticgit/src/commands/pull.rs | 2 +- crates/ticgit/src/commands/tui.rs | 1400 ++++++++++++++++++++++--- crates/ticgit/src/session_state.rs | 20 + docs/agents.md | 133 +++ scripts/bump-version.sh | 24 + 11 files changed, 2465 insertions(+), 399 deletions(-) create mode 100644 crates/ticgit/src/commands/claim.rs create mode 100644 docs/agents.md create mode 100755 scripts/bump-version.sh diff --git a/Cargo.lock b/Cargo.lock index b4fe4ce9..83e83606 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -795,11 +795,11 @@ dependencies = [ [[package]] name = "git-meta-lib" -version = "0.1.3" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0983f1c8a8589008a1b51c6ec9226702022fa933079a7d44183064b21d3d9f65" +checksum = "1d8d88d5f482ae39baea4b7375ccb5e99932c5f6079436530f044854763e30c1" dependencies = [ - "gix", + "gix 0.83.0", "rusqlite", "serde", "serde_json", @@ -814,52 +814,109 @@ version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0473c64d9ccbcfb9953a133b47c8b9a335b87ac6c52b983ee4b03d49000b0f3f" dependencies = [ - "gix-actor", - "gix-archive", - "gix-attributes", - "gix-blame", - "gix-command", - "gix-commitgraph", - "gix-config", + "gix-actor 0.40.0", + "gix-archive 0.30.0", + "gix-attributes 0.31.0", + "gix-blame 0.11.0", + "gix-command 0.8.1", + "gix-commitgraph 0.35.0", + "gix-config 0.54.0", + "gix-date", + "gix-diff 0.61.0", + "gix-dir 0.23.0", + "gix-discover 0.49.0", + "gix-error", + "gix-features 0.46.2", + "gix-filter 0.28.0", + "gix-fs 0.19.2", + "gix-glob 0.24.0", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-ignore 0.19.1", + "gix-index 0.49.0", + "gix-lock 21.0.2", + "gix-merge 0.14.0", + "gix-negotiate 0.29.0", + "gix-object 0.58.0", + "gix-odb 0.78.0", + "gix-pack 0.68.0", + "gix-path 0.11.3", + "gix-pathspec 0.16.1", + "gix-protocol 0.59.0", + "gix-ref 0.61.0", + "gix-refspec 0.39.0", + "gix-revision 0.43.0", + "gix-revwalk 0.29.0", + "gix-sec 0.13.3", + "gix-shallow 0.10.0", + "gix-status 0.28.0", + "gix-submodule 0.28.0", + "gix-tempfile 21.0.2", + "gix-trace", + "gix-traverse 0.55.0", + "gix-url 0.35.3", + "gix-utils", + "gix-validate", + "gix-worktree 0.50.0", + "gix-worktree-state 0.28.0", + "gix-worktree-stream 0.30.0", + "nonempty", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix" +version = "0.83.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce52001b946a6249d5d0d3011df0a042ac3f8a4d013460db6476577b0b9c567" +dependencies = [ + "gix-actor 0.41.0", + "gix-archive 0.32.0", + "gix-attributes 0.33.0", + "gix-blame 0.13.0", + "gix-command 0.9.0", + "gix-commitgraph 0.37.0", + "gix-config 0.56.0", "gix-date", - "gix-diff", - "gix-dir", - "gix-discover", + "gix-diff 0.63.0", + "gix-dir 0.25.0", + "gix-discover 0.51.0", "gix-error", - "gix-features", - "gix-filter", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-hashtable", - "gix-ignore", - "gix-index", - "gix-lock", - "gix-merge", - "gix-negotiate", - "gix-object", - "gix-odb", - "gix-pack", - "gix-path", - "gix-pathspec", - "gix-protocol", - "gix-ref", - "gix-refspec", - "gix-revision", - "gix-revwalk", - "gix-sec", - "gix-shallow", - "gix-status", - "gix-submodule", - "gix-tempfile", + "gix-features 0.48.0", + "gix-filter 0.30.0", + "gix-fs 0.21.1", + "gix-glob 0.26.0", + "gix-hash 0.25.0", + "gix-hashtable 0.15.0", + "gix-ignore 0.21.0", + "gix-index 0.51.0", + "gix-lock 23.0.0", + "gix-merge 0.16.0", + "gix-negotiate 0.31.0", + "gix-object 0.60.0", + "gix-odb 0.80.0", + "gix-pack 0.70.0", + "gix-path 0.12.0", + "gix-pathspec 0.18.0", + "gix-protocol 0.61.0", + "gix-ref 0.63.0", + "gix-refspec 0.41.0", + "gix-revision 0.45.0", + "gix-revwalk 0.31.0", + "gix-sec 0.14.0", + "gix-shallow 0.12.0", + "gix-status 0.30.0", + "gix-submodule 0.30.0", + "gix-tempfile 23.0.0", "gix-trace", - "gix-traverse", - "gix-url", + "gix-traverse 0.57.0", + "gix-url 0.36.0", "gix-utils", "gix-validate", - "gix-worktree", - "gix-worktree-state", - "gix-worktree-stream", + "gix-worktree 0.52.0", + "gix-worktree-state 0.30.0", + "gix-worktree-stream 0.32.0", "nonempty", "smallvec", "thiserror 2.0.18", @@ -877,6 +934,17 @@ dependencies = [ "winnow", ] +[[package]] +name = "gix-actor" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "272916673b83714734b15d4ef3c8b5f1ccddb15fea8ff548430b97c1ab7b7ed8" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + [[package]] name = "gix-archive" version = "0.30.0" @@ -886,8 +954,21 @@ dependencies = [ "bstr", "gix-date", "gix-error", - "gix-object", - "gix-worktree-stream", + "gix-object 0.58.0", + "gix-worktree-stream 0.30.0", +] + +[[package]] +name = "gix-archive" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a20ec244b733338d4cb60e5e05eac700dab7fcc689647b1d1daa9396b119342" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-object 0.60.0", + "gix-worktree-stream 0.32.0", ] [[package]] @@ -897,8 +978,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c233d6eaa098c0ca5ce03236fd7a96e27f1abe72fad74b46003fbd11fe49563c" dependencies = [ "bstr", - "gix-glob", - "gix-path", + "gix-glob 0.24.0", + "gix-path 0.11.3", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + +[[package]] +name = "gix-attributes" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe17c5a1c0b6f2ef1476aa1d3222ea50cdff67608016613a58bfc3e078046000" +dependencies = [ + "bstr", + "gix-glob 0.26.0", + "gix-path 0.12.0", "gix-quote", "gix-trace", "kstring", @@ -922,16 +1020,36 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c77aaf9f7348f4da3ebfbfbbc35fa0d07155d98377856198dde6f695fd648705" dependencies = [ - "gix-commitgraph", + "gix-commitgraph 0.35.0", "gix-date", - "gix-diff", + "gix-diff 0.61.0", "gix-error", - "gix-hash", - "gix-object", - "gix-revwalk", + "gix-hash 0.23.0", + "gix-object 0.58.0", + "gix-revwalk 0.29.0", "gix-trace", - "gix-traverse", - "gix-worktree", + "gix-traverse 0.55.0", + "gix-worktree 0.50.0", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-blame" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dab9a942ab54a9661ded7397c3bf927274e7afa94494db0d75cfcbde02ca0a" +dependencies = [ + "gix-commitgraph 0.37.0", + "gix-date", + "gix-diff 0.63.0", + "gix-error", + "gix-hash 0.25.0", + "gix-object 0.60.0", + "gix-revwalk 0.31.0", + "gix-trace", + "gix-traverse 0.57.0", + "gix-worktree 0.52.0", "smallvec", "thiserror 2.0.18", ] @@ -952,7 +1070,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae4bb9fa74c44c93f7238b08255f7f9afc158bafea4b95af665fa535352cd73c" dependencies = [ "bstr", - "gix-path", + "gix-path 0.11.3", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-command" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86335306511abe43d75c866d4b1f3d90932fe202edcd43e1314036333e7384d8" +dependencies = [ + "bstr", + "gix-path 0.12.0", "gix-quote", "gix-trace", "shell-words", @@ -967,7 +1098,21 @@ dependencies = [ "bstr", "gix-chunk", "gix-error", - "gix-hash", + "gix-hash 0.23.0", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-commitgraph" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3b5aa0f24e19028c261d229aeeedafcaaa52ebd71021cc15184620fc9d32eb" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash 0.25.0", "memmap2", "nonempty", ] @@ -979,12 +1124,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08939b4c4ed7a663d0e64be9e1e9bdf23a1fb4fcee1febdf449f12229542e50d" dependencies = [ "bstr", - "gix-config-value", - "gix-features", - "gix-glob", - "gix-path", - "gix-ref", - "gix-sec", + "gix-config-value 0.17.2", + "gix-features 0.46.2", + "gix-glob 0.24.0", + "gix-path 0.11.3", + "gix-ref 0.61.0", + "gix-sec 0.13.3", "memchr", "smallvec", "thiserror 2.0.18", @@ -992,6 +1137,24 @@ dependencies = [ "winnow", ] +[[package]] +name = "gix-config" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c01848aebd21c67f6ba41f1de8efd46ae96df21f001954a3c9e1517e514d410" +dependencies = [ + "bstr", + "gix-config-value 0.18.0", + "gix-features 0.48.0", + "gix-glob 0.26.0", + "gix-path 0.12.0", + "gix-ref 0.63.0", + "gix-sec 0.14.0", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + [[package]] name = "gix-config-value" version = "0.17.2" @@ -1000,16 +1163,29 @@ checksum = "4378c53ec3db049919edf91ff76f56f28886a8b4b4a5a9dc633108d84afc3675" dependencies = [ "bitflags 2.11.1", "bstr", - "gix-path", + "gix-path 0.11.3", + "libc", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-config-value" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b39ed39ee4c10a3b157f9fb94bac8098d9f8e56201f0cf7dee6c187416c4b2" +dependencies = [ + "bitflags 2.11.1", + "bstr", + "gix-path 0.12.0", "libc", "thiserror 2.0.18", ] [[package]] name = "gix-date" -version = "0.15.2" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc99523b8bf32561b9abf72c878fbff3854d806ed46c1198e57899f9f3c7f05" +checksum = "b94cdae4eb4b0f4136e3d9b3aa2d2cd03cfb5bb9b636b31263aea2df86d41543" dependencies = [ "bstr", "gix-error", @@ -1025,21 +1201,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88f3b3475e5d3877d7c30c40827cc2441936ce890efc226e5ba4afe3a7ae33f0" dependencies = [ "bstr", - "gix-command", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-object", - "gix-path", - "gix-tempfile", + "gix-command 0.8.1", + "gix-filter 0.28.0", + "gix-fs 0.19.2", + "gix-hash 0.23.0", + "gix-object 0.58.0", + "gix-path 0.11.3", + "gix-tempfile 21.0.2", "gix-trace", - "gix-traverse", - "gix-worktree", + "gix-traverse 0.55.0", + "gix-worktree 0.50.0", "imara-diff 0.1.8", "imara-diff 0.2.0", "thiserror 2.0.18", ] +[[package]] +name = "gix-diff" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc08e0fa1a91ff5f24affeab052f198056645e1de004910bde7b82b50ea5982a" +dependencies = [ + "bstr", + "gix-command 0.9.0", + "gix-filter 0.30.0", + "gix-fs 0.21.1", + "gix-hash 0.25.0", + "gix-imara-diff", + "gix-object 0.60.0", + "gix-path 0.12.0", + "gix-tempfile 23.0.0", + "gix-trace", + "gix-traverse 0.57.0", + "gix-worktree 0.52.0", + "thiserror 2.0.18", +] + [[package]] name = "gix-dir" version = "0.23.0" @@ -1047,16 +1244,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5da4604a360988f0ba8efe6f90093ca5a844f4a7f8e1a3dcda501ec44e600ea9" dependencies = [ "bstr", - "gix-discover", - "gix-fs", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", + "gix-discover 0.49.0", + "gix-fs 0.19.2", + "gix-ignore 0.19.1", + "gix-index 0.49.0", + "gix-object 0.58.0", + "gix-path 0.11.3", + "gix-pathspec 0.16.1", "gix-trace", "gix-utils", - "gix-worktree", + "gix-worktree 0.50.0", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-dir" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a0fc06e9e1e430cbf0a313666976d90f822f461a6525320427aa9b8af5236c" +dependencies = [ + "bstr", + "gix-discover 0.51.0", + "gix-fs 0.21.1", + "gix-ignore 0.21.0", + "gix-index 0.51.0", + "gix-object 0.60.0", + "gix-path 0.12.0", + "gix-pathspec 0.18.0", + "gix-trace", + "gix-utils", + "gix-worktree 0.52.0", "thiserror 2.0.18", ] @@ -1068,18 +1285,33 @@ checksum = "c65bd3330fe0cb9d40d875bf862fd5e8ad6fa4164ddbc4842fbeb889c3f0b2c6" dependencies = [ "bstr", "dunce", - "gix-fs", - "gix-path", - "gix-ref", - "gix-sec", + "gix-fs 0.19.2", + "gix-path 0.11.3", + "gix-ref 0.61.0", + "gix-sec 0.13.3", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-discover" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17852e6a501e688a1702b24ebe5b3761d4719455bc869fd29f38b0b859bcad34" +dependencies = [ + "bstr", + "dunce", + "gix-fs 0.21.1", + "gix-path 0.12.0", + "gix-ref 0.63.0", + "gix-sec 0.14.0", "thiserror 2.0.18", ] [[package]] name = "gix-error" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c998bf10447f0797e579567382b5e22a19c22435d2df091e25857728c6d9af8d" +checksum = "e207b971746ab724fccdfced2e4e19e854744611904a0195d3aa8fda8a110613" dependencies = [ "bstr", ] @@ -1092,7 +1324,26 @@ checksum = "752493cd4b1d5eaaa0138a7493f65c96863fefa990fc021e0e519579e389ab20" dependencies = [ "bytes", "crc32fast", - "gix-path", + "gix-path 0.11.3", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "prodash", + "thiserror 2.0.18", + "walkdir", + "zlib-rs", +] + +[[package]] +name = "gix-features" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af375693ad5333d0a2c66b4c5b2cbe9ccc38e34f8e8bf24e4ae42c12307fdc4f" +dependencies = [ + "bytes", + "crc32fast", + "gix-path 0.12.0", "gix-trace", "gix-utils", "libc", @@ -1111,12 +1362,33 @@ checksum = "d37598282a6566da6fb52667570c7fe0aedcb122ac886724a9e62a2180523e35" dependencies = [ "bstr", "encoding_rs", - "gix-attributes", - "gix-command", - "gix-hash", - "gix-object", + "gix-attributes 0.31.0", + "gix-command 0.8.1", + "gix-hash 0.23.0", + "gix-object 0.58.0", "gix-packetline", - "gix-path", + "gix-path 0.11.3", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-filter" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac917dbe9653c9b615d248db91907a365bd779750c9e1b457a9d9fdeece3a08" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes 0.33.0", + "gix-command 0.9.0", + "gix-hash 0.25.0", + "gix-object 0.60.0", + "gix-packetline", + "gix-path 0.12.0", "gix-quote", "gix-trace", "gix-utils", @@ -1132,8 +1404,22 @@ checksum = "a964b4aec683eb0bacb87533defa80805bb4768056371a47ab38b00a2d377b72" dependencies = [ "bstr", "fastrand", - "gix-features", - "gix-path", + "gix-features 0.46.2", + "gix-path 0.11.3", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-fs" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e1967daac9848757c47c2aef0c57bcadc1a897347f559778249bf286a536c86" +dependencies = [ + "bstr", + "fastrand", + "gix-features 0.48.0", + "gix-path 0.12.0", "gix-utils", "thiserror 2.0.18", ] @@ -1146,8 +1432,20 @@ checksum = "b03e6cd88cc0dc1eafa1fddac0fb719e4e74b6ea58dd016e71125fde4a326bee" dependencies = [ "bitflags 2.11.1", "bstr", - "gix-features", - "gix-path", + "gix-features 0.46.2", + "gix-path 0.11.3", +] + +[[package]] +name = "gix-glob" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bf29249a069bf2507f5964f80997f37b134d320ea348d66527726b9be2c38c" +dependencies = [ + "bitflags 2.11.1", + "bstr", + "gix-features 0.48.0", + "gix-path 0.12.0", ] [[package]] @@ -1157,7 +1455,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fb896a02d9ab96fa518475a5f30ad3952010f801a8de5840f633f4a6b985dfb" dependencies = [ "faster-hex", - "gix-features", + "gix-features 0.46.2", + "sha1-checked", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-hash" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf70d1e252337eed16360f8b8ebb71865ece58eab7954b39ce38b420de703d2" +dependencies = [ + "faster-hex", + "gix-features 0.48.0", "sha1-checked", "thiserror 2.0.18", ] @@ -1168,7 +1478,18 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2664216fc5e89b51e756a4a3ac676315602ce2dac07acf1da959a22038d69b33" dependencies = [ - "gix-hash", + "gix-hash 0.23.0", + "hashbrown 0.16.1", + "parking_lot", +] + +[[package]] +name = "gix-hashtable" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d33b455e07b3c16d3b2eeebc7b38d2dafcbf8a653de1138ef55d4c2a1fd0b08b" +dependencies = [ + "gix-hash 0.25.0", "hashbrown 0.16.1", "parking_lot", ] @@ -1180,12 +1501,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09f915dcf6911e3027537166d34e13f0fe101ed12225178d2ae29cd1272cff26" dependencies = [ "bstr", - "gix-glob", - "gix-path", + "gix-glob 0.24.0", + "gix-path 0.11.3", "gix-trace", "unicode-bom", ] +[[package]] +name = "gix-ignore" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb13fbbeeafee943e52b61fcc88dfddf6a452fcaf0c4d0cdc8f218fa25bbec5" +dependencies = [ + "bstr", + "gix-glob 0.26.0", + "gix-path 0.12.0", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39eb0623e15e4cb83c02ce6a959e48fadd1ae3b715b36b5acc01816e01388c82" +dependencies = [ + "bstr", + "hashbrown 0.16.1", +] + [[package]] name = "gix-index" version = "0.49.0" @@ -1197,12 +1541,40 @@ dependencies = [ "filetime", "fnv", "gix-bitmap", - "gix-features", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-traverse", + "gix-features 0.46.2", + "gix-fs 0.19.2", + "gix-hash 0.23.0", + "gix-lock 21.0.2", + "gix-object 0.58.0", + "gix-traverse 0.55.0", + "gix-utils", + "gix-validate", + "hashbrown 0.16.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-index" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54c3ef97ad08121e4327a6226bd63fed6b9e3c6b976d48bddd4356d9d41191db" +dependencies = [ + "bitflags 2.11.1", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features 0.48.0", + "gix-fs 0.21.1", + "gix-hash 0.25.0", + "gix-lock 23.0.0", + "gix-object 0.60.0", + "gix-traverse 0.57.0", "gix-utils", "gix-validate", "hashbrown 0.16.1", @@ -1220,7 +1592,18 @@ version = "21.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "054fbd0989700c69dc5aa80bc66944f05df1e15aa7391a9e42aca7366337905f" dependencies = [ - "gix-tempfile", + "gix-tempfile 21.0.2", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-lock" +version = "23.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3bc074e5723027b482dcd9ab99d95804a53742f6de812d0172fbba4a186c1" +dependencies = [ + "gix-tempfile 23.0.0", "gix-utils", "thiserror 2.0.18", ] @@ -1232,25 +1615,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4606747466512d22c2dffc019142e1941238f543987ea51353c938cca80c500" dependencies = [ "bstr", - "gix-command", - "gix-diff", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-index", - "gix-object", - "gix-path", + "gix-command 0.8.1", + "gix-diff 0.61.0", + "gix-filter 0.28.0", + "gix-fs 0.19.2", + "gix-hash 0.23.0", + "gix-index 0.49.0", + "gix-object 0.58.0", + "gix-path 0.11.3", "gix-quote", - "gix-revision", - "gix-revwalk", - "gix-tempfile", + "gix-revision 0.43.0", + "gix-revwalk 0.29.0", + "gix-tempfile 21.0.2", "gix-trace", - "gix-worktree", + "gix-worktree 0.50.0", "imara-diff 0.1.8", "nonempty", "thiserror 2.0.18", ] +[[package]] +name = "gix-merge" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74bbcdcc52b70a32f0a151b024dff9d0fcf56ee48f00d9503e735af9d99ea881" +dependencies = [ + "bstr", + "gix-command 0.9.0", + "gix-diff 0.63.0", + "gix-filter 0.30.0", + "gix-fs 0.21.1", + "gix-hash 0.25.0", + "gix-imara-diff", + "gix-index 0.51.0", + "gix-object 0.60.0", + "gix-path 0.12.0", + "gix-quote", + "gix-revision 0.45.0", + "gix-revwalk 0.31.0", + "gix-tempfile 23.0.0", + "gix-trace", + "gix-worktree 0.52.0", + "nonempty", + "thiserror 2.0.18", +] + [[package]] name = "gix-negotiate" version = "0.29.0" @@ -1258,11 +1667,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea064c7595eea08fdd01c70748af747d9acc40f727b61f4c8a2145a5c5fc28c" dependencies = [ "bitflags 2.11.1", - "gix-commitgraph", + "gix-commitgraph 0.35.0", "gix-date", - "gix-hash", - "gix-object", - "gix-revwalk", + "gix-hash 0.23.0", + "gix-object 0.58.0", + "gix-revwalk 0.29.0", +] + +[[package]] +name = "gix-negotiate" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d42bfade1b8a96ca5005933127bdad461ce588d92422b2c2daa3ff20d780c" +dependencies = [ + "bitflags 2.11.1", + "gix-commitgraph 0.37.0", + "gix-date", + "gix-hash 0.25.0", + "gix-object 0.60.0", + "gix-revwalk 0.31.0", ] [[package]] @@ -1272,12 +1695,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cafb802bb688a7c1e69ef965612ff5ff859f046bfb616377e4a0ba4c01e43d47" dependencies = [ "bstr", - "gix-actor", + "gix-actor 0.40.0", "gix-date", - "gix-features", - "gix-hash", - "gix-hashtable", - "gix-path", + "gix-features 0.46.2", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-path 0.11.3", "gix-utils", "gix-validate", "itoa", @@ -1286,6 +1709,25 @@ dependencies = [ "winnow", ] +[[package]] +name = "gix-object" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38075a95d7cc5df8afd38e72c617026c1456952207a4120a7f55a3fbf93b4d7" +dependencies = [ + "bstr", + "gix-actor 0.41.0", + "gix-date", + "gix-features 0.48.0", + "gix-hash 0.25.0", + "gix-hashtable 0.15.0", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror 2.0.18", +] + [[package]] name = "gix-odb" version = "0.78.0" @@ -1293,14 +1735,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24833ae9323b4f7079575fb9f961cf9c414b0afbec428a536ab8e7dd93bc002b" dependencies = [ "arc-swap", - "gix-features", - "gix-fs", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-pack", - "gix-path", + "gix-features 0.46.2", + "gix-fs 0.19.2", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-object 0.58.0", + "gix-pack 0.68.0", + "gix-path 0.11.3", + "gix-quote", + "parking_lot", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-odb" +version = "0.80.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeeda12a9663120418735ecdc1250d06eeab0be75700e47b3402a981331716ba" +dependencies = [ + "arc-swap", + "gix-features 0.48.0", + "gix-fs 0.21.1", + "gix-hash 0.25.0", + "gix-hashtable 0.15.0", + "gix-object 0.60.0", + "gix-pack 0.70.0", + "gix-path 0.12.0", "gix-quote", + "memmap2", "parking_lot", "tempfile", "thiserror 2.0.18", @@ -1315,11 +1778,30 @@ dependencies = [ "clru", "gix-chunk", "gix-error", - "gix-features", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-path", + "gix-features 0.46.2", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-object 0.58.0", + "gix-path 0.11.3", + "memmap2", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pack" +version = "0.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf02e6f5c8f07a069c9ea5245f40d9b14856ada4086091dc99941b49002b4fa" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features 0.48.0", + "gix-hash 0.25.0", + "gix-hashtable 0.15.0", + "gix-object 0.60.0", + "gix-path 0.12.0", "memmap2", "smallvec", "thiserror 2.0.18", @@ -1349,6 +1831,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "gix-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671a6059e8a4c1b7f406e24716499cefa3926e060876fb1959ef225efeee346e" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.18", +] + [[package]] name = "gix-pathspec" version = "0.16.1" @@ -1357,10 +1851,25 @@ checksum = "f89611f13544ca5ebeb68a502673814ef57200df60c24a61c2ce7b96f612f08b" dependencies = [ "bitflags 2.11.1", "bstr", - "gix-attributes", - "gix-config-value", - "gix-glob", - "gix-path", + "gix-attributes 0.31.0", + "gix-config-value 0.17.2", + "gix-glob 0.24.0", + "gix-path 0.11.3", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pathspec" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a84a4f083dd70fb49f4377e13afa6d90df2daaa1c705c49d6ff1331fc7e8855" +dependencies = [ + "bitflags 2.11.1", + "bstr", + "gix-attributes 0.33.0", + "gix-config-value 0.18.0", + "gix-glob 0.26.0", + "gix-path 0.12.0", "thiserror 2.0.18", ] @@ -1372,11 +1881,11 @@ checksum = "4f38666350736b5877c79f57ddae02bde07a4ce186d889adc391e831cddcbe76" dependencies = [ "bstr", "gix-date", - "gix-features", - "gix-hash", - "gix-ref", - "gix-shallow", - "gix-transport", + "gix-features 0.46.2", + "gix-hash 0.23.0", + "gix-ref 0.61.0", + "gix-shallow 0.10.0", + "gix-transport 0.55.1", "gix-utils", "maybe-async", "nonempty", @@ -1384,6 +1893,25 @@ dependencies = [ "winnow", ] +[[package]] +name = "gix-protocol" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4bee82db63ec635996b96efae71cf467c155fa3f34a556184373224a26c4fd" +dependencies = [ + "bstr", + "gix-date", + "gix-features 0.48.0", + "gix-hash 0.25.0", + "gix-ref 0.63.0", + "gix-shallow 0.12.0", + "gix-transport 0.57.0", + "gix-utils", + "maybe-async", + "nonempty", + "thiserror 2.0.18", +] + [[package]] name = "gix-quote" version = "0.7.1" @@ -1401,14 +1929,14 @@ version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2159978abb99b7027c8579d15211e262ef0ef2594d5cecb3334fbcbdfe2997c" dependencies = [ - "gix-actor", - "gix-features", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-path", - "gix-tempfile", + "gix-actor 0.40.0", + "gix-features 0.46.2", + "gix-fs 0.19.2", + "gix-hash 0.23.0", + "gix-lock 21.0.2", + "gix-object 0.58.0", + "gix-path 0.11.3", + "gix-tempfile 21.0.2", "gix-utils", "gix-validate", "memmap2", @@ -1416,6 +1944,26 @@ dependencies = [ "winnow", ] +[[package]] +name = "gix-ref" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8ba9cc15f558b274c99349b83130f5ec83459660828fde9718bbbb43a726167" +dependencies = [ + "gix-actor 0.41.0", + "gix-features 0.48.0", + "gix-fs 0.21.1", + "gix-hash 0.25.0", + "gix-lock 23.0.0", + "gix-object 0.60.0", + "gix-path 0.12.0", + "gix-tempfile 23.0.0", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror 2.0.18", +] + [[package]] name = "gix-refspec" version = "0.39.0" @@ -1424,9 +1972,25 @@ checksum = "dc806ee13f437428f8a1ba4c72ecfaa3f20e14f5f0d4c2bc17d0b33e794aa6ac" dependencies = [ "bstr", "gix-error", - "gix-glob", - "gix-hash", - "gix-revision", + "gix-glob 0.24.0", + "gix-hash 0.23.0", + "gix-revision 0.43.0", + "gix-validate", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-refspec" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61755b27d57edc8940a1b1593c8c61548ca8e4c02da1ed8d5bfeda9eb2a6b761" +dependencies = [ + "bstr", + "gix-error", + "gix-glob 0.26.0", + "gix-hash 0.25.0", + "gix-revision 0.45.0", "gix-validate", "smallvec", "thiserror 2.0.18", @@ -1440,13 +2004,32 @@ checksum = "7c08f1ec5d1e6a524f8ba291c41f0ccaef64e48ed0e8cf790b3461cae45f6d3d" dependencies = [ "bitflags 2.11.1", "bstr", - "gix-commitgraph", + "gix-commitgraph 0.35.0", "gix-date", "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-object 0.58.0", + "gix-revwalk 0.29.0", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revision" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fb5288fac706d3ea3e4e2ba9ec38b78743b8c02f422e18cb342299cfd6ab7e8" +dependencies = [ + "bitflags 2.11.1", + "bstr", + "gix-commitgraph 0.37.0", + "gix-date", + "gix-error", + "gix-hash 0.25.0", + "gix-hashtable 0.15.0", + "gix-object 0.60.0", + "gix-revwalk 0.31.0", "gix-trace", "nonempty", ] @@ -1457,12 +2040,28 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e4b2b87772b21ca449249e86d32febadba5cba32b0fcce804ab9cefc6f2111c" dependencies = [ - "gix-commitgraph", + "gix-commitgraph 0.35.0", "gix-date", "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-object 0.58.0", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-revwalk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "313813706b073a12ff7f9b2896bf3e6504cdac7cfbc97b1920114724705069f0" +dependencies = [ + "gix-commitgraph 0.37.0", + "gix-date", + "gix-error", + "gix-hash 0.25.0", + "gix-hashtable 0.15.0", + "gix-object 0.60.0", "smallvec", "thiserror 2.0.18", ] @@ -1474,7 +2073,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "283f4a746c9bde8550be63e6f961ff4651f412ca12666e8f5615f39464960ab9" dependencies = [ "bitflags 2.11.1", - "gix-path", + "gix-path 0.11.3", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-sec" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3a2d3e504a238136751e646a6c028252286a0ea64ea9974bf0498633407c6" +dependencies = [ + "bitflags 2.11.1", + "gix-path 0.12.0", "libc", "windows-sys 0.61.2", ] @@ -1486,8 +2097,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbf60711c9083b2364b3fac8a352444af76b17201f3682fdebe74fa66d89a772" dependencies = [ "bstr", - "gix-hash", - "gix-lock", + "gix-hash 0.23.0", + "gix-lock 21.0.2", + "nonempty", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-shallow" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29187305521bfacf4aefd284ab28dbfa9fb74abd39a5e63dd313b1baa5808c27" +dependencies = [ + "bstr", + "gix-hash 0.25.0", + "gix-lock 23.0.0", "nonempty", "thiserror 2.0.18", ] @@ -1500,17 +2124,40 @@ checksum = "23d6c598e3fdbc352fba1c5ba7e709e69402fafbc44d9295edad2e3c4738996b" dependencies = [ "bstr", "filetime", - "gix-diff", - "gix-dir", - "gix-features", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-worktree", + "gix-diff 0.61.0", + "gix-dir 0.23.0", + "gix-features 0.46.2", + "gix-filter 0.28.0", + "gix-fs 0.19.2", + "gix-hash 0.23.0", + "gix-index 0.49.0", + "gix-object 0.58.0", + "gix-path 0.11.3", + "gix-pathspec 0.16.1", + "gix-worktree 0.50.0", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-status" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c6d2a8c521ffa205fe7e268c82e6d1378ba37cd826ca10ab6129fdc29a4b65" +dependencies = [ + "bstr", + "filetime", + "gix-diff 0.63.0", + "gix-dir 0.25.0", + "gix-features 0.48.0", + "gix-filter 0.30.0", + "gix-fs 0.21.1", + "gix-hash 0.25.0", + "gix-index 0.51.0", + "gix-object 0.60.0", + "gix-path 0.12.0", + "gix-pathspec 0.18.0", + "gix-worktree 0.52.0", "portable-atomic", "thiserror 2.0.18", ] @@ -1522,11 +2169,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce5c3929c5e6821f651d35e8420f72fea3cfafe9fc1e928a61e718b462c72a5" dependencies = [ "bstr", - "gix-config", - "gix-path", - "gix-pathspec", - "gix-refspec", - "gix-url", + "gix-config 0.54.0", + "gix-path 0.11.3", + "gix-pathspec 0.16.1", + "gix-refspec 0.39.0", + "gix-url 0.35.3", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-submodule" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fd5fc8692890bd71a596e540fd4c364f8460eaa82c4eaaedebde6e1e3eb4d91" +dependencies = [ + "bstr", + "gix-config 0.56.0", + "gix-path 0.12.0", + "gix-pathspec 0.18.0", + "gix-refspec 0.41.0", + "gix-url 0.36.0", "thiserror 2.0.18", ] @@ -1537,7 +2199,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d22227f6b203f511ff451c33c89899e87e4f571fc596b06f68e6e613a6508528" dependencies = [ "dashmap", - "gix-fs", + "gix-fs 0.19.2", + "libc", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-tempfile" +version = "23.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "691ea1e31435c7e7d4d04705ec9d1c0d9482c46b2acf512bc723939d8f0af7fb" +dependencies = [ + "dashmap", + "gix-fs 0.21.1", "libc", "parking_lot", "tempfile", @@ -1556,12 +2231,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a521e39c6235ce63ed6c001e2dd79818c830b82c3b7b59247ee7b229c39ec9bb" dependencies = [ "bstr", - "gix-command", - "gix-features", + "gix-command 0.8.1", + "gix-features 0.46.2", "gix-packetline", "gix-quote", - "gix-sec", - "gix-url", + "gix-sec 0.13.3", + "gix-url 0.35.3", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-transport" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd6a5c676b92d4ead5f5a2b2935024415dec69edc997b6090ca9cac010a3018" +dependencies = [ + "bstr", + "gix-command 0.9.0", + "gix-features 0.48.0", + "gix-packetline", + "gix-quote", + "gix-sec 0.14.0", + "gix-url 0.36.0", "thiserror 2.0.18", ] @@ -1572,12 +2263,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "963dc2afcdb611092aa587c3f9365e749ac0a0892ff27662dbc75f26c953fbec" dependencies = [ "bitflags 2.11.1", - "gix-commitgraph", + "gix-commitgraph 0.35.0", + "gix-date", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-object 0.58.0", + "gix-revwalk 0.29.0", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-traverse" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14b7052c0786676c03e71fcfde7d7f0f8e8316e642b5cec6bb3998719b2ce5c" +dependencies = [ + "bitflags 2.11.1", + "gix-commitgraph 0.37.0", "gix-date", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", + "gix-hash 0.25.0", + "gix-hashtable 0.15.0", + "gix-object 0.60.0", + "gix-revwalk 0.31.0", "smallvec", "thiserror 2.0.18", ] @@ -1589,7 +2297,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a61ead12e33fa52ae92b207ee27554f646a8e7a3dad8b78da1582ec91eda0a6" dependencies = [ "bstr", - "gix-path", + "gix-path 0.11.3", + "percent-encoding", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-url" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35842d099e813f6f6bba529e88d4670572149c3df79b7a412952259887721ece" +dependencies = [ + "bstr", + "gix-path 0.12.0", "percent-encoding", "thiserror 2.0.18", ] @@ -1621,14 +2341,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6bd5830cbc43c9c00918b826467d2afad685b195cb82329cde2b2d116d2c578" dependencies = [ "bstr", - "gix-attributes", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", + "gix-attributes 0.31.0", + "gix-fs 0.19.2", + "gix-glob 0.24.0", + "gix-hash 0.23.0", + "gix-ignore 0.19.1", + "gix-index 0.49.0", + "gix-object 0.58.0", + "gix-path 0.11.3", + "gix-validate", +] + +[[package]] +name = "gix-worktree" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69955eb5e2910832f88d041964b809eee01dadd579237e0b55efec58fd406fd" +dependencies = [ + "bstr", + "gix-attributes 0.33.0", + "gix-fs 0.21.1", + "gix-glob 0.26.0", + "gix-hash 0.25.0", + "gix-ignore 0.21.0", + "gix-index 0.51.0", + "gix-object 0.60.0", + "gix-path 0.12.0", "gix-validate", ] @@ -1639,13 +2377,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "644a1681f96e1be43c2a8384337d9d220e7624f50db54beda70997052aebf707" dependencies = [ "bstr", - "gix-features", - "gix-filter", - "gix-fs", - "gix-index", - "gix-object", - "gix-path", - "gix-worktree", + "gix-features 0.46.2", + "gix-filter 0.28.0", + "gix-fs 0.19.2", + "gix-index 0.49.0", + "gix-object 0.58.0", + "gix-path 0.11.3", + "gix-worktree 0.50.0", + "io-close", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-worktree-state" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a96dccbcf9e8fe0291c55f06e08da93ebb2e691c1311276f541eefcc6d70800" +dependencies = [ + "bstr", + "gix-features 0.48.0", + "gix-filter 0.30.0", + "gix-fs 0.21.1", + "gix-index 0.51.0", + "gix-object 0.60.0", + "gix-path 0.12.0", + "gix-worktree 0.52.0", "io-close", "thiserror 2.0.18", ] @@ -1656,15 +2412,33 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24e3fb70a1f650a5cec7d5b8d10d6d6fe86daf3cf15bde08ba0c70988a2932c3" dependencies = [ - "gix-attributes", + "gix-attributes 0.31.0", + "gix-error", + "gix-features 0.46.2", + "gix-filter 0.28.0", + "gix-fs 0.19.2", + "gix-hash 0.23.0", + "gix-object 0.58.0", + "gix-path 0.11.3", + "gix-traverse 0.55.0", + "parking_lot", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8444b8ed4662e1a0c97f3eceda29630001a1bbb2632201e50312623e594213" +dependencies = [ + "gix-attributes 0.33.0", "gix-error", - "gix-features", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-object", - "gix-path", - "gix-traverse", + "gix-features 0.48.0", + "gix-filter 0.30.0", + "gix-fs 0.21.1", + "gix-hash 0.25.0", + "gix-object 0.60.0", + "gix-path 0.12.0", + "gix-traverse 0.57.0", "parking_lot", ] @@ -3179,7 +3953,7 @@ dependencies = [ "crossterm", "dialoguer", "dirs", - "gix", + "gix 0.81.0", "predicates", "ratatui", "rusqlite", @@ -3199,8 +3973,8 @@ name = "ticgit-lib" version = "0.2.0" dependencies = [ "git-meta-lib", - "gix", - "gix-actor", + "gix 0.81.0", + "gix-actor 0.40.0", "serde", "serde_json", "tempfile", diff --git a/crates/ticgit-lib/src/query.rs b/crates/ticgit-lib/src/query.rs index 4217246e..07fda9a4 100644 --- a/crates/ticgit-lib/src/query.rs +++ b/crates/ticgit-lib/src/query.rs @@ -42,6 +42,7 @@ pub enum SearchScope { /// `desc` flag. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortKey { + Priority, Title, State, Assigned, @@ -57,6 +58,7 @@ pub struct SortOrder { impl SortKey { pub fn parse(s: &str) -> Option { match s { + "priority" | "prio" => Some(SortKey::Priority), "title" => Some(SortKey::Title), "state" => Some(SortKey::State), "assigned" => Some(SortKey::Assigned), @@ -263,6 +265,10 @@ fn status_rank(s: TicketStatus) -> u8 { fn compare(a: &Ticket, b: &Ticket, key: SortKey, desc: bool) -> Ordering { let ord = match key { + SortKey::Priority => priority_rank(a.priority) + .cmp(&priority_rank(b.priority)) + .then_with(|| b.created_at.cmp(&a.created_at)) + .then_with(|| a.id.cmp(&b.id)), SortKey::Title => a.title.cmp(&b.title), SortKey::State => status_rank(a.status) .cmp(&status_rank(b.status)) diff --git a/crates/ticgit-lib/src/store.rs b/crates/ticgit-lib/src/store.rs index 11e029fd..1dd64397 100644 --- a/crates/ticgit-lib/src/store.rs +++ b/crates/ticgit-lib/src/store.rs @@ -52,7 +52,7 @@ impl TicketStore { /// Open a store for an already-loaded `gix::Repository` (used in tests /// and by host applications that own the repo handle). pub fn open(repo: gix::Repository) -> Result { - let session = Session::open(repo)?; + let session = Session::open(repo.path())?; Self::ensure_schema(&session)?; Ok(Self { session }) } diff --git a/crates/ticgit-lib/src/test_support.rs b/crates/ticgit-lib/src/test_support.rs index cc476efc..9bce4127 100644 --- a/crates/ticgit-lib/src/test_support.rs +++ b/crates/ticgit-lib/src/test_support.rs @@ -28,7 +28,7 @@ pub fn test_store() -> (TicketStore, TempDir) { run_git(path, &["commit", "--allow-empty", "-m", "init", "--quiet"]); let repo = gix::open(path).expect("gix open"); - let session = Session::open(repo).expect("session open"); + let session = Session::open(repo.path()).expect("session open"); let store = TicketStore::from_session(session).expect("ticket store"); (store, td) } diff --git a/crates/ticgit/src/commands/claim.rs b/crates/ticgit/src/commands/claim.rs new file mode 100644 index 00000000..c1e1e3e9 --- /dev/null +++ b/crates/ticgit/src/commands/claim.rs @@ -0,0 +1,43 @@ +use anyhow::Result; +use clap::Parser; +use ticgit_lib::{TicketState, TicketStatus}; + +use crate::commands::{open_store, resolve_ticket}; +use crate::render; + +#[derive(Debug, Parser)] +pub struct Args { + /// Ticket id (or prefix). Defaults to the currently checked-out ticket. + #[arg(short = 't', long = "ticket")] + pub ticket: Option, + + /// Output the updated ticket as JSON. + #[arg(long = "json")] + pub json: bool, + + /// Output the updated ticket as Markdown. + #[arg(long = "markdown", conflicts_with = "json")] + pub markdown: bool, +} + +pub fn run(args: Args) -> Result<()> { + let store = open_store()?; + let id = resolve_ticket(&store, args.ticket.as_deref())?; + let email = store.email().to_string(); + + store.set_assigned(&id, Some(&email))?; + store.set_lifecycle(&id, TicketStatus::Open, TicketState::Assigned)?; + + let ticket = store.load(&id)?; + if args.json { + println!("{}", render::ticket_json(&ticket)?); + return Ok(()); + } + if args.markdown { + println!("{}", render::ticket_markdown(&ticket)); + return Ok(()); + } + + println!("{} claimed by {}", ticket.short_id(), email); + Ok(()) +} diff --git a/crates/ticgit/src/commands/list.rs b/crates/ticgit/src/commands/list.rs index d6ba7ec2..46140869 100644 --- a/crates/ticgit/src/commands/list.rs +++ b/crates/ticgit/src/commands/list.rs @@ -43,7 +43,7 @@ pub struct Args { #[arg(long = "search")] pub search: Option, - /// Sort order. e.g. `state`, `title.desc`, `created`, `assigned`. + /// Sort order. e.g. `priority`, `state`, `title.desc`, `created`, `assigned`. #[arg(short = 'o', long = "order")] pub order: Option, diff --git a/crates/ticgit/src/commands/pull.rs b/crates/ticgit/src/commands/pull.rs index 9d2aff7b..35509b9f 100644 --- a/crates/ticgit/src/commands/pull.rs +++ b/crates/ticgit/src/commands/pull.rs @@ -161,7 +161,7 @@ fn fetch_remote_tickets(url: &str) -> Result> { // Open a store on the temp repo and pull from the remote. let repo = gix::open(path).context("opening temp repo")?; - let session = ticgit_lib::Session::open(repo).context("opening session on temp repo")?; + let session = ticgit_lib::Session::open(repo.path()).context("opening session on temp repo")?; let remote_store = TicketStore::from_session(session).context("opening ticket store on temp repo")?; diff --git a/crates/ticgit/src/commands/tui.rs b/crates/ticgit/src/commands/tui.rs index 303222a4..70b3adee 100644 --- a/crates/ticgit/src/commands/tui.rs +++ b/crates/ticgit/src/commands/tui.rs @@ -21,8 +21,8 @@ use ratatui::widgets::{ }; use ratatui::{Frame, Terminal}; use ticgit_lib::{ - query, Comment, Filter, NewTicketOpts, SortOrder, Ticket, TicketLifecycle, TicketState, - TicketStatus, TicketStore, + query, Comment, Filter, NewTicketOpts, SortKey, SortOrder, Ticket, TicketLifecycle, + TicketState, TicketStatus, TicketStore, }; use time::OffsetDateTime; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -32,6 +32,7 @@ const LIST_ID_WIDTH: usize = 3; const LIST_STATE_WIDTH: usize = 2; const LIST_AGE_WIDTH: usize = 3; const LIST_PRIORITY_WIDTH: usize = 3; +const COMPACT_LIST_MIN_TITLE_WIDTH: usize = 24; const ANSI_TAG_COLORS: [Color; 12] = [ Color::Blue, Color::Cyan, @@ -53,6 +54,10 @@ const BOARD_STATES: [TicketState; 5] = [ TicketState::Blocked, TicketState::Review, ]; +const DETAIL_WIDTH_PERCENT_DEFAULT: u16 = 58; +const DETAIL_WIDTH_PERCENT_MIN: u16 = 35; +const DETAIL_WIDTH_PERCENT_MAX: u16 = 80; +const DETAIL_WIDTH_PERCENT_STEP: u16 = 5; use crate::commands::{open_store, SessionGitDir}; use crate::editor; @@ -144,10 +149,13 @@ struct App { tag_filter: BTreeSet, tag_filter_match_all: bool, tag_picker_state: ListState, + manage_tag_state: ListState, + order_state: ListState, mode: Mode, input: String, new_ticket: NewTicketDraft, detail: Option, + detail_width_percent: u16, comments_mode: bool, comment_state: ListState, show_help: bool, @@ -190,6 +198,8 @@ enum Mode { Normal, Filter, Tags, + ManageTags, + Order, SavedViews, ConfirmDeleteView, SaveView, @@ -198,6 +208,27 @@ enum Mode { Create, } +#[derive(Debug, Clone, Copy)] +struct MenuHint { + key: &'static str, + desc: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OrderChoice { + Priority, + DateAsc, + DateDesc, + State, +} + +const ORDER_CHOICES: [OrderChoice; 4] = [ + OrderChoice::Priority, + OrderChoice::DateAsc, + OrderChoice::DateDesc, + OrderChoice::State, +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputKind { Comment, @@ -227,6 +258,13 @@ enum NewTicketField { impl App { fn new(store: TicketStore) -> Result { + let project_settings = State::load() + .unwrap_or_default() + .project_settings_for(&store.session().repo_git_dir()); + let detail_width_percent = project_settings + .detail_width_percent + .unwrap_or(DETAIL_WIDTH_PERCENT_DEFAULT) + .clamp(DETAIL_WIDTH_PERCENT_MIN, DETAIL_WIDTH_PERCENT_MAX); let mut app = Self { store, tickets: Vec::new(), @@ -248,10 +286,13 @@ impl App { tag_filter: BTreeSet::new(), tag_filter_match_all: true, tag_picker_state: ListState::default(), + manage_tag_state: ListState::default(), + order_state: ListState::default(), mode: Mode::Normal, input: String::new(), new_ticket: NewTicketDraft::default(), detail: None, + detail_width_percent, comments_mode: false, comment_state: ListState::default(), show_help: false, @@ -339,9 +380,13 @@ impl App { .split(area); if self.detail.is_some() { + let list_width = 100_u16.saturating_sub(self.detail_width_percent); let panes = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) + .constraints([ + Constraint::Percentage(list_width), + Constraint::Percentage(self.detail_width_percent), + ]) .split(outer[0]); if self.comments_mode { self.draw_comments_list(frame, panes[0]); @@ -365,6 +410,8 @@ impl App { match self.mode { Mode::Tags => self.draw_tags_modal(frame), + Mode::ManageTags => self.draw_manage_tags_modal(frame), + Mode::Order => self.draw_order_modal(frame), Mode::SavedViews => self.draw_saved_views_modal(frame), Mode::ConfirmDeleteView => self.draw_delete_view_confirm_modal(frame), Mode::SaveView => self.draw_save_view_modal(frame), @@ -382,99 +429,426 @@ impl App { } fn draw_menu_bar(&self, frame: &mut Frame<'_>, area: Rect) { - let prompt = match self.mode { - Mode::Filter => Line::from(vec![ - Span::styled( - " ti tui ", - Style::default().fg(Color::Black).bg(Color::Yellow), - ), - Span::raw(" "), - Span::styled("filter ", Style::default().fg(Color::Yellow)), - Span::raw("/"), - Span::styled(self.filter.as_str(), Style::default().fg(Color::Cyan)), - Span::raw(" type to filter, Enter/Esc to finish"), - ]), - Mode::Tags => Line::from(vec![ - Span::styled( - " ti tui ", - Style::default().fg(Color::Black).bg(Color::Yellow), - ), - Span::raw(" "), - Span::styled("tags ", Style::default().fg(Color::Yellow)), - Span::raw("j/k move Space toggle a all/any c clear Enter/Esc finish"), - ]), - Mode::SavedViews => Line::from(vec![ - Span::styled( - " ti tui ", - Style::default().fg(Color::Black).bg(Color::Yellow), - ), - Span::raw(" "), - Span::styled("views ", Style::default().fg(Color::Yellow)), - Span::raw("j/k move Enter apply d default Esc cancel"), - ]), - Mode::SaveView => Line::from(vec![ - Span::styled( - " ti tui ", - Style::default().fg(Color::Black).bg(Color::Yellow), - ), - Span::raw(" "), - Span::styled("save view ", Style::default().fg(Color::Yellow)), - Span::raw(self.input.as_str()), - Span::raw(" Enter save, Esc cancel"), - ]), - Mode::Input(kind) => Line::from(vec![ - Span::styled( - " ti tui ", - Style::default().fg(Color::Black).bg(Color::Yellow), - ), - Span::raw(" "), - Span::styled("editing ", Style::default().fg(Color::Yellow)), - Span::raw(kind.label()), - Span::raw(" Enter apply, Esc cancel"), - ]), - Mode::State => Line::from(vec![ - Span::styled( - " ti tui ", - Style::default().fg(Color::Black).bg(Color::Yellow), - ), - Span::raw(" "), - Span::styled("editing ", Style::default().fg(Color::Yellow)), - Span::raw("state choose in modal, Esc cancel"), - ]), - Mode::Create => Line::from(vec![ - Span::styled( - " ti tui ", - Style::default().fg(Color::Black).bg(Color::Yellow), - ), - Span::raw(" "), - Span::styled("new ", Style::default().fg(Color::Yellow)), - Span::raw("Tab/↑/↓ fields Enter create Esc cancel"), - ]), - Mode::Normal => { - let status = self.status.as_deref().unwrap_or_else(|| { - if self.comments_mode { - "j/k comments c add comment Esc details ? help q quit" - } else if self.view == ViewMode::Board && self.detail.is_none() { - "b list h/l columns j/k tickets Enter details e edit i spec s state c comment ? help q quit" - } else { - "b board n new / search g tags v views V save view e edit i spec C claim Enter details S sync ? help q quit" - } - }); - Line::from(vec![ - Span::styled( - " ti tui ", - Style::default().fg(Color::Black).bg(Color::Yellow), - ), - Span::raw(" "), - Span::styled("normal ", Style::default().fg(Color::Yellow)), - Span::raw(status), - ]) - } - }; + let (mode, detail, hints) = self.menu_bar_content(); + let prompt = menu_bar_line(usize::from(area.width), mode, detail.as_deref(), &hints); let paragraph = Paragraph::new(prompt).style(Style::default().bg(Color::DarkGray)); frame.render_widget(paragraph, area); } + fn menu_bar_content(&self) -> (&'static str, Option, Vec) { + match self.mode { + Mode::Filter => ( + "filter", + Some(format!("/{}", self.filter)), + vec![ + MenuHint { + key: "type", + desc: "filter", + }, + MenuHint { + key: "Backspace", + desc: "delete", + }, + MenuHint { + key: "Enter", + desc: "apply", + }, + MenuHint { + key: "Esc", + desc: "finish", + }, + ], + ), + Mode::Tags => ( + "tags", + None, + vec![ + MenuHint { + key: "j/k", + desc: "move", + }, + MenuHint { + key: "Space", + desc: "toggle", + }, + MenuHint { + key: "a", + desc: "all/any", + }, + MenuHint { + key: "c", + desc: "clear", + }, + MenuHint { + key: "Enter", + desc: "apply", + }, + MenuHint { + key: "Esc", + desc: "finish", + }, + ], + ), + Mode::ManageTags => ( + "ticket tags", + None, + vec![ + MenuHint { + key: "j/k", + desc: "move", + }, + MenuHint { + key: "Space", + desc: "toggle", + }, + MenuHint { + key: "n", + desc: "new", + }, + MenuHint { + key: "r", + desc: "remove", + }, + MenuHint { + key: "Enter", + desc: "finish", + }, + MenuHint { + key: "Esc", + desc: "finish", + }, + ], + ), + Mode::Order => ( + "order", + None, + vec![ + MenuHint { + key: "j/k", + desc: "move", + }, + MenuHint { + key: "Enter", + desc: "apply", + }, + MenuHint { + key: "1-4", + desc: "choose", + }, + MenuHint { + key: "Esc", + desc: "cancel", + }, + ], + ), + Mode::SavedViews => ( + "views", + None, + vec![ + MenuHint { + key: "j/k", + desc: "move", + }, + MenuHint { + key: "Enter", + desc: "apply", + }, + MenuHint { + key: "d", + desc: "default", + }, + MenuHint { + key: "D", + desc: "delete", + }, + MenuHint { + key: "Esc", + desc: "cancel", + }, + ], + ), + Mode::ConfirmDeleteView => ( + "delete view", + self.pending_delete_view.clone(), + vec![ + MenuHint { + key: "y", + desc: "delete", + }, + MenuHint { + key: "n/Esc", + desc: "cancel", + }, + ], + ), + Mode::SaveView => ( + "save view", + (!self.input.is_empty()).then(|| self.input.clone()), + vec![ + MenuHint { + key: "type", + desc: "name", + }, + MenuHint { + key: "Enter", + desc: "save", + }, + MenuHint { + key: "Esc", + desc: "cancel", + }, + MenuHint { + key: "Backspace", + desc: "delete", + }, + ], + ), + Mode::Input(kind) => ( + "editing", + Some(kind.label().to_string()), + vec![ + MenuHint { + key: "Enter", + desc: "apply", + }, + MenuHint { + key: "Esc", + desc: "cancel", + }, + MenuHint { + key: "Backspace", + desc: "delete", + }, + ], + ), + Mode::State => ( + "state", + None, + vec![ + MenuHint { + key: "n/a/p/b/v", + desc: "open", + }, + MenuHint { + key: "r/w/u/i", + desc: "closed", + }, + MenuHint { + key: "Esc", + desc: "cancel", + }, + ], + ), + Mode::Create => ( + "new", + None, + vec![ + MenuHint { + key: "Tab", + desc: "fields", + }, + MenuHint { + key: "Enter", + desc: "create", + }, + MenuHint { + key: "Esc", + desc: "cancel", + }, + MenuHint { + key: "Backspace", + desc: "delete", + }, + ], + ), + Mode::Normal => { + let detail = self.status.clone(); + let hints = if self.comments_mode { + vec![ + MenuHint { + key: "j/k", + desc: "comments", + }, + MenuHint { + key: "c", + desc: "comment", + }, + MenuHint { + key: "+/-", + desc: "resize", + }, + MenuHint { + key: "Esc", + desc: "details", + }, + MenuHint { + key: "r", + desc: "refresh", + }, + MenuHint { + key: "q", + desc: "quit", + }, + ] + } else if self.view == ViewMode::Board && self.detail.is_none() { + vec![ + MenuHint { + key: "j/k", + desc: "tickets", + }, + MenuHint { + key: "h/l", + desc: "columns", + }, + MenuHint { + key: "Enter", + desc: "details", + }, + MenuHint { + key: "b", + desc: "list", + }, + MenuHint { + key: "s", + desc: "state", + }, + MenuHint { + key: "t", + desc: "tags", + }, + MenuHint { + key: "e", + desc: "edit", + }, + MenuHint { + key: "c", + desc: "comment", + }, + MenuHint { + key: "o", + desc: "order", + }, + MenuHint { + key: "r", + desc: "refresh", + }, + MenuHint { + key: "q", + desc: "quit", + }, + ] + } else if self.detail.is_some() { + vec![ + MenuHint { + key: "j/k", + desc: "tickets", + }, + MenuHint { + key: "b", + desc: "board", + }, + MenuHint { + key: "+/-", + desc: "resize", + }, + MenuHint { + key: "t", + desc: "tags", + }, + MenuHint { + key: "c", + desc: "comment", + }, + MenuHint { + key: "m", + desc: "comments", + }, + MenuHint { + key: "i", + desc: "spec", + }, + MenuHint { + key: "e", + desc: "edit", + }, + MenuHint { + key: "s", + desc: "state", + }, + MenuHint { + key: "o", + desc: "order", + }, + MenuHint { + key: "Esc", + desc: "close", + }, + MenuHint { + key: "r", + desc: "refresh", + }, + MenuHint { + key: "q", + desc: "quit", + }, + ] + } else { + vec![ + MenuHint { + key: "j/k", + desc: "tickets", + }, + MenuHint { + key: "Enter", + desc: "details", + }, + MenuHint { + key: "t", + desc: "tags", + }, + MenuHint { + key: "/", + desc: "search", + }, + MenuHint { + key: "g", + desc: "filter tags", + }, + MenuHint { + key: "o", + desc: "order", + }, + MenuHint { + key: "b", + desc: "board", + }, + MenuHint { + key: "n", + desc: "new", + }, + MenuHint { + key: "v", + desc: "views", + }, + MenuHint { + key: "S", + desc: "sync", + }, + MenuHint { + key: "r", + desc: "refresh", + }, + MenuHint { + key: "q", + desc: "quit", + }, + ] + }; + ("normal", detail, hints) + } + } + } + fn draw_sync_progress(&self, frame: &mut Frame<'_>, area: Rect) { let Some(sync) = &self.sync else { return; @@ -586,6 +960,13 @@ impl App { return; }; let ticket = &self.tickets[idx]; + let detail_width = usize::from( + Block::default() + .borders(Borders::ALL) + .title("Details") + .inner(area) + .width, + ); let mut detail_lines = vec![ Line::from(Span::styled( ticket.id.to_string(), @@ -626,7 +1007,7 @@ impl App { detail_lines.push(field_line("Milestone", milestone)); } if let Some(spec) = &ticket.spec { - detail_lines.push(field_line("Spec", first_spec_line(spec))); + detail_lines.push(spec_field_line(spec, detail_width)); } if let Some(description) = &ticket.description { detail_lines.push(Line::raw("")); @@ -648,8 +1029,9 @@ impl App { } } + let detail_block = Block::default().borders(Borders::ALL).title("Details"); let detail = Paragraph::new(detail_lines) - .block(Block::default().borders(Borders::ALL).title("Details")) + .block(detail_block) .wrap(Wrap { trim: false }); frame.render_widget(detail, area); } @@ -809,6 +1191,120 @@ impl App { frame.render_stateful_widget(list, area, &mut self.tag_picker_state); } + fn draw_manage_tags_modal(&mut self, frame: &mut Frame<'_>) { + let area = centered_rect(64, 20, frame.area()); + let Some(ticket) = self.selected_ticket() else { + return; + }; + let ticket_tags = ticket.tags.clone(); + let title = format!("Manage Tags: {}", ticket.short_id()); + let tags = self.manageable_tags(&ticket_tags); + let selected = self + .manage_tag_state + .selected() + .unwrap_or(0) + .min(tags.len().saturating_sub(1)); + if tags.is_empty() { + self.manage_tag_state.select(None); + } else { + self.manage_tag_state.select(Some(selected)); + } + + let items: Vec> = if tags.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No known tags. Press n to create one on this ticket.", + Style::default().fg(Color::DarkGray), + )))] + } else { + tags.iter() + .map(|tag| { + let checked = if ticket_tags.contains(tag) { + "[x]" + } else { + "[ ]" + }; + ListItem::new(Line::from(vec![ + Span::styled(checked, Style::default().fg(Color::Yellow)), + Span::raw(" "), + Span::styled(tag.clone(), Style::default().fg(tag_color(tag))), + ])) + }) + .collect() + }; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(area); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + let help = Paragraph::new(Line::from(vec![ + Span::styled("Space", Style::default().fg(Color::Yellow)), + Span::raw(" add/remove "), + Span::styled("n", Style::default().fg(Color::Yellow)), + Span::raw(" new tags "), + Span::styled("r", Style::default().fg(Color::Yellow)), + Span::raw(" remove by name "), + Span::styled("Enter/Esc", Style::default().fg(Color::Yellow)), + Span::raw(" finish"), + ])) + .style(Style::default().bg(Color::DarkGray)); + frame.render_widget(Clear, area); + frame.render_stateful_widget(list, chunks[0], &mut self.manage_tag_state); + frame.render_widget(help, chunks[1]); + } + + fn draw_order_modal(&mut self, frame: &mut Frame<'_>) { + let area = centered_rect(52, 10, frame.area()); + let current = self.current_order_choice(); + let selected = self + .order_state + .selected() + .unwrap_or_else(|| order_choice_index(current)) + .min(ORDER_CHOICES.len() - 1); + self.order_state.select(Some(selected)); + + let items: Vec> = ORDER_CHOICES + .iter() + .enumerate() + .map(|(idx, choice)| { + let active = *choice == current; + let marker = if active { "*" } else { " " }; + ListItem::new(Line::from(vec![ + Span::styled(marker, Style::default().fg(Color::Yellow)), + Span::raw(" "), + Span::styled( + format!("{}", idx + 1), + Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(choice.label(), Style::default().fg(Color::Cyan)), + ])) + }) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("List Order")) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + frame.render_widget(Clear, area); + frame.render_stateful_widget(list, area, &mut self.order_state); + } + fn draw_saved_views_modal(&mut self, frame: &mut Frame<'_>) { let area = centered_rect(78, 20, frame.area()); let views = self.view_entries(); @@ -1083,6 +1579,29 @@ impl App { )); lines.push(help_columns(("Enter", "apply"), Some(("Esc", "finish")))); } + Mode::ManageTags => { + help_section(&mut lines, "Ticket Tags"); + lines.push(help_columns( + ("j/k", "move tag"), + Some(("Space", "add / remove")), + )); + lines.push(help_columns( + ("n", "new tags"), + Some(("r", "remove by name")), + )); + lines.push(help_columns(("Enter", "finish"), None)); + lines.push(help_columns(("Esc", "finish"), None)); + } + Mode::Order => { + help_section(&mut lines, "List Order"); + lines.push(help_columns( + ("j/k", "move order"), + Some(("Enter", "apply")), + )); + lines.push(help_columns(("1", "prio"), Some(("2", "date asc")))); + lines.push(help_columns(("3", "date desc"), Some(("4", "state")))); + lines.push(help_columns(("Esc", "cancel"), None)); + } Mode::SavedViews => { help_section(&mut lines, "Views"); lines.push(help_columns( @@ -1139,7 +1658,11 @@ impl App { ("j/k", "move comment"), Some(("c", "add comment")), )); - lines.push(help_columns(("Esc", "detail view"), None)); + lines.push(help_columns( + ("+/-", "resize detail"), + Some(("Esc", "detail view")), + )); + lines.push(help_columns(("r", "refresh"), None)); } Mode::Normal if self.view == ViewMode::Board && self.detail.is_none() => { help_section(&mut lines, "Navigation"); @@ -1152,6 +1675,7 @@ impl App { Some(("Up/Down", "move tickets")), )); lines.push(help_columns(("Enter", "details"), Some(("b", "list view")))); + lines.push(help_columns(("r", "refresh"), None)); help_section(&mut lines, "Edit Ticket"); lines.push(help_columns(("C", "claim"), Some(("s", "state")))); @@ -1159,9 +1683,8 @@ impl App { ("e", "edit title/body"), Some(("i", "edit spec")), )); - lines.push(help_columns(("c", "comment"), None)); - lines.push(help_columns(("p", "priority"), Some(("o", "points")))); - lines.push(help_columns(("+/-", "edit tags"), None)); + lines.push(help_columns(("c", "comment"), Some(("t", "manage tags")))); + lines.push(help_columns(("p", "priority"), Some(("o", "order")))); } Mode::Normal => { help_section(&mut lines, "Navigation"); @@ -1174,13 +1697,16 @@ impl App { Some(("b", "board view")), )); lines.push(help_columns(("m", "comments"), Some(("n", "new ticket")))); + lines.push(help_columns(("+/-", "resize detail"), None)); + lines.push(help_columns(("r", "refresh"), None)); help_section(&mut lines, "Views & Filters"); lines.push(help_columns( ("/", "search text"), Some(("g", "tag picker")), )); - lines.push(help_columns(("v", "saved views"), Some(("V", "save view")))); + lines.push(help_columns(("o", "order"), Some(("v", "saved views")))); + lines.push(help_columns(("V", "save view"), None)); help_section(&mut lines, "Edit Ticket"); lines.push(help_columns(("C", "claim"), Some(("s", "state")))); @@ -1188,9 +1714,8 @@ impl App { ("e", "edit title/body"), Some(("i", "edit spec")), )); - lines.push(help_columns(("c", "comment"), None)); - lines.push(help_columns(("p", "priority"), Some(("o", "points")))); - lines.push(help_columns(("+/-", "edit tags"), None)); + lines.push(help_columns(("c", "comment"), Some(("t", "manage tags")))); + lines.push(help_columns(("p", "priority"), None)); } } @@ -1242,6 +1767,14 @@ impl App { self.handle_tags_key(key); false } + Mode::ManageTags => { + self.handle_manage_tags_key(key)?; + false + } + Mode::Order => { + self.handle_order_key(key)?; + false + } Mode::SavedViews => { self.handle_saved_views_key(key)?; false @@ -1306,6 +1839,10 @@ impl App { self.begin_tag_filter(); false } + KeyCode::Char('t') => { + self.begin_manage_tags(); + false + } KeyCode::Char('v') => { self.begin_saved_views(); false @@ -1315,7 +1852,11 @@ impl App { false } KeyCode::Char('b') => { - self.toggle_view(); + self.handle_board_key(); + false + } + KeyCode::Char('r') => { + self.refresh_data()?; false } KeyCode::Char('n') => { @@ -1383,15 +1924,19 @@ impl App { false } KeyCode::Char('o') => { + self.begin_order(); + false + } + KeyCode::Char('O') => { self.begin_input(InputKind::Points); false } KeyCode::Char('+') | KeyCode::Char('=') => { - self.begin_input(InputKind::AddTags); + self.resize_detail(DETAIL_WIDTH_PERCENT_STEP as i16); false } KeyCode::Char('-') => { - self.begin_input(InputKind::RemoveTags); + self.resize_detail(-(DETAIL_WIDTH_PERCENT_STEP as i16)); false } KeyCode::Char('s') => { @@ -1448,6 +1993,39 @@ impl App { } } + fn handle_manage_tags_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Enter | KeyCode::Esc => { + self.mode = Mode::Normal; + } + KeyCode::Down | KeyCode::Char('j') => self.next_manage_tag(), + KeyCode::Up | KeyCode::Char('k') => self.previous_manage_tag(), + KeyCode::Char(' ') => self.toggle_selected_ticket_tag()?, + KeyCode::Char('n') => self.begin_input(InputKind::AddTags), + KeyCode::Char('r') => self.begin_input(InputKind::RemoveTags), + _ => {} + } + Ok(()) + } + + fn handle_order_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { + self.mode = Mode::Normal; + self.status = Some("Cancelled.".to_string()); + } + KeyCode::Enter => self.apply_selected_order()?, + KeyCode::Down | KeyCode::Char('j') => self.next_order(), + KeyCode::Up | KeyCode::Char('k') => self.previous_order(), + KeyCode::Char('1') => self.apply_order_choice(OrderChoice::Priority)?, + KeyCode::Char('2') => self.apply_order_choice(OrderChoice::DateAsc)?, + KeyCode::Char('3') => self.apply_order_choice(OrderChoice::DateDesc)?, + KeyCode::Char('4') => self.apply_order_choice(OrderChoice::State)?, + _ => {} + } + Ok(()) + } + fn handle_saved_views_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Enter => { @@ -1618,6 +2196,31 @@ impl App { self.mode = Mode::Tags; } + fn begin_manage_tags(&mut self) { + let Some(ticket) = self.selected_ticket() else { + self.status = Some("Select a ticket first.".to_string()); + return; + }; + let tags = self.manageable_tags(&ticket.tags); + if tags.is_empty() { + self.manage_tag_state.select(None); + } else { + let selected = self + .manage_tag_state + .selected() + .unwrap_or(0) + .min(tags.len() - 1); + self.manage_tag_state.select(Some(selected)); + } + self.mode = Mode::ManageTags; + } + + fn begin_order(&mut self) { + self.order_state + .select(Some(order_choice_index(self.current_order_choice()))); + self.mode = Mode::Order; + } + fn begin_saved_views(&mut self) { let views = self.view_entries(); let selected = self @@ -1708,7 +2311,7 @@ impl App { assigned: self.assigned_filter.clone(), only_tagged: self.only_tagged, search: optional_trimmed(&self.filter).map(ToString::to_string), - order: self.sort_order.map(|_| "created.desc".to_string()), + order: Some(self.current_order_choice().spec().to_string()), all: self.base_status.is_none() && self.base_state.is_none(), subissues: !self.hide_subissues, limit: 0, @@ -1998,6 +2601,11 @@ impl App { let Mode::Input(kind) = self.mode else { return Ok(false); }; + let preferred_after_reload = if kind == InputKind::Priority { + self.adjacent_ticket_for_priority_triage(id) + } else { + Some(id) + }; match kind { InputKind::Comment => { @@ -2061,7 +2669,7 @@ impl App { } } - self.reload(Some(id))?; + self.reload(preferred_after_reload)?; if kind == InputKind::Comment { if let Some(ticket) = self.selected_ticket() { if !ticket.comments.is_empty() { @@ -2072,6 +2680,17 @@ impl App { Ok(true) } + fn adjacent_ticket_for_priority_triage(&self, id: uuid::Uuid) -> Option { + let selected = self + .visible + .iter() + .position(|idx| self.tickets[*idx].id == id)?; + self.visible + .get(selected + 1) + .or_else(|| selected.checked_sub(1).and_then(|previous| self.visible.get(previous))) + .map(|idx| self.tickets[*idx].id) + } + fn set_lifecycle(&mut self, status: TicketStatus, state: TicketState) -> Result<()> { let Some(ticket) = self.selected_ticket() else { self.status = Some("Select a ticket first.".to_string()); @@ -2222,6 +2841,120 @@ impl App { self.apply_filter(); } + fn current_order_choice(&self) -> OrderChoice { + match self.sort_order { + Some(order) if order.key == SortKey::Created && !order.desc => OrderChoice::DateAsc, + Some(order) if order.key == SortKey::Created && order.desc => OrderChoice::DateDesc, + Some(order) if order.key == SortKey::State => OrderChoice::State, + Some(order) if order.key == SortKey::Priority => OrderChoice::Priority, + _ => OrderChoice::Priority, + } + } + + fn next_order(&mut self) { + let selected = self.order_state.selected().unwrap_or(0); + self.order_state + .select(Some((selected + 1) % ORDER_CHOICES.len())); + } + + fn previous_order(&mut self) { + let selected = self.order_state.selected().unwrap_or(0); + let previous = selected + .checked_sub(1) + .unwrap_or_else(|| ORDER_CHOICES.len() - 1); + self.order_state.select(Some(previous)); + } + + fn apply_selected_order(&mut self) -> Result<()> { + let selected = self.order_state.selected().unwrap_or(0); + let choice = ORDER_CHOICES + .get(selected) + .copied() + .unwrap_or(OrderChoice::Priority); + self.apply_order_choice(choice) + } + + fn apply_order_choice(&mut self, choice: OrderChoice) -> Result<()> { + let selected_id = self.selected_ticket().map(|ticket| ticket.id); + self.sort_order = choice.sort_order(); + self.mode = Mode::Normal; + self.reload(selected_id)?; + self.status = Some(format!("Ordered by {}.", choice.label())); + Ok(()) + } + + fn manageable_tags(&self, ticket_tags: &BTreeSet) -> Vec { + let mut tags = ticket_tags.clone(); + for ticket in &self.tickets { + tags.extend(ticket.tags.iter().cloned()); + } + tags.into_iter().collect() + } + + fn next_manage_tag(&mut self) { + let Some(ticket) = self.selected_ticket() else { + self.manage_tag_state.select(None); + return; + }; + let tags = self.manageable_tags(&ticket.tags); + if tags.is_empty() { + self.manage_tag_state.select(None); + return; + } + let selected = self.manage_tag_state.selected().unwrap_or(0); + self.manage_tag_state + .select(Some((selected + 1) % tags.len())); + } + + fn previous_manage_tag(&mut self) { + let Some(ticket) = self.selected_ticket() else { + self.manage_tag_state.select(None); + return; + }; + let tags = self.manageable_tags(&ticket.tags); + if tags.is_empty() { + self.manage_tag_state.select(None); + return; + } + let selected = self.manage_tag_state.selected().unwrap_or(0); + let previous = selected + .checked_sub(1) + .unwrap_or_else(|| tags.len().saturating_sub(1)); + self.manage_tag_state.select(Some(previous)); + } + + fn toggle_selected_ticket_tag(&mut self) -> Result<()> { + let Some(ticket) = self.selected_ticket() else { + self.status = Some("Select a ticket first.".to_string()); + return Ok(()); + }; + let id = ticket.id; + let ticket_tags = ticket.tags.clone(); + let tags = self.manageable_tags(&ticket_tags); + let Some(tag) = self + .manage_tag_state + .selected() + .and_then(|selected| tags.get(selected)) + .cloned() + else { + self.status = Some("No tag selected.".to_string()); + return Ok(()); + }; + + if ticket_tags.contains(&tag) { + self.store.remove_tag(&id, &tag)?; + self.status = Some(format!("Removed tag `{tag}`.")); + } else { + self.store.add_tag(&id, &tag)?; + self.status = Some(format!("Added tag `{tag}`.")); + } + self.reload(Some(id))?; + if !self.visible.iter().any(|idx| self.tickets[*idx].id == id) { + self.mode = Mode::Normal; + } + Ok(()) + } + fn apply_filter(&mut self) { let needle = self.filter.to_ascii_lowercase(); self.visible = self @@ -2279,6 +3012,50 @@ impl App { self.sync_open_detail(); } + fn resize_detail(&mut self, delta: i16) { + if self.detail.is_none() { + self.status = Some("Open ticket details first.".to_string()); + return; + } + let next = if delta.is_negative() { + self.detail_width_percent + .saturating_sub(delta.unsigned_abs()) + } else { + self.detail_width_percent.saturating_add(delta as u16) + }; + let next = next.clamp(DETAIL_WIDTH_PERCENT_MIN, DETAIL_WIDTH_PERCENT_MAX); + if next != self.detail_width_percent { + self.detail_width_percent = next; + if let Err(err) = self.save_project_settings() { + self.status = Some(format!("Detail pane: {next}%. Settings not saved: {err}")); + return; + } + } + self.status = Some(format!("Detail pane: {}%.", self.detail_width_percent)); + } + + fn save_project_settings(&self) -> Result<()> { + let git_dir = self.store.session().repo_git_dir(); + let mut state = State::load().unwrap_or_default(); + let mut settings = state.project_settings_for(&git_dir); + settings.detail_width_percent = Some(self.detail_width_percent); + state.set_project_settings(&git_dir, settings); + state.save() + } + + fn refresh_data(&mut self) -> Result<()> { + let selected_id = self.selected_ticket().map(|ticket| ticket.id); + let was_board = self.view == ViewMode::Board && self.detail.is_none(); + self.reload(selected_id)?; + if was_board { + if let Some(id) = selected_id { + self.select_board_ticket_by_id(id); + } + } + self.status = Some("Refreshed.".to_string()); + Ok(()) + } + fn toggle_view(&mut self) { self.view = match self.view { ViewMode::List => ViewMode::Board, @@ -2287,6 +3064,60 @@ impl App { self.sync_board_to_list_selection(); } + fn select_board_ticket_by_id(&mut self, id: uuid::Uuid) { + let Some(ticket_idx) = self.tickets.iter().position(|ticket| ticket.id == id) else { + return; + }; + let ticket_state = self.tickets[ticket_idx].state; + let Some(column) = BOARD_STATES.iter().position(|state| *state == ticket_state) else { + return; + }; + let Some(row) = self + .board_column_tickets(column) + .iter() + .position(|idx| **idx == ticket_idx) + else { + return; + }; + self.board_column = column; + self.board_rows[column] = row; + } + + fn handle_board_key(&mut self) { + if self.detail.is_some() { + self.open_board_for_detail_ticket(); + } else { + self.toggle_view(); + } + } + + fn open_board_for_detail_ticket(&mut self) { + let Some(idx) = self.detail else { + return; + }; + let ticket_state = self.tickets[idx].state; + let Some(column) = BOARD_STATES.iter().position(|state| *state == ticket_state) else { + self.status = Some("Selected ticket is not on the board.".to_string()); + return; + }; + let Some(row) = self + .board_column_tickets(column) + .iter() + .position(|ticket_idx| **ticket_idx == idx) + else { + self.status = + Some("Selected ticket is hidden by the current board filters.".to_string()); + return; + }; + + self.view = ViewMode::Board; + self.detail = None; + self.comments_mode = false; + self.board_column = column; + self.board_rows[column] = row; + self.sync_board_to_list_selection(); + } + fn open_selected(&mut self) { if let Some(idx) = self.selected_ticket_index() { self.detail = Some(idx); @@ -2483,6 +3314,54 @@ impl NewTicketDraft { } } +impl OrderChoice { + fn label(self) -> &'static str { + match self { + OrderChoice::Priority => "priority", + OrderChoice::DateAsc => "date asc", + OrderChoice::DateDesc => "date desc", + OrderChoice::State => "state", + } + } + + fn spec(self) -> &'static str { + match self { + OrderChoice::Priority => "priority", + OrderChoice::DateAsc => "created", + OrderChoice::DateDesc => "created.desc", + OrderChoice::State => "state", + } + } + + fn sort_order(self) -> Option { + Some(match self { + OrderChoice::Priority => SortOrder { + key: SortKey::Priority, + desc: false, + }, + OrderChoice::DateAsc => SortOrder { + key: SortKey::Created, + desc: false, + }, + OrderChoice::DateDesc => SortOrder { + key: SortKey::Created, + desc: true, + }, + OrderChoice::State => SortOrder { + key: SortKey::State, + desc: false, + }, + }) + } +} + +fn order_choice_index(choice: OrderChoice) -> usize { + ORDER_CHOICES + .iter() + .position(|candidate| *candidate == choice) + .unwrap_or(0) +} + impl InputKind { fn label(self) -> &'static str { match self { @@ -2505,6 +3384,95 @@ impl InputKind { } } +fn menu_bar_line( + width: usize, + mode: &str, + detail: Option<&str>, + hints: &[MenuHint], +) -> Line<'static> { + let mut spans = vec![ + Span::styled( + " ti tui ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(" "), + Span::styled( + format!("{mode} "), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ]; + let help = MenuHint { + key: "?", + desc: "help", + }; + let help_width = menu_hint_width(help); + let separator_width = 2; + + if width <= help_width { + return Line::from(menu_hint_spans(help)); + } + + let mut used = spans_width(&spans); + let reserve_for_help = separator_width + help_width; + + if let Some(detail) = detail.filter(|detail| !detail.is_empty()) { + if let Some(available) = width + .checked_sub(used) + .and_then(|available| available.checked_sub(separator_width + reserve_for_help)) + { + let detail_width = available.min(36); + if detail_width >= 4 { + append_menu_separator(&mut spans); + let value = truncate_display(detail, detail_width); + used += separator_width + UnicodeWidthStr::width(value.as_str()); + spans.push(Span::styled(value, Style::default().fg(Color::Cyan))); + } + } + } + + for hint in hints { + let hint_width = menu_hint_width(*hint); + if used + separator_width + hint_width + reserve_for_help > width { + continue; + } + append_menu_separator(&mut spans); + spans.extend(menu_hint_spans(*hint)); + used += separator_width + hint_width; + } + + if used + reserve_for_help <= width { + append_menu_separator(&mut spans); + spans.extend(menu_hint_spans(help)); + } else { + spans = menu_hint_spans(help); + } + + Line::from(spans) +} + +fn menu_hint_width(hint: MenuHint) -> usize { + UnicodeWidthStr::width(hint.key) + 1 + UnicodeWidthStr::width(hint.desc) +} + +fn menu_hint_spans(hint: MenuHint) -> Vec> { + vec![ + Span::styled( + hint.key, + Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(hint.desc, Style::default().fg(Color::Gray)), + ] +} + +fn append_menu_separator(spans: &mut Vec>) { + spans.push(Span::raw(" ")); +} + fn parse_optional_i64(raw: &str, label: &str) -> Result> { let Some(value) = optional_trimmed(raw) else { return Ok(None); @@ -2577,13 +3545,22 @@ fn ticket_list_line( .chars() .take(LIST_ID_WIDTH) .collect::(); - let meta = list_meta_display(ticket); + let title = flatten_display(&ticket.title); + if compact { + return compact_ticket_list_line( + &short_id, + &title, + &list_meta_display(ticket), + ticket.assigned.as_deref() == Some(current_user), + width, + ); + } ticket_list_line_from_parts( - &short_id, - &flatten_display(&ticket.title), - &meta, - if compact { None } else { Some(&ticket.tags) }, + Some(&short_id), + &title, + &list_meta_display(ticket), + Some(&ticket.tags), ticket.assigned.as_deref() == Some(current_user), width, ) @@ -2621,46 +3598,44 @@ fn priority_points_display(ticket: &Ticket) -> Option { } fn ticket_list_line_from_parts( - short_id: &str, + short_id: Option<&str>, title: &str, meta: &[(String, Style)], tags: Option<&BTreeSet>, assigned_to_current_user: bool, width: usize, ) -> Line<'static> { - let id = truncate_display(short_id, width); - let id_width = UnicodeWidthStr::width(id.as_str()); - let star = if assigned_to_current_user { "*" } else { " " }; - let star_width = width - .saturating_sub(id_width) - .min(UnicodeWidthStr::width(star)); - let gap_width = width.saturating_sub(id_width + star_width).min(1); - let gap = " ".repeat(gap_width); - let mut leading = vec![ - Span::styled(id, Style::default().fg(Color::DarkGray)), - Span::styled( - truncate_display(star, star_width), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - Span::raw(gap), - ]; - let meta_width = push_meta_spans( - &mut leading, - meta, - width.saturating_sub(id_width + star_width + gap_width), - ); + let mut leading = Vec::new(); + let mut used_width = 0; + if let Some(short_id) = short_id { + let id = truncate_display(short_id, width); + let id_width = UnicodeWidthStr::width(id.as_str()); + let star = if assigned_to_current_user { "*" } else { " " }; + let star_width = width + .saturating_sub(id_width) + .min(UnicodeWidthStr::width(star)); + let gap_width = width.saturating_sub(id_width + star_width).min(1); + let gap = " ".repeat(gap_width); + used_width = id_width + star_width + gap_width; + leading.extend([ + Span::styled(id, Style::default().fg(Color::DarkGray)), + Span::styled( + truncate_display(star, star_width), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(gap), + ]); + } + let meta_width = push_meta_spans(&mut leading, meta, width.saturating_sub(used_width)); let meta_gap_width = if meta_width > 0 { - width - .saturating_sub(id_width + star_width + gap_width + meta_width) - .min(1) + width.saturating_sub(used_width + meta_width).min(1) } else { 0 }; let meta_gap = " ".repeat(meta_gap_width); - let content_width = - width.saturating_sub(id_width + star_width + gap_width + meta_width + meta_gap_width); + let content_width = width.saturating_sub(used_width + meta_width + meta_gap_width); if meta_width > 0 { leading.push(Span::raw(meta_gap)); @@ -2695,12 +3670,76 @@ fn ticket_list_line_from_parts( Line::from(leading) } +fn compact_ticket_list_line( + short_id: &str, + title: &str, + meta: &[(String, Style)], + assigned_to_current_user: bool, + width: usize, +) -> Line<'static> { + let title_target_width = COMPACT_LIST_MIN_TITLE_WIDTH.min(width).max(1); + let mut short_id = Some(short_id); + let mut meta = meta.to_vec(); + + while compact_title_width(short_id, &meta, width) < title_target_width { + if !remove_first_meta_width(&mut meta, LIST_STATE_WIDTH) { + if !remove_first_meta_width(&mut meta, LIST_AGE_WIDTH) { + if short_id.take().is_none() + && !remove_first_meta_width(&mut meta, LIST_PRIORITY_WIDTH) + { + break; + } + } + } + } + + ticket_list_line_from_parts( + short_id, + title, + &meta, + None, + assigned_to_current_user, + width, + ) +} + +fn compact_title_width( + short_id: Option<&str>, + meta: &[(String, Style)], + width: usize, +) -> usize { + let id_width = short_id + .map(|id| UnicodeWidthStr::width(id).min(width)) + .unwrap_or_default(); + let star_width = short_id + .map(|_| width.saturating_sub(id_width).min(1)) + .unwrap_or_default(); + let id_gap_width = short_id + .map(|_| width.saturating_sub(id_width + star_width).min(1)) + .unwrap_or_default(); + let meta_width = meta + .iter() + .map(|(value, _)| UnicodeWidthStr::width(value.as_str())) + .sum::() + .min(width.saturating_sub(id_width + star_width + id_gap_width)); + let meta_gap_width = usize::from(meta_width > 0) + .min(width.saturating_sub(id_width + star_width + id_gap_width + meta_width)); + width.saturating_sub(id_width + star_width + id_gap_width + meta_width + meta_gap_width) +} + +fn remove_first_meta_width(meta: &mut Vec<(String, Style)>, width: usize) -> bool { + let Some(idx) = meta + .iter() + .position(|(value, _)| UnicodeWidthStr::width(value.as_str()) == width) + else { + return false; + }; + meta.remove(idx); + true +} + fn list_meta_display(ticket: &Ticket) -> Vec<(String, Style)> { vec![ - ( - fit_display(state_abbrev(ticket.state), LIST_STATE_WIDTH), - state_abbrev_style(ticket.state), - ), ( fit_display( &relative_date(ticket.created_at, OffsetDateTime::now_utc()), @@ -2710,6 +3749,10 @@ fn list_meta_display(ticket: &Ticket) -> Vec<(String, Style)> { .fg(Color::DarkGray) .add_modifier(Modifier::DIM), ), + ( + fit_display(state_abbrev(ticket.state), LIST_STATE_WIDTH), + state_abbrev_style(ticket.state), + ), ( fit_display( &ticket @@ -2725,15 +3768,15 @@ fn list_meta_display(ticket: &Ticket) -> Vec<(String, Style)> { fn state_abbrev(state: TicketState) -> &'static str { match state { - TicketState::New => "NW", - TicketState::Assigned => "AS", - TicketState::InProgress => "IP", - TicketState::Blocked => "BL", - TicketState::Review => "RV", - TicketState::Resolved => "RS", - TicketState::Wontfix => "WF", - TicketState::Duplicate => "DP", - TicketState::Invalid => "IV", + TicketState::New => "nw", + TicketState::Assigned => "as", + TicketState::InProgress => "ip", + TicketState::Blocked => "bl", + TicketState::Review => "rv", + TicketState::Resolved => "rs", + TicketState::Wontfix => "wf", + TicketState::Duplicate => "dp", + TicketState::Invalid => "iv", } } @@ -3017,6 +4060,41 @@ fn field_line(label: &str, value: &str) -> Line<'static> { ]) } +fn spec_field_line(spec: &str, width: usize) -> Line<'static> { + let label_width = 10; + let separator_width = 3; + let hint_key = "i"; + let hint_desc = "view/edit"; + let hint_width = UnicodeWidthStr::width(hint_key) + 1 + UnicodeWidthStr::width(hint_desc); + let first_line = first_spec_line(spec); + let value_budget = width.saturating_sub(label_width + separator_width + hint_width + 2); + let value = truncate_display(first_line, value_budget); + let value_width = UnicodeWidthStr::width(value.as_str()); + let padding_width = width + .saturating_sub(label_width + separator_width + value_width + hint_width) + .max(2); + + Line::from(vec![ + Span::styled( + format!("{:<10}", "Spec"), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" : ", Style::default().fg(Color::DarkGray)), + Span::styled(value, Style::default().fg(Color::Cyan)), + Span::raw(" ".repeat(padding_width)), + Span::styled( + hint_key, + Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(hint_desc, Style::default().fg(Color::DarkGray)), + ]) +} + fn tags_field_line(tags: &BTreeSet) -> Line<'static> { let mut spans = vec![ Span::styled( @@ -3168,19 +4246,7 @@ fn status_state_line(ticket: &Ticket) -> Line<'static> { ), Span::styled(" : ", Style::default().fg(Color::DarkGray)), Span::styled( - ticket.status.as_str().to_string(), - Style::default().fg(Color::Green), - ), - Span::raw(" "), - Span::styled( - "State", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" : ", Style::default().fg(Color::DarkGray)), - Span::styled( - ticket.state.as_str().to_string(), + format!("{}:{}", ticket.status.as_str(), ticket.state.as_str()), Style::default().fg(Color::Green), ), ]) diff --git a/crates/ticgit/src/session_state.rs b/crates/ticgit/src/session_state.rs index 855807ec..0f61109d 100644 --- a/crates/ticgit/src/session_state.rs +++ b/crates/ticgit/src/session_state.rs @@ -26,6 +26,15 @@ pub struct State { /// Map of canonicalised git-dir path → named saved views. #[serde(default)] pub views: HashMap>, + /// Map of canonicalised git-dir path → per-user project UI settings. + #[serde(default)] + pub project_settings: HashMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ProjectSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub detail_width_percent: Option, } /// A saved set of list filter parameters. @@ -158,6 +167,17 @@ impl State { }) .unwrap_or_default() } + + pub fn project_settings_for(&self, git_dir: &Path) -> ProjectSettings { + self.project_settings + .get(&key_for(git_dir)) + .cloned() + .unwrap_or_default() + } + + pub fn set_project_settings(&mut self, git_dir: &Path, settings: ProjectSettings) { + self.project_settings.insert(key_for(git_dir), settings); + } } fn key_for(git_dir: &Path) -> String { diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 00000000..30f7847f --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,133 @@ +--- +name: ticgit +description: Use TicGit (`ti`) to track Git-native tickets in this repository. +--- + +# TicGit Agent Guide + +TicGit stores tickets as Git metadata. Use `ti` for planning, progress notes, +triage, and resolving work. Prefer commands with `--markdown` when reading +ticket data because Markdown output includes useful context and next commands. + +## Basic Workflow + +Find work: + +```sh +ti list --markdown +ti next --markdown +ti show --markdown +``` + +Select a current ticket when you will run several commands against it: + +```sh +ti checkout +ti show --markdown +ti comment "progress update" +``` + +Claim work before starting: + +```sh +ti claim +``` + +## Create And Edit Tickets + +Create a ticket from a file. The first line is the title; the rest is the +description: + +```sh +ti new -F /tmp/ticket.md --tags bug,parser --markdown +``` + +Edit title and description: + +```sh +ti edit +ti edit -F /tmp/ticket.md +``` + +## Progress Notes + +Add comments for useful observations, plans, blockers, and verification: + +```sh +ti comment -t "found the failing case" +ti comment "implemented fix; running cargo test -p ticgit" +ti comment -t --edit +``` + +## State And Triage + +Tickets have a broad status and a specific state: + +```text +open: new, assigned, in-progress, blocked, review +closed: resolved, wontfix, duplicate, invalid +``` + +Useful updates: + +```sh +ti state blocked -t +ti state review -t +ti close -t +``` + +`ti close` resolves the ticket and records the current user as `closed_by`. + +## Planning Fields + +Use priority, tags, estimates, and milestones to keep work easy to sort: + +```sh +ti priority -t 2 +ti tag -t bug parser +ti points -t 3 +ti milestone -t v1.0 +``` + +Use `spec` for implementation notes before coding: + +```sh +ti spec -t -F /tmp/spec.md +ti show --filter .spec +``` + +## Dependencies + +Track ordering constraints explicitly: + +```sh +ti dep -t +ti dep -t --remove +``` + +`ti next` skips tickets with unresolved dependencies. + +## Saved Views And Sync + +Save common filters: + +```sh +ti list --tag bug +ti views save bugs +ti list bugs --markdown +``` + +Sync ticket metadata when collaborating: + +```sh +ti sync +``` + +## Agent Practices + +- Use ticket IDs or unique prefixes. +- Prefer `--markdown` for reading tickets. +- Check for a spec before implementing; add one if the path is unclear. +- Comment when you learn something important or finish a meaningful step. +- Mark blockers with `ti state blocked` and dependencies with `ti dep`. +- Resolve tickets only after implementation and verification are complete. diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 00000000..6ebb1e10 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ $# -ne 1 ]; then + echo "Usage: $0 " + echo "Example: $0 0.2.0" + exit 1 +fi + +NEW="$1" +ROOT="$(cd "$(dirname "$0")" && pwd)" + +# Update workspace version in Cargo.toml +sed -i '' "s/^version = \".*\"/version = \"${NEW}\"/" "$ROOT/Cargo.toml" + +# Update version strings in docs/index.html +sed -i '' "s/ticgit [0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*/ticgit ${NEW}/g" "$ROOT/docs/index.html" +sed -i '' "s/>[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$NEW Date: Tue, 12 May 2026 16:05:03 +0200 Subject: [PATCH 3/4] tui updates --- crates/ticgit/src/commands/list.rs | 1 + crates/ticgit/src/commands/tui.rs | 16 +++++++-- crates/ticgit/src/session_state.rs | 55 ++++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/ticgit/src/commands/list.rs b/crates/ticgit/src/commands/list.rs index 46140869..cf405e66 100644 --- a/crates/ticgit/src/commands/list.rs +++ b/crates/ticgit/src/commands/list.rs @@ -145,6 +145,7 @@ pub fn run(args: Args) -> Result<()> { // Save last-used filters so `ti views save` can recall them. if args.view.is_none() { let saved = SavedView { + created_at: None, status: args.status.clone(), state: args.state.clone(), tag: args.tag.first().cloned(), diff --git a/crates/ticgit/src/commands/tui.rs b/crates/ticgit/src/commands/tui.rs index 70b3adee..c0d80141 100644 --- a/crates/ticgit/src/commands/tui.rs +++ b/crates/ticgit/src/commands/tui.rs @@ -1087,7 +1087,10 @@ impl App { .add_modifier(Modifier::DIM), ), Span::raw(" "), - Span::styled(comment.author.clone(), Style::default().fg(Color::Cyan)), + Span::styled( + comment_author_display(&comment.author), + Style::default().fg(Color::Cyan), + ), ]), Line::raw(""), ]; @@ -3980,7 +3983,7 @@ fn github_author(description: &str) -> Option<&str> { fn comment_summary_line(comment: &Comment, width: usize) -> Line<'static> { let date = relative_date(comment.at, OffsetDateTime::now_utc()); - let author = truncate_display(&comment.author, 14); + let author = comment_author_display(&comment.author); let prefix_width = UnicodeWidthStr::width(date.as_str()) + 2 + UnicodeWidthStr::width(author.as_str()) + 2; let body_width = width.saturating_sub(prefix_width); @@ -4000,6 +4003,15 @@ fn comment_summary_line(comment: &Comment, width: usize) -> Line<'static> { ]) } +fn comment_author_display(author: &str) -> String { + let display = author + .split_once('@') + .map(|(local, _)| local) + .filter(|local| !local.is_empty()) + .unwrap_or(author); + truncate_display(display, 15) +} + fn help_heading(label: &str) -> Line<'static> { Line::from(Span::styled( label.to_string(), diff --git a/crates/ticgit/src/session_state.rs b/crates/ticgit/src/session_state.rs index 0f61109d..23b0ae1a 100644 --- a/crates/ticgit/src/session_state.rs +++ b/crates/ticgit/src/session_state.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; use uuid::Uuid; #[derive(Debug, Default, Serialize, Deserialize)] @@ -40,6 +41,12 @@ pub struct ProjectSettings { /// A saved set of list filter parameters. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SavedView { + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub created_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -139,7 +146,10 @@ impl State { self.last_filters.get(&key_for(git_dir)) } - pub fn save_view(&mut self, git_dir: &Path, name: &str, view: SavedView) { + pub fn save_view(&mut self, git_dir: &Path, name: &str, mut view: SavedView) { + if view.created_at.is_none() { + view.created_at = Some(OffsetDateTime::now_utc()); + } self.views .entry(key_for(git_dir)) .or_default() @@ -162,7 +172,11 @@ impl State { .get(&key_for(git_dir)) .map(|m| { let mut v: Vec<_> = m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - v.sort_by(|a, b| a.0.cmp(&b.0)); + v.sort_by(|a, b| { + b.1.created_at + .cmp(&a.1.created_at) + .then_with(|| a.0.cmp(&b.0)) + }); v }) .unwrap_or_default() @@ -187,3 +201,40 @@ fn key_for(git_dir: &Path) -> String { .to_string_lossy() .to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_views_orders_newest_first() { + let mut state = State::default(); + let git_dir = Path::new("/tmp/ticgit-session-state-test"); + let older = OffsetDateTime::from_unix_timestamp(1).unwrap(); + let newer = OffsetDateTime::from_unix_timestamp(2).unwrap(); + + state.save_view( + git_dir, + "older", + SavedView { + created_at: Some(older), + ..Default::default() + }, + ); + state.save_view( + git_dir, + "newer", + SavedView { + created_at: Some(newer), + ..Default::default() + }, + ); + + let names = state + .list_views(git_dir) + .into_iter() + .map(|(name, _)| name) + .collect::>(); + assert_eq!(names, vec!["newer", "older"]); + } +} From b7f44e85008fdf0ddb811a2eba59c09ca6a18913 Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Wed, 13 May 2026 08:58:05 +0200 Subject: [PATCH 4/4] Add writeup workflow Add Git-native writeups with CLI and TUI support, including versions, tags, issue linking, and linked-item navigation in the TUI. --- crates/ticgit-lib/src/keys.rs | 76 +- crates/ticgit-lib/src/lib.rs | 8 + crates/ticgit-lib/src/store.rs | 424 ++++++ crates/ticgit-lib/src/writeup.rs | 67 + crates/ticgit/src/cli.rs | 5 + crates/ticgit/src/commands/import.rs | 16 +- crates/ticgit/src/commands/mod.rs | 1 + crates/ticgit/src/commands/tag.rs | 70 +- crates/ticgit/src/commands/tui.rs | 1890 +++++++++++++++++++++++-- crates/ticgit/src/commands/writeup.rs | 350 +++++ crates/ticgit/tests/cli.rs | 110 ++ 11 files changed, 2878 insertions(+), 139 deletions(-) create mode 100644 crates/ticgit-lib/src/writeup.rs create mode 100644 crates/ticgit/src/commands/writeup.rs diff --git a/crates/ticgit-lib/src/keys.rs b/crates/ticgit-lib/src/keys.rs index b722461d..45bf0a96 100644 --- a/crates/ticgit-lib/src/keys.rs +++ b/crates/ticgit-lib/src/keys.rs @@ -11,6 +11,7 @@ //! keys: //! ticgit: # ticketing-system metadata //! ticgit:tickets:: # per-ticket fields +//! ticgit:writeups:: # per-writeup fields //! ticgit:views: # saved selections (set of UUIDs) //! ``` @@ -46,6 +47,26 @@ pub fn ticket_field(id: &Uuid, field: &str) -> String { format!("{NS}:tickets:{id}:{field}") } +/// Prefix for the per-writeup field keyspace; pass to +/// `SessionTargetHandle::get_all_values` for project-wide writeup scans. +#[must_use] +pub fn writeups_prefix() -> String { + format!("{NS}:writeups") +} + +/// All keys for a single writeup share this prefix; used for one-writeup +/// scans. +#[must_use] +pub fn writeup_prefix(id: &Uuid) -> String { + format!("{NS}:writeups:{id}") +} + +/// A specific field on a specific writeup, e.g. `ticgit:writeups::title`. +#[must_use] +pub fn writeup_field(id: &Uuid, field: &str) -> String { + format!("{NS}:writeups:{id}:{field}") +} + /// A specific metadata field on a ticket, e.g. `ticgit:tickets::meta:branch`. #[must_use] pub fn ticket_meta_field(id: &Uuid, field: &str) -> String { @@ -87,7 +108,11 @@ pub fn user_key(nick: &str) -> String { pub fn parse_user_nick(key: &str) -> Option<&str> { let prefix = format!("{NS}:users:"); let name = key.strip_prefix(&prefix)?; - if name.is_empty() { None } else { Some(name) } + if name.is_empty() { + None + } else { + Some(name) + } } /// If `key` is a per-ticket field key, returns `(ticket_uuid, field_name)`. @@ -104,6 +129,20 @@ pub fn parse_ticket_field(key: &str) -> Option<(Uuid, &str)> { Some((uuid, field)) } +/// If `key` is a per-writeup field key, returns `(writeup_uuid, field_name)`. +/// Returns `None` for system keys, ticket keys, or anything malformed. +#[must_use] +pub fn parse_writeup_field(key: &str) -> Option<(Uuid, &str)> { + let prefix = format!("{NS}:writeups:"); + let rest = key.strip_prefix(&prefix)?; + let (uuid_part, field) = rest.split_once(':')?; + let uuid = Uuid::parse_str(uuid_part).ok()?; + if field.is_empty() { + return None; + } + Some((uuid, field)) +} + /// If `key` is a saved-view key, returns the view name. #[must_use] pub fn parse_view_name(key: &str) -> Option<&str> { @@ -140,6 +179,15 @@ mod tests { ticket_field(&id, "state"), "ticgit:tickets:00000000-0000-0000-0000-000000000001:state" ); + assert_eq!(writeups_prefix(), "ticgit:writeups"); + assert_eq!( + writeup_prefix(&id), + "ticgit:writeups:00000000-0000-0000-0000-000000000001" + ); + assert_eq!( + writeup_field(&id, "title"), + "ticgit:writeups:00000000-0000-0000-0000-000000000001:title" + ); assert_eq!( ticket_meta_field(&id, "branch"), "ticgit:tickets:00000000-0000-0000-0000-000000000001:meta:branch" @@ -177,11 +225,37 @@ mod tests { fn parse_ticket_field_rejects_non_ticket_keys() { assert!(parse_ticket_field("ticgit:owners").is_none()); assert!(parse_ticket_field("ticgit:views:foo").is_none()); + assert!( + parse_ticket_field("ticgit:writeups:00000000-0000-0000-0000-000000000001:title") + .is_none() + ); assert!(parse_ticket_field("ticgit:tickets").is_none()); assert!(parse_ticket_field("ticgit:tickets:not-a-uuid:title").is_none()); assert!(parse_ticket_field("foo:bar:baz").is_none()); } + #[test] + fn parse_writeup_field_round_trips_known_uuids() { + let id = fixed_uuid(); + let key = writeup_field(&id, "versions"); + let (got_id, field) = parse_writeup_field(&key).expect("should parse"); + assert_eq!(got_id, id); + assert_eq!(field, "versions"); + } + + #[test] + fn parse_writeup_field_rejects_non_writeup_keys() { + assert!(parse_writeup_field("ticgit:owners").is_none()); + assert!(parse_writeup_field("ticgit:views:foo").is_none()); + assert!( + parse_writeup_field("ticgit:tickets:00000000-0000-0000-0000-000000000001:title") + .is_none() + ); + assert!(parse_writeup_field("ticgit:writeups").is_none()); + assert!(parse_writeup_field("ticgit:writeups:not-a-uuid:title").is_none()); + assert!(parse_writeup_field("foo:bar:baz").is_none()); + } + #[test] fn parse_view_name_works() { assert_eq!(parse_view_name("ticgit:views:mine"), Some("mine")); diff --git a/crates/ticgit-lib/src/lib.rs b/crates/ticgit-lib/src/lib.rs index 1b4fa212..3e1398a4 100644 --- a/crates/ticgit-lib/src/lib.rs +++ b/crates/ticgit-lib/src/lib.rs @@ -17,6 +17,12 @@ //! ticgit:tickets::comments # list of JSON-encoded {author, body} //! ticgit:tickets::created-at # RFC3339 string //! ticgit:tickets::created-by # string (email) +//! ticgit:writeups::title # string +//! ticgit:writeups::status # string ("open" | "closed") +//! ticgit:writeups::tags # set +//! ticgit:writeups::authors # set of emails +//! ticgit:writeups::versions # list of markdown documents +//! ticgit:writeups::tickets # set of linked ticket UUIDs //! ticgit:views: # set of UUIDs (saved selection) //! ticgit:owners # set of emails //! ticgit:schema-version # string ("1") @@ -30,6 +36,7 @@ pub mod keys; pub mod query; pub mod store; pub mod ticket; +pub mod writeup; #[cfg(any(test, feature = "test-support"))] pub mod test_support; @@ -40,6 +47,7 @@ pub use store::TicketStore; pub use ticket::{ validate_code_uri, Comment, NewTicketOpts, Ticket, TicketLifecycle, TicketState, TicketStatus, }; +pub use writeup::{NewWriteupOpts, Writeup, WriteupStatus, WriteupVersion}; /// Re-exported for callers who want to talk to git-meta directly. pub use git_meta_lib::{MetaValue, Session, Target}; diff --git a/crates/ticgit-lib/src/store.rs b/crates/ticgit-lib/src/store.rs index 1dd64397..4e39f197 100644 --- a/crates/ticgit-lib/src/store.rs +++ b/crates/ticgit-lib/src/store.rs @@ -15,6 +15,7 @@ use uuid::Uuid; use crate::error::{Error, Result}; use crate::keys; use crate::ticket::{Comment, CommentBody, NewTicketOpts, Ticket, TicketState, TicketStatus}; +use crate::writeup::{NewWriteupOpts, Writeup, WriteupStatus, WriteupVersion}; /// Basic email format check: must contain exactly one `@` with non-empty /// local and domain parts. @@ -528,6 +529,28 @@ impl TicketStore { Ok(()) } + pub fn add_writeup_tag(&self, id: &Uuid, tag: &str) -> Result<()> { + self.load_writeup(id)?; + let tag = tag.trim(); + if tag.is_empty() { + return Ok(()); + } + self.project_handle() + .set_add(&keys::writeup_field(id, "tags"), tag)?; + Ok(()) + } + + pub fn remove_writeup_tag(&self, id: &Uuid, tag: &str) -> Result<()> { + self.load_writeup(id)?; + let tag = tag.trim(); + if tag.is_empty() { + return Ok(()); + } + self.project_handle() + .set_remove(&keys::writeup_field(id, "tags"), tag)?; + Ok(()) + } + pub fn add_comment(&self, id: &Uuid, body: &str) -> Result<()> { let p = self.project_handle(); self.push_comment(&p, id, body)?; @@ -551,6 +574,220 @@ impl TicketStore { Ok(()) } + // ------------------------------------------------------------------- + // Writeups + // ------------------------------------------------------------------- + + pub fn create_writeup(&self, title: &str, opts: NewWriteupOpts) -> Result { + let title = title.trim(); + if title.is_empty() { + return Err(Error::InvalidValue( + "writeup title cannot be empty".to_string(), + )); + } + + let id = Uuid::new_v4(); + let p = self.project_handle(); + let now = opts.created_at.unwrap_or_else(OffsetDateTime::now_utc); + let now_rfc = now + .format(&Rfc3339) + .map_err(|e| Error::Time(e.to_string()))?; + let author = self.session.email(); + validate_email(author)?; + + p.set(&keys::writeup_field(&id, "title"), title)?; + p.set( + &keys::writeup_field(&id, "status"), + WriteupStatus::Open.as_str(), + )?; + p.set(&keys::writeup_field(&id, "created-at"), now_rfc.as_str())?; + p.set(&keys::writeup_field(&id, "created-by"), author)?; + p.set_add(&keys::writeup_field(&id, "authors"), author)?; + + for tag in opts.tags { + let tag = tag.trim(); + if !tag.is_empty() { + p.set_add(&keys::writeup_field(&id, "tags"), tag)?; + } + } + + if let Some(body) = opts.body { + self.push_writeup_version(&p, &id, &body)?; + } + + self.load_writeup(&id) + } + + pub fn list_writeups(&self) -> Result> { + let p = self.project_handle(); + let pairs = p.get_all_values(Some(&keys::writeups_prefix()))?; + let mut by_id: BTreeMap> = BTreeMap::new(); + for (key, value) in pairs { + if let Some((id, field)) = keys::parse_writeup_field(&key) { + by_id + .entry(id) + .or_default() + .push((field.to_string(), value)); + } + } + + let mut out = Vec::with_capacity(by_id.len()); + for (id, fields) in by_id { + if let Some(writeup) = build_writeup(id, fields) { + out.push(writeup); + } + } + out.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| a.title.cmp(&b.title)) + }); + Ok(out) + } + + pub fn load_writeup(&self, id: &Uuid) -> Result { + let p = self.project_handle(); + let pairs = p.get_all_values(Some(&keys::writeup_prefix(id)))?; + let mut fields = Vec::with_capacity(pairs.len()); + for (key, value) in pairs { + if let Some((parsed_id, field)) = keys::parse_writeup_field(&key) { + if parsed_id == *id { + fields.push((field.to_string(), value)); + } + } + } + build_writeup(*id, fields).ok_or(Error::NotFound(*id)) + } + + pub fn resolve_writeup_id(&self, reference: &str) -> Result { + let needle = reference.trim().to_ascii_lowercase().replace('-', ""); + if needle.is_empty() { + return Err(Error::NoMatch(reference.to_string())); + } + let writeups = self.list_writeups()?; + let matches: Vec<&Writeup> = writeups + .iter() + .filter(|writeup| { + let hex = writeup.id.to_string().replace('-', ""); + hex.starts_with(&needle) + }) + .collect(); + match matches.len() { + 0 => Err(Error::NoMatch(reference.to_string())), + 1 => Ok(matches[0].id), + n => { + let open_matches: Vec<&Writeup> = matches + .iter() + .copied() + .filter(|writeup| writeup.status == WriteupStatus::Open) + .collect(); + if open_matches.len() == 1 { + Ok(open_matches[0].id) + } else { + Err(Error::Ambiguous(reference.to_string(), n)) + } + } + } + } + + pub fn append_writeup_version(&self, id: &Uuid, body: &str) -> Result<()> { + self.load_writeup(id)?; + let p = self.project_handle(); + self.push_writeup_version(&p, id, body)?; + Ok(()) + } + + pub fn set_writeup_title(&self, id: &Uuid, title: &str) -> Result<()> { + self.load_writeup(id)?; + let title = title.trim(); + if title.is_empty() { + return Err(Error::InvalidValue( + "writeup title cannot be empty".to_string(), + )); + } + self.project_handle() + .set(&keys::writeup_field(id, "title"), title)?; + Ok(()) + } + + fn push_writeup_version( + &self, + handle: &git_meta_lib::SessionTargetHandle<'_>, + id: &Uuid, + body: &str, + ) -> Result<()> { + let body = body.trim(); + if body.is_empty() { + return Err(Error::InvalidValue( + "writeup version body cannot be empty".to_string(), + )); + } + let author = self.session.email(); + validate_email(author)?; + let now = OffsetDateTime::now_utc(); + let now_rfc = now + .format(&Rfc3339) + .map_err(|e| Error::Time(e.to_string()))?; + let doc = format!("---\nauthor: {author}\ndate: {now_rfc}\n---\n\n{body}"); + handle.list_push(&keys::writeup_field(id, "versions"), &doc)?; + handle.set_add(&keys::writeup_field(id, "authors"), author)?; + Ok(()) + } + + pub fn set_writeup_status(&self, id: &Uuid, status: WriteupStatus) -> Result<()> { + self.load_writeup(id)?; + self.project_handle() + .set(&keys::writeup_field(id, "status"), status.as_str())?; + Ok(()) + } + + pub fn link_writeup_ticket(&self, writeup_id: &Uuid, ticket_id: &Uuid) -> Result<()> { + self.load_writeup(writeup_id)?; + self.load(ticket_id)?; + let p = self.project_handle(); + p.set_add( + &keys::writeup_field(writeup_id, "tickets"), + &ticket_id.to_string(), + )?; + p.set_add( + &keys::ticket_field(ticket_id, "writeups"), + &writeup_id.to_string(), + )?; + Ok(()) + } + + pub fn unlink_writeup_ticket(&self, writeup_id: &Uuid, ticket_id: &Uuid) -> Result<()> { + self.load_writeup(writeup_id)?; + self.load(ticket_id)?; + let p = self.project_handle(); + p.set_remove( + &keys::writeup_field(writeup_id, "tickets"), + &ticket_id.to_string(), + )?; + p.set_remove( + &keys::ticket_field(ticket_id, "writeups"), + &writeup_id.to_string(), + )?; + Ok(()) + } + + pub fn promote_writeup(&self, writeup_id: &Uuid) -> Result { + let writeup = self.load_writeup(writeup_id)?; + let body = writeup.latest_body().unwrap_or("").trim().to_string(); + let ticket = self.create( + &writeup.title, + NewTicketOpts { + tags: writeup.tags.iter().cloned().collect(), + ..Default::default() + }, + )?; + if !body.is_empty() { + self.set_description(&ticket.id, Some(&body))?; + } + self.link_writeup_ticket(writeup_id, &ticket.id)?; + self.load(&ticket.id) + } + fn project_handle(&self) -> git_meta_lib::SessionTargetHandle<'_> { self.session.target(&Target::project()) } @@ -868,6 +1105,62 @@ fn build_ticket(id: Uuid, fields: Vec<(String, MetaValue)>) -> Option { }) } +fn build_writeup(id: Uuid, fields: Vec<(String, MetaValue)>) -> Option { + if fields.is_empty() { + return None; + } + + let mut title: Option = None; + let mut status = WriteupStatus::Open; + let mut created_at: Option = None; + let mut created_by = String::new(); + let mut authors = BTreeSet::new(); + let mut tags = BTreeSet::new(); + let mut tickets = BTreeSet::new(); + let mut versions = Vec::new(); + + for (field, value) in fields { + match (field.as_str(), value) { + ("title", MetaValue::String(s)) => title = Some(s), + ("status", MetaValue::String(s)) => { + status = WriteupStatus::parse(&s).unwrap_or(WriteupStatus::Open); + } + ("created-at", MetaValue::String(s)) => { + created_at = OffsetDateTime::parse(&s, &Rfc3339).ok(); + } + ("created-by", MetaValue::String(s)) => created_by = s, + ("authors", MetaValue::Set(members)) => authors = members, + ("tags", MetaValue::Set(members)) => tags = members, + ("tickets", MetaValue::Set(members)) => { + tickets = members + .iter() + .filter_map(|s| Uuid::parse_str(s).ok()) + .collect(); + } + ("versions", MetaValue::List(entries)) => versions = decode_writeup_versions(entries), + _ => {} + } + } + + let title = title?; + let created_at = created_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); + if created_by.is_empty() { + created_by = authors.iter().next().cloned().unwrap_or_default(); + } + + Some(Writeup { + id, + title, + status, + created_at, + created_by, + authors, + tags, + tickets, + versions, + }) +} + fn decode_comments(entries: Vec) -> Vec { let mut out = Vec::with_capacity(entries.len()); for entry in entries { @@ -885,6 +1178,55 @@ fn decode_comments(entries: Vec) -> Vec { out } +fn decode_writeup_versions(entries: Vec) -> Vec { + let mut out = Vec::with_capacity(entries.len()); + for entry in entries { + let fallback_at = + OffsetDateTime::from_unix_timestamp_nanos(i128::from(entry.timestamp) * 1_000_000) + .unwrap_or(OffsetDateTime::UNIX_EPOCH); + out.push(decode_writeup_version(&entry.value, fallback_at)); + } + out +} + +fn decode_writeup_version(raw: &str, fallback_at: OffsetDateTime) -> WriteupVersion { + let Some(rest) = raw.strip_prefix("---\n") else { + return WriteupVersion { + author: "unknown".to_string(), + at: fallback_at, + body: raw.to_string(), + }; + }; + let Some((frontmatter, body)) = rest.split_once("\n---\n") else { + return WriteupVersion { + author: "unknown".to_string(), + at: fallback_at, + body: raw.to_string(), + }; + }; + + let mut author = "unknown".to_string(); + let mut at = fallback_at; + for line in frontmatter.lines() { + if let Some(value) = line.strip_prefix("author:") { + let value = value.trim(); + if !value.is_empty() { + author = value.to_string(); + } + } else if let Some(value) = line.strip_prefix("date:") { + if let Ok(parsed) = OffsetDateTime::parse(value.trim(), &Rfc3339) { + at = parsed; + } + } + } + + WriteupVersion { + author, + at, + body: body.trim_start_matches(['\r', '\n']).to_string(), + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1070,6 +1412,88 @@ mod tests { assert_eq!(store.load_view("everything").unwrap(), just_a); } + #[test] + fn writeups_round_trip_versions_and_status() { + let (store, _td) = test_store(); + let writeup = store + .create_writeup( + "Design note", + NewWriteupOpts { + body: Some("first draft".to_string()), + tags: vec!["design".to_string()], + ..Default::default() + }, + ) + .unwrap(); + store + .append_writeup_version(&writeup.id, "second draft") + .unwrap(); + store + .set_writeup_status(&writeup.id, WriteupStatus::Closed) + .unwrap(); + store.add_writeup_tag(&writeup.id, "review").unwrap(); + store.remove_writeup_tag(&writeup.id, "design").unwrap(); + + let loaded = store.load_writeup(&writeup.id).unwrap(); + assert_eq!(loaded.title, "Design note"); + assert_eq!(loaded.status, WriteupStatus::Closed); + assert!(!loaded.tags.contains("design")); + assert!(loaded.tags.contains("review")); + assert!(loaded.authors.contains(store.email())); + assert_eq!(loaded.versions.len(), 2); + assert_eq!(loaded.versions[0].author, store.email()); + assert_eq!(loaded.versions[0].body, "first draft"); + assert_eq!(loaded.versions[1].body, "second draft"); + assert_eq!( + store.resolve_writeup_id(&writeup.short_id()).unwrap(), + writeup.id + ); + } + + #[test] + fn writeups_link_unlink_and_promote() { + let (store, _td) = test_store(); + let ticket = store.create("existing", NewTicketOpts::default()).unwrap(); + let writeup = store + .create_writeup( + "Promotable", + NewWriteupOpts { + body: Some("make this actionable".to_string()), + tags: vec!["feature".to_string()], + ..Default::default() + }, + ) + .unwrap(); + + store.link_writeup_ticket(&writeup.id, &ticket.id).unwrap(); + assert!(store + .load_writeup(&writeup.id) + .unwrap() + .tickets + .contains(&ticket.id)); + store + .unlink_writeup_ticket(&writeup.id, &ticket.id) + .unwrap(); + assert!(!store + .load_writeup(&writeup.id) + .unwrap() + .tickets + .contains(&ticket.id)); + + let promoted = store.promote_writeup(&writeup.id).unwrap(); + assert_eq!(promoted.title, "Promotable"); + assert_eq!( + promoted.description.as_deref(), + Some("make this actionable") + ); + assert!(promoted.tags.contains("feature")); + assert!(store + .load_writeup(&writeup.id) + .unwrap() + .tickets + .contains(&promoted.id)); + } + fn insert_ticket( store: &TicketStore, id: Uuid, diff --git a/crates/ticgit-lib/src/writeup.rs b/crates/ticgit-lib/src/writeup.rs new file mode 100644 index 00000000..25e07120 --- /dev/null +++ b/crates/ticgit-lib/src/writeup.rs @@ -0,0 +1,67 @@ +use std::collections::BTreeSet; + +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteupStatus { + Open, + Closed, +} + +impl WriteupStatus { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + WriteupStatus::Open => "open", + WriteupStatus::Closed => "closed", + } + } + + pub fn parse(raw: &str) -> Option { + match raw { + "open" => Some(WriteupStatus::Open), + "closed" => Some(WriteupStatus::Closed), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +pub struct WriteupVersion { + pub author: String, + pub at: OffsetDateTime, + pub body: String, +} + +#[derive(Debug, Clone)] +pub struct Writeup { + pub id: Uuid, + pub title: String, + pub status: WriteupStatus, + pub created_at: OffsetDateTime, + pub created_by: String, + pub authors: BTreeSet, + pub tags: BTreeSet, + pub tickets: BTreeSet, + pub versions: Vec, +} + +impl Writeup { + #[must_use] + pub fn short_id(&self) -> String { + self.id.to_string()[..6].to_string() + } + + #[must_use] + pub fn latest_body(&self) -> Option<&str> { + self.versions.last().map(|version| version.body.as_str()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct NewWriteupOpts { + pub body: Option, + pub tags: Vec, + pub created_at: Option, +} diff --git a/crates/ticgit/src/cli.rs b/crates/ticgit/src/cli.rs index d96f2fd5..076207b4 100644 --- a/crates/ticgit/src/cli.rs +++ b/crates/ticgit/src/cli.rs @@ -47,6 +47,7 @@ use crate::commands; \x1b[1;36mViews & Import:\x1b[0m views Manage saved views (save, delete, list) + writeup Capture rough notes and promote them to tickets stats Show a ticket stats dashboard import Import tickets from external systems (e.g. GitHub) @@ -188,6 +189,9 @@ pub enum Command { #[command(next_help_heading = "Views & Import")] Views(commands::view::Args), + /// Capture rough notes and promote them to tickets. + Writeup(commands::writeup::Args), + /// Show a ticket stats dashboard. Stats(commands::stats::Args), @@ -267,6 +271,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { Some(Command::Meta(args)) => commands::meta::run(args), Some(Command::Comment(args)) => commands::comment::run(args), Some(Command::Views(args)) => commands::view::run(args), + Some(Command::Writeup(args)) => commands::writeup::run(args), Some(Command::Users(args)) => commands::users::run(args), Some(Command::Sync(args)) => commands::sync::run_sync(args), Some(Command::Pull(args)) => commands::pull::run(args), diff --git a/crates/ticgit/src/commands/import.rs b/crates/ticgit/src/commands/import.rs index a3d8ad9e..9b2e1b68 100644 --- a/crates/ticgit/src/commands/import.rs +++ b/crates/ticgit/src/commands/import.rs @@ -301,10 +301,9 @@ fn run_linear(args: LinearArgs) -> Result<()> { continue; } - let created_at = issue - .created_at - .as_deref() - .and_then(|s| time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()); + let created_at = issue.created_at.as_deref().and_then(|s| { + time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok() + }); let opts = NewTicketOpts { comment: None, tags: linear_issue_tags(&issue), @@ -429,7 +428,7 @@ query($teamKey: String!, $first: Int!, $after: String) { priority createdAt state { name } - assignee { email } + assignee { name email } labels { nodes { name } } project { name } } @@ -556,8 +555,10 @@ fn linear_description(issue: &LinearIssue) -> String { } } if let Some(assignee) = &issue.assignee { - if let Some(name) = non_empty(&assignee.name) { + if let Some(name) = assignee.name.as_deref().and_then(non_empty) { desc.push_str(&format!("\nLinear assignee: {name}")); + } else if let Some(email) = non_empty(&assignee.email) { + desc.push_str(&format!("\nLinear assignee: {email}")); } } @@ -591,6 +592,7 @@ struct LinearState { #[derive(Debug, Deserialize)] struct LinearAssignee { + name: Option, email: String, } @@ -712,6 +714,7 @@ mod tests { name: "In Progress".to_string(), }), assignee: Some(LinearAssignee { + name: Some("Alice".to_string()), email: "alice@example.com".to_string(), }), labels: Some(LinearLabels { @@ -757,6 +760,7 @@ mod tests { fn linear_assignee_skips_non_email() { let mut issue = linear_issue(); issue.assignee = Some(LinearAssignee { + name: None, email: String::new(), }); assert_eq!(linear_assignee(&issue), None); diff --git a/crates/ticgit/src/commands/mod.rs b/crates/ticgit/src/commands/mod.rs index fc228cea..c08c4f14 100644 --- a/crates/ticgit/src/commands/mod.rs +++ b/crates/ticgit/src/commands/mod.rs @@ -33,6 +33,7 @@ pub mod tui; pub mod update; pub mod users; pub mod view; +pub mod writeup; use anyhow::{Context, Result}; use ticgit_lib::TicketStore; diff --git a/crates/ticgit/src/commands/tag.rs b/crates/ticgit/src/commands/tag.rs index 502aa691..6642c28d 100644 --- a/crates/ticgit/src/commands/tag.rs +++ b/crates/ticgit/src/commands/tag.rs @@ -7,9 +7,13 @@ use crate::render; #[derive(Debug, Parser)] pub struct Args { /// Ticket id (or prefix). Defaults to the currently checked-out ticket. - #[arg(short = 't', long = "ticket")] + #[arg(short = 't', long = "ticket", conflicts_with = "writeup")] pub ticket: Option, + /// Writeup id (or unique prefix) to tag instead of a ticket. + #[arg(short = 'w', long = "writeup")] + pub writeup: Option, + /// Tag(s) to add. Comma- or space-separated. #[arg(num_args = 0.., conflicts_with = "remove")] pub tags: Vec, @@ -18,23 +22,81 @@ pub struct Args { #[arg(short = 'd', long = "remove")] pub remove: Vec, - /// Output the updated ticket as JSON. + /// Output the updated item as JSON. #[arg(long = "json")] pub json: bool, - /// Output the updated ticket as Markdown. + /// Output the updated item as Markdown. #[arg(long = "markdown", conflicts_with = "json")] pub markdown: bool, } pub fn run(args: Args) -> Result<()> { let store = open_store()?; - let id = resolve_ticket(&store, args.ticket.as_deref())?; if args.tags.is_empty() && args.remove.is_empty() { anyhow::bail!("specify at least one tag to add (or use -d to remove)"); } + if let Some(writeup_ref) = args.writeup.as_deref() { + let id = store.resolve_writeup_id(writeup_ref)?; + for raw in &args.tags { + for t in split_tags(raw) { + store.add_writeup_tag(&id, &t)?; + } + } + for raw in &args.remove { + for t in split_tags(raw) { + store.remove_writeup_tag(&id, &t)?; + } + } + + let writeup = store.load_writeup(&id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "id": writeup.id, + "short_id": writeup.short_id(), + "title": writeup.title, + "status": writeup.status.as_str(), + "tags": writeup.tags, + }))? + ); + return Ok(()); + } + if args.markdown { + let joined: Vec<_> = writeup.tags.iter().cloned().collect(); + println!("# Writeup: {}", writeup.title); + println!(); + println!("- Id: `{}`", writeup.id); + println!("- Short id: `{}`", writeup.short_id()); + println!("- Status: `{}`", writeup.status.as_str()); + println!( + "- Tags: {}", + if joined.is_empty() { + "(none)".to_string() + } else { + joined.join(", ") + } + ); + return Ok(()); + } + + let joined: Vec<_> = writeup.tags.iter().cloned().collect(); + println!( + "Tags on writeup {}: {}", + writeup.short_id(), + if joined.is_empty() { + "(none)".to_string() + } else { + joined.join(", ") + } + ); + return Ok(()); + } + + let id = resolve_ticket(&store, args.ticket.as_deref())?; for raw in &args.tags { for t in split_tags(raw) { store.add_tag(&id, &t)?; diff --git a/crates/ticgit/src/commands/tui.rs b/crates/ticgit/src/commands/tui.rs index c0d80141..c906dfb4 100644 --- a/crates/ticgit/src/commands/tui.rs +++ b/crates/ticgit/src/commands/tui.rs @@ -21,8 +21,8 @@ use ratatui::widgets::{ }; use ratatui::{Frame, Terminal}; use ticgit_lib::{ - query, Comment, Filter, NewTicketOpts, SortKey, SortOrder, Ticket, TicketLifecycle, - TicketState, TicketStatus, TicketStore, + query, Comment, Filter, NewTicketOpts, NewWriteupOpts, SortKey, SortOrder, Ticket, + TicketLifecycle, TicketState, TicketStatus, TicketStore, Writeup, WriteupStatus, }; use time::OffsetDateTime; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -132,10 +132,15 @@ struct App { store: TicketStore, tickets: Vec, visible: Vec, + writeups: Vec, + visible_writeups: Vec, list_state: ListState, + writeup_state: ListState, board_column: usize, board_rows: [usize; BOARD_STATES.len()], view: ViewMode, + active_tab: TuiTab, + show_all_writeups: bool, active_view_name: Option, saved_view_state: ListState, pending_delete_view: Option, @@ -150,11 +155,14 @@ struct App { tag_filter_match_all: bool, tag_picker_state: ListState, manage_tag_state: ListState, + link_issue_state: ListState, + version_state: ListState, order_state: ListState, mode: Mode, input: String, new_ticket: NewTicketDraft, detail: Option, + writeup_detail: Option, detail_width_percent: u16, comments_mode: bool, comment_state: ListState, @@ -170,6 +178,18 @@ enum ViewMode { Board, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TuiTab { + Issues, + Writeups, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TagTarget { + Ticket(uuid::Uuid), + Writeup(uuid::Uuid), +} + struct SyncState { receiver: Receiver>, selected_id: Option, @@ -203,6 +223,9 @@ enum Mode { SavedViews, ConfirmDeleteView, SaveView, + LinkIssueSearch, + UnlinkIssueSelect, + Versions, Input(InputKind), State, Create, @@ -269,10 +292,15 @@ impl App { store, tickets: Vec::new(), visible: Vec::new(), + writeups: Vec::new(), + visible_writeups: Vec::new(), list_state: ListState::default(), + writeup_state: ListState::default(), board_column: 0, board_rows: [0; BOARD_STATES.len()], view: ViewMode::List, + active_tab: TuiTab::Issues, + show_all_writeups: false, active_view_name: None, saved_view_state: ListState::default(), pending_delete_view: None, @@ -287,11 +315,14 @@ impl App { tag_filter_match_all: true, tag_picker_state: ListState::default(), manage_tag_state: ListState::default(), + link_issue_state: ListState::default(), + version_state: ListState::default(), order_state: ListState::default(), mode: Mode::Normal, input: String::new(), new_ticket: NewTicketDraft::default(), detail: None, + writeup_detail: None, detail_width_percent, comments_mode: false, comment_state: ListState::default(), @@ -300,7 +331,7 @@ impl App { status: None, sync: None, }; - app.reload(None)?; + app.reload_all(None, None)?; Ok(app) } @@ -342,6 +373,44 @@ impl App { Ok(()) } + fn reload_all( + &mut self, + preferred_ticket_id: Option, + preferred_writeup_id: Option, + ) -> Result<()> { + self.reload(preferred_ticket_id)?; + self.reload_writeups(preferred_writeup_id)?; + Ok(()) + } + + fn reload_writeups(&mut self, preferred_id: Option) -> Result<()> { + self.writeups = self.store.list_writeups()?; + self.writeups.sort_by(|a, b| { + writeup_recent_at(b) + .cmp(&writeup_recent_at(a)) + .then_with(|| a.title.cmp(&b.title)) + .then_with(|| a.id.cmp(&b.id)) + }); + self.apply_writeup_filter(); + + if let Some(id) = preferred_id { + if let Some(visible_pos) = self + .visible_writeups + .iter() + .position(|idx| self.writeups[*idx].id == id) + { + self.writeup_state.select(Some(visible_pos)); + if self.writeup_detail.is_some() { + self.writeup_detail = self.visible_writeups.get(visible_pos).copied(); + } + } else if self.writeup_detail.is_some() { + self.writeup_detail = None; + } + } + + Ok(()) + } + fn run(&mut self, terminal: &mut Terminal>) -> Result<()> { loop { self.poll_sync()?; @@ -379,7 +448,7 @@ impl App { .constraints(constraints) .split(area); - if self.detail.is_some() { + if self.active_tab == TuiTab::Issues && self.detail.is_some() { let list_width = 100_u16.saturating_sub(self.detail_width_percent); let panes = Layout::default() .direction(Direction::Horizontal) @@ -395,10 +464,24 @@ impl App { self.draw_list(frame, panes[0]); self.draw_detail(frame, panes[1]); } + } else if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() { + let list_width = 100_u16.saturating_sub(self.detail_width_percent); + let panes = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(list_width), + Constraint::Percentage(self.detail_width_percent), + ]) + .split(outer[0]); + self.draw_writeup_list(frame, panes[0]); + self.draw_writeup_detail(frame, panes[1]); } else { - match self.view { - ViewMode::List => self.draw_list(frame, outer[0]), - ViewMode::Board => self.draw_board(frame, outer[0]), + match self.active_tab { + TuiTab::Issues => match self.view { + ViewMode::List => self.draw_list(frame, outer[0]), + ViewMode::Board => self.draw_board(frame, outer[0]), + }, + TuiTab::Writeups => self.draw_writeup_list(frame, outer[0]), } } if self.sync.is_some() { @@ -415,6 +498,9 @@ impl App { Mode::SavedViews => self.draw_saved_views_modal(frame), Mode::ConfirmDeleteView => self.draw_delete_view_confirm_modal(frame), Mode::SaveView => self.draw_save_view_modal(frame), + Mode::LinkIssueSearch => self.draw_link_issue_search_modal(frame), + Mode::UnlinkIssueSelect => self.draw_unlink_issue_select_modal(frame), + Mode::Versions => self.draw_versions_modal(frame), Mode::Input(kind) => self.draw_input_modal(frame, kind), Mode::State => self.draw_state_modal(frame), Mode::Create => self.draw_create_modal(frame), @@ -490,7 +576,7 @@ impl App { ], ), Mode::ManageTags => ( - "ticket tags", + "tags", None, vec![ MenuHint { @@ -603,6 +689,64 @@ impl App { }, ], ), + Mode::LinkIssueSearch => ( + "link issue", + (!self.input.is_empty()).then(|| self.input.clone()), + vec![ + MenuHint { + key: "type", + desc: "search", + }, + MenuHint { + key: "j/k", + desc: "move", + }, + MenuHint { + key: "Enter", + desc: "link", + }, + MenuHint { + key: "Esc", + desc: "cancel", + }, + MenuHint { + key: "Backspace", + desc: "delete", + }, + ], + ), + Mode::UnlinkIssueSelect => ( + "unlink issue", + None, + vec![ + MenuHint { + key: "j/k", + desc: "move", + }, + MenuHint { + key: "Enter", + desc: "unlink", + }, + MenuHint { + key: "Esc", + desc: "cancel", + }, + ], + ), + Mode::Versions => ( + "versions", + None, + vec![ + MenuHint { + key: "j/k", + desc: "move", + }, + MenuHint { + key: "Enter/Esc", + desc: "close", + }, + ], + ), Mode::Input(kind) => ( "editing", Some(kind.label().to_string()), @@ -690,8 +834,71 @@ impl App { desc: "quit", }, ] + } else if self.active_tab == TuiTab::Writeups { + vec![ + MenuHint { + key: "Tab", + desc: "issues", + }, + MenuHint { + key: "j/k", + desc: "writeups", + }, + MenuHint { + key: "Enter", + desc: "details", + }, + MenuHint { + key: "e", + desc: "edit", + }, + MenuHint { + key: "t", + desc: "tags", + }, + MenuHint { + key: "v", + desc: "versions", + }, + MenuHint { + key: "n", + desc: "new", + }, + MenuHint { + key: "p", + desc: "promote", + }, + MenuHint { + key: "a", + desc: "all/open", + }, + MenuHint { + key: "c/o", + desc: "close/open", + }, + MenuHint { + key: "l/u", + desc: "link", + }, + MenuHint { + key: "1-9", + desc: "jump", + }, + MenuHint { + key: "r", + desc: "refresh", + }, + MenuHint { + key: "q", + desc: "quit", + }, + ] } else if self.view == ViewMode::Board && self.detail.is_none() { vec![ + MenuHint { + key: "Tab", + desc: "writeups", + }, MenuHint { key: "j/k", desc: "tickets", @@ -739,6 +946,10 @@ impl App { ] } else if self.detail.is_some() { vec![ + MenuHint { + key: "Tab", + desc: "writeups", + }, MenuHint { key: "j/k", desc: "tickets", @@ -779,6 +990,10 @@ impl App { key: "o", desc: "order", }, + MenuHint { + key: "1-9", + desc: "writeup", + }, MenuHint { key: "Esc", desc: "close", @@ -794,6 +1009,10 @@ impl App { ] } else { vec![ + MenuHint { + key: "Tab", + desc: "writeups", + }, MenuHint { key: "j/k", desc: "tickets", @@ -875,6 +1094,7 @@ impl App { } else { format!("{scope} matching {filter} ({count})") }; + let title = tabs_title(self.active_tab, &title); let block = Block::default().borders(Borders::ALL).title(title); let row_width = usize::from(block.inner(area).width) @@ -891,6 +1111,7 @@ impl App { row_width, compact, self.store.email(), + !self.linked_writeups(ticket.id).is_empty(), )) }) .collect(); @@ -908,6 +1129,57 @@ impl App { frame.render_stateful_widget(list, area, &mut self.list_state); } + fn draw_writeup_list(&mut self, frame: &mut Frame<'_>, area: Rect) { + let count = self.visible_writeups.len(); + let scope = if self.show_all_writeups { + "All writeups" + } else { + "Open writeups" + }; + let title = if self.filter.is_empty() { + format!("{scope} by recency ({count})") + } else { + format!("{scope} matching \"{}\" ({count})", self.filter) + }; + let block = Block::default() + .borders(Borders::ALL) + .title(tabs_title(self.active_tab, &title)); + let row_width = usize::from(block.inner(area).width) + .saturating_sub(UnicodeWidthStr::width(HIGHLIGHT_SYMBOL)); + let compact = self.writeup_detail.is_some(); + + let items: Vec> = if self.visible_writeups.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + if self.show_all_writeups { + "No writeups yet." + } else { + "No open writeups. Press a to show all." + }, + Style::default().fg(Color::DarkGray), + )))] + } else { + self.visible_writeups + .iter() + .map(|&idx| { + let writeup = &self.writeups[idx]; + ListItem::new(writeup_list_line(writeup, row_width, compact)) + }) + .collect() + }; + + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + frame.render_stateful_widget(list, area, &mut self.writeup_state); + } + fn draw_board(&mut self, frame: &mut Frame<'_>, area: Rect) { let constraints = vec![Constraint::Percentage(20); BOARD_STATES.len()]; let columns = Layout::default() @@ -1009,6 +1281,30 @@ impl App { if let Some(spec) = &ticket.spec { detail_lines.push(spec_field_line(spec, detail_width)); } + let linked_writeups = self.linked_writeups(ticket.id); + if !linked_writeups.is_empty() { + detail_lines.push(Line::raw("")); + detail_lines.push(Line::from(Span::styled( + "Writeups", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); + for (idx, writeup) in linked_writeups.iter().take(9).enumerate() { + detail_lines.push(Line::from(vec![ + Span::styled( + format!("{}", idx + 1), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(writeup.short_id(), Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::raw(writeup.title.clone()), + ])); + } + } if let Some(description) = &ticket.description { detail_lines.push(Line::raw("")); for line in description.lines() { @@ -1036,6 +1332,82 @@ impl App { frame.render_widget(detail, area); } + fn draw_writeup_detail(&self, frame: &mut Frame<'_>, area: Rect) { + let Some(idx) = self.writeup_detail else { + return; + }; + let writeup = &self.writeups[idx]; + let mut lines = vec![Line::from(Span::styled( + writeup.id.to_string(), + Style::default().fg(Color::DarkGray), + ))]; + lines.extend(writeup_metadata_lines( + writeup, + usize::from(area.width).saturating_sub(2), + )); + if !writeup.tags.is_empty() { + lines.push(tags_field_line(&writeup.tags)); + } + if !writeup.tickets.is_empty() { + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "Issues", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); + for (idx, ticket_id) in writeup.tickets.iter().take(9).enumerate() { + let ticket = self.tickets.iter().find(|ticket| ticket.id == *ticket_id); + let title = ticket + .map(|ticket| ticket.title.clone()) + .unwrap_or_else(|| "missing ticket".to_string()); + lines.push(Line::from(vec![ + Span::styled( + format!("{}", idx + 1), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + ticket_id.to_string()[..6].to_string(), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), + Span::raw(title), + ])); + } + } + lines.push(Line::raw("")); + if let Some(version) = writeup.versions.last() { + lines.push(Line::from(Span::styled( + writeup.title.clone(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::raw("")); + lines.extend(version.body.lines().map(|line| Line::raw(line.to_string()))); + } else { + lines.push(Line::from(Span::styled( + writeup.title.clone(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "No versions yet. Press e to add one.", + Style::default().fg(Color::DarkGray), + ))); + } + + let detail = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title("Writeup")) + .wrap(Wrap { trim: false }); + frame.render_widget(detail, area); + } + fn draw_comments_list(&mut self, frame: &mut Frame<'_>, area: Rect) { let Some(idx) = self.detail else { return; @@ -1138,46 +1510,265 @@ impl App { frame.render_widget(modal, area); } - fn draw_tags_modal(&mut self, frame: &mut Frame<'_>) { - let area = centered_rect(64, 20, frame.area()); - let tags = self.available_tags(); + fn draw_link_issue_search_modal(&mut self, frame: &mut Frame<'_>) { + let area = centered_rect(74, 22, frame.area()); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + let results = self.link_issue_search_results(); let selected = self - .tag_picker_state + .link_issue_state .selected() .unwrap_or(0) - .min(tags.len().saturating_sub(1)); - if tags.is_empty() { - self.tag_picker_state.select(None); + .min(results.len().saturating_sub(1)); + if results.is_empty() { + self.link_issue_state.select(None); } else { - self.tag_picker_state.select(Some(selected)); + self.link_issue_state.select(Some(selected)); } - let items: Vec> = if tags.is_empty() { + let search = Paragraph::new(Line::from(self.input.as_str())).block( + Block::default() + .borders(Borders::ALL) + .title("Search issues"), + ); + let row_width = usize::from(chunks[1].width) + .saturating_sub(2) + .saturating_sub(UnicodeWidthStr::width(HIGHLIGHT_SYMBOL)); + let items: Vec> = if results.is_empty() { vec![ListItem::new(Line::from(Span::styled( - "No tags on open tickets.", + "No matching unlinked issues.", Style::default().fg(Color::DarkGray), )))] } else { - tags.iter() - .map(|(tag, count)| { - let checked = if self.tag_filter.contains(tag) { - "[x]" - } else { - "[ ]" - }; - ListItem::new(Line::from(vec![ - Span::styled(checked, Style::default().fg(Color::Yellow)), - Span::raw(" "), - Span::styled(tag.clone(), Style::default().fg(tag_color(tag))), - Span::styled(format!(" ({count})"), Style::default().fg(Color::DarkGray)), - ])) + results + .iter() + .map(|idx| { + ListItem::new(ticket_list_line( + &self.tickets[*idx], + row_width, + false, + self.store.email(), + false, + )) }) .collect() }; - let mode = if self.tag_filter_match_all { - "all selected tags" - } else { - "any selected tag" + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Link Issue")) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + let help = Paragraph::new(Line::from(vec![ + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" link "), + Span::styled("j/k", Style::default().fg(Color::Yellow)), + Span::raw(" move "), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(" cancel"), + ])) + .style(Style::default().bg(Color::DarkGray)); + + frame.render_widget(Clear, area); + frame.render_widget(search, chunks[0]); + frame.render_stateful_widget(list, chunks[1], &mut self.link_issue_state); + frame.render_widget(help, chunks[2]); + } + + fn draw_unlink_issue_select_modal(&mut self, frame: &mut Frame<'_>) { + let area = centered_rect(74, 20, frame.area()); + let linked = self.linked_issue_ids_for_selected_writeup(); + let selected = self + .link_issue_state + .selected() + .unwrap_or(0) + .min(linked.len().saturating_sub(1)); + if linked.is_empty() { + self.link_issue_state.select(None); + } else { + self.link_issue_state.select(Some(selected)); + } + + let row_width = usize::from(area.width) + .saturating_sub(2) + .saturating_sub(UnicodeWidthStr::width(HIGHLIGHT_SYMBOL)); + let items: Vec> = if linked.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No linked issues.", + Style::default().fg(Color::DarkGray), + )))] + } else { + linked + .iter() + .map(|ticket_id| self.linked_issue_line(*ticket_id, row_width)) + .map(ListItem::new) + .collect() + }; + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Unlink Issue")) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + + frame.render_widget(Clear, area); + frame.render_stateful_widget(list, area, &mut self.link_issue_state); + } + + fn draw_versions_modal(&mut self, frame: &mut Frame<'_>) { + let Some(writeup) = self.selected_writeup().cloned() else { + return; + }; + let title = writeup.title.clone(); + let versions = writeup.versions; + let area = centered_rect(86, 28, frame.area()); + let panes = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(34), Constraint::Percentage(66)]) + .split(area); + let selected = self + .version_state + .selected() + .unwrap_or_else(|| versions.len().saturating_sub(1)) + .min(versions.len().saturating_sub(1)); + if versions.is_empty() { + self.version_state.select(None); + } else { + self.version_state.select(Some(selected)); + } + + let items: Vec> = if versions.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No versions yet.", + Style::default().fg(Color::DarkGray), + )))] + } else { + versions + .iter() + .enumerate() + .map(|(idx, version)| { + ListItem::new(Line::from(vec![ + Span::styled( + format!("v{}", idx + 1), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + relative_date(version.at, OffsetDateTime::now_utc()), + Style::default().fg(Color::DarkGray), + ), + ])) + }) + .collect() + }; + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Versions")) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + + let mut preview_lines = Vec::new(); + if let Some(version) = self + .version_state + .selected() + .and_then(|idx| versions.get(idx)) + { + preview_lines.push(Line::from(Span::styled( + title, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + preview_lines.push(Line::raw("")); + preview_lines.push(Line::from(vec![ + Span::styled( + version + .at + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| relative_date(version.at, OffsetDateTime::now_utc())), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), + Span::styled(&version.author, Style::default().fg(Color::Cyan)), + ])); + preview_lines.push(Line::raw("")); + preview_lines.extend(version.body.lines().map(|line| Line::raw(line.to_string()))); + } else { + preview_lines.push(Line::from(Span::styled( + "No version selected.", + Style::default().fg(Color::DarkGray), + ))); + } + let preview = Paragraph::new(preview_lines) + .block(Block::default().borders(Borders::ALL).title("Preview")) + .wrap(Wrap { trim: false }); + + frame.render_widget(Clear, area); + frame.render_stateful_widget(list, panes[0], &mut self.version_state); + frame.render_widget(preview, panes[1]); + } + + fn draw_tags_modal(&mut self, frame: &mut Frame<'_>) { + let area = centered_rect(64, 20, frame.area()); + let tags = self.available_tags(); + let selected = self + .tag_picker_state + .selected() + .unwrap_or(0) + .min(tags.len().saturating_sub(1)); + if tags.is_empty() { + self.tag_picker_state.select(None); + } else { + self.tag_picker_state.select(Some(selected)); + } + + let items: Vec> = if tags.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No tags on open tickets.", + Style::default().fg(Color::DarkGray), + )))] + } else { + tags.iter() + .map(|(tag, count)| { + let checked = if self.tag_filter.contains(tag) { + "[x]" + } else { + "[ ]" + }; + ListItem::new(Line::from(vec![ + Span::styled(checked, Style::default().fg(Color::Yellow)), + Span::raw(" "), + Span::styled(tag.clone(), Style::default().fg(tag_color(tag))), + Span::styled(format!(" ({count})"), Style::default().fg(Color::DarkGray)), + ])) + }) + .collect() + }; + let mode = if self.tag_filter_match_all { + "all selected tags" + } else { + "any selected tag" }; let title = format!("Tag Filter: {mode}"); let list = List::new(items) @@ -1196,12 +1787,11 @@ impl App { fn draw_manage_tags_modal(&mut self, frame: &mut Frame<'_>) { let area = centered_rect(64, 20, frame.area()); - let Some(ticket) = self.selected_ticket() else { + let Some((_, target_label, target_tags)) = self.selected_tag_target() else { return; }; - let ticket_tags = ticket.tags.clone(); - let title = format!("Manage Tags: {}", ticket.short_id()); - let tags = self.manageable_tags(&ticket_tags); + let title = format!("Manage Tags: {target_label}"); + let tags = self.manageable_tags(&target_tags); let selected = self .manage_tag_state .selected() @@ -1215,13 +1805,13 @@ impl App { let items: Vec> = if tags.is_empty() { vec![ListItem::new(Line::from(Span::styled( - "No known tags. Press n to create one on this ticket.", + "No known tags. Press n to create one here.", Style::default().fg(Color::DarkGray), )))] } else { tags.iter() .map(|tag| { - let checked = if ticket_tags.contains(tag) { + let checked = if target_tags.contains(tag) { "[x]" } else { "[ ]" @@ -1583,7 +2173,7 @@ impl App { lines.push(help_columns(("Enter", "apply"), Some(("Esc", "finish")))); } Mode::ManageTags => { - help_section(&mut lines, "Ticket Tags"); + help_section(&mut lines, "Tags"); lines.push(help_columns( ("j/k", "move tag"), Some(("Space", "add / remove")), @@ -1629,6 +2219,33 @@ impl App { )); lines.push(help_columns(("Enter", "save"), Some(("Esc", "cancel")))); } + Mode::LinkIssueSearch => { + help_section(&mut lines, "Link Issue"); + lines.push(help_columns( + ("type", "search title/description"), + Some(("Backspace", "delete char")), + )); + lines.push(help_columns( + ("j/k", "move issue"), + Some(("Enter", "link selected")), + )); + lines.push(help_columns(("Esc", "cancel"), None)); + } + Mode::UnlinkIssueSelect => { + help_section(&mut lines, "Unlink Issue"); + lines.push(help_columns( + ("j/k", "move issue"), + Some(("Enter", "unlink selected")), + )); + lines.push(help_columns(("Esc", "cancel"), None)); + } + Mode::Versions => { + help_section(&mut lines, "Versions"); + lines.push(help_columns( + ("j/k", "move version"), + Some(("Enter/Esc", "close")), + )); + } Mode::Input(kind) => { help_section(&mut lines, &format!("Editing {}", kind.label())); lines.push(help_columns( @@ -1667,6 +2284,30 @@ impl App { )); lines.push(help_columns(("r", "refresh"), None)); } + Mode::Normal if self.active_tab == TuiTab::Writeups => { + help_section(&mut lines, "Writeups"); + lines.push(help_columns( + ("Tab", "issues tab"), + Some(("j/k", "move writeups")), + )); + lines.push(help_columns( + ("Enter", "details"), + Some(("e", "edit latest")), + )); + lines.push(help_columns( + ("n", "new writeup"), + Some(("a", "show all/open")), + )); + lines.push(help_columns(("c", "close"), Some(("o", "reopen")))); + lines.push(help_columns(("p", "promote"), Some(("l", "link issue")))); + lines.push(help_columns(("v", "versions"), Some(("t", "manage tags")))); + lines.push(help_columns(("u", "unlink issue"), None)); + lines.push(help_columns(("1-9", "jump issue"), None)); + lines.push(help_columns( + ("+/-", "resize detail"), + Some(("r", "refresh")), + )); + } Mode::Normal if self.view == ViewMode::Board && self.detail.is_none() => { help_section(&mut lines, "Navigation"); lines.push(help_columns( @@ -1692,8 +2333,12 @@ impl App { Mode::Normal => { help_section(&mut lines, "Navigation"); lines.push(help_columns( - ("j/k", "move tickets"), - Some(("Up/Down", "move tickets")), + ("Tab", "writeups tab"), + Some(("j/k", "move tickets")), + )); + lines.push(help_columns( + ("Up/Down", "move tickets"), + Some(("1-9", "jump writeup")), )); lines.push(help_columns( ("Enter", "details"), @@ -1790,6 +2435,18 @@ impl App { self.handle_save_view_key(key)?; false } + Mode::LinkIssueSearch => { + self.handle_link_issue_search_key(key)?; + false + } + Mode::UnlinkIssueSelect => { + self.handle_unlink_issue_select_key(key)?; + false + } + Mode::Versions => { + self.handle_versions_key(key); + false + } Mode::Input(_) => { self.handle_input_key(key)?; false @@ -1815,10 +2472,17 @@ impl App { self.status = None; let quit = match key.code { KeyCode::Char('q') => true, + KeyCode::Tab | KeyCode::BackTab => { + self.toggle_tab(); + false + } KeyCode::Esc => { if self.comments_mode { self.comments_mode = false; false + } else if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() { + self.writeup_detail = None; + false } else if self.detail.is_some() { self.detail = None; self.comments_mode = false; @@ -1839,7 +2503,9 @@ impl App { false } KeyCode::Char('g') => { - self.begin_tag_filter(); + if self.active_tab == TuiTab::Issues { + self.begin_tag_filter(); + } false } KeyCode::Char('t') => { @@ -1847,15 +2513,23 @@ impl App { false } KeyCode::Char('v') => { - self.begin_saved_views(); + if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() { + self.begin_versions(); + } else if self.active_tab == TuiTab::Issues { + self.begin_saved_views(); + } false } KeyCode::Char('V') => { - self.begin_save_view(); + if self.active_tab == TuiTab::Issues { + self.begin_save_view(); + } false } KeyCode::Char('b') => { - self.handle_board_key(); + if self.active_tab == TuiTab::Issues { + self.handle_board_key(); + } false } KeyCode::Char('r') => { @@ -1863,13 +2537,20 @@ impl App { false } KeyCode::Char('n') => { - self.begin_create(); + if self.active_tab == TuiTab::Issues { + self.begin_create(); + } else { + self.create_writeup_in_editor(terminal)?; + } false } KeyCode::Down | KeyCode::Char('j') => { if self.comments_mode { self.next_comment(); - } else if self.view == ViewMode::Board && self.detail.is_none() { + } else if self.active_tab == TuiTab::Issues + && self.view == ViewMode::Board + && self.detail.is_none() + { self.next_board_ticket(); } else { self.next(); @@ -1879,7 +2560,10 @@ impl App { KeyCode::Up | KeyCode::Char('k') => { if self.comments_mode { self.previous_comment(); - } else if self.view == ViewMode::Board && self.detail.is_none() { + } else if self.active_tab == TuiTab::Issues + && self.view == ViewMode::Board + && self.detail.is_none() + { self.previous_board_ticket(); } else { self.previous(); @@ -1887,13 +2571,21 @@ impl App { false } KeyCode::Right | KeyCode::Char('l') => { - if self.view == ViewMode::Board && self.detail.is_none() { + if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() { + self.begin_link_issue_search(); + } else if self.active_tab == TuiTab::Issues + && self.view == ViewMode::Board + && self.detail.is_none() + { self.next_board_column(); } false } KeyCode::Left | KeyCode::Char('h') => { - if self.view == ViewMode::Board && self.detail.is_none() { + if self.active_tab == TuiTab::Issues + && self.view == ViewMode::Board + && self.detail.is_none() + { self.previous_board_column(); } false @@ -1903,35 +2595,65 @@ impl App { false } KeyCode::Char('e') => { - self.edit_ticket_in_editor(terminal)?; + if self.active_tab == TuiTab::Writeups { + self.edit_writeup_in_editor(terminal)?; + } else { + self.edit_ticket_in_editor(terminal)?; + } false } KeyCode::Char('i') => { - self.edit_spec_in_editor(terminal)?; + if self.active_tab == TuiTab::Issues { + self.edit_spec_in_editor(terminal)?; + } false } KeyCode::Char('c') => { - self.begin_input(InputKind::Comment); + if self.active_tab == TuiTab::Issues { + self.begin_input(InputKind::Comment); + } else { + self.set_selected_writeup_status(WriteupStatus::Closed)?; + } false } KeyCode::Char('C') => { - self.claim_selected()?; + if self.active_tab == TuiTab::Issues { + self.claim_selected()?; + } false } KeyCode::Char('m') => { - self.enter_comments_mode(); + if self.active_tab == TuiTab::Issues { + self.enter_comments_mode(); + } false } KeyCode::Char('p') => { - self.begin_input(InputKind::Priority); + if self.active_tab == TuiTab::Writeups { + self.promote_selected_writeup()?; + } else { + self.begin_input(InputKind::Priority); + } false } KeyCode::Char('o') => { - self.begin_order(); + if self.active_tab == TuiTab::Issues { + self.begin_order(); + } else { + self.set_selected_writeup_status(WriteupStatus::Open)?; + } + false + } + KeyCode::Char('a') => { + if self.active_tab == TuiTab::Writeups { + self.toggle_writeup_scope(); + } false } KeyCode::Char('O') => { - self.begin_input(InputKind::Points); + if self.active_tab == TuiTab::Issues { + self.begin_input(InputKind::Points); + } false } KeyCode::Char('+') | KeyCode::Char('=') => { @@ -1942,8 +2664,14 @@ impl App { self.resize_detail(-(DETAIL_WIDTH_PERCENT_STEP as i16)); false } + KeyCode::Char('u') => { + if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() { + self.begin_unlink_issue_select(); + } + false + } KeyCode::Char('s') => { - if self.selected_ticket().is_some() { + if self.active_tab == TuiTab::Issues && self.selected_ticket().is_some() { self.mode = Mode::State; } else { self.status = Some("Select a ticket first.".to_string()); @@ -1954,6 +2682,10 @@ impl App { self.start_sync(); false } + KeyCode::Char(c) if ('1'..='9').contains(&c) => { + self.jump_linked_item(usize::from(c as u8 - b'1')); + false + } _ => false, }; Ok(quit) @@ -2003,7 +2735,7 @@ impl App { } KeyCode::Down | KeyCode::Char('j') => self.next_manage_tag(), KeyCode::Up | KeyCode::Char('k') => self.previous_manage_tag(), - KeyCode::Char(' ') => self.toggle_selected_ticket_tag()?, + KeyCode::Char(' ') => self.toggle_selected_target_tag()?, KeyCode::Char('n') => self.begin_input(InputKind::AddTags), KeyCode::Char('r') => self.begin_input(InputKind::RemoveTags), _ => {} @@ -2115,6 +2847,63 @@ impl App { Ok(()) } + fn handle_link_issue_search_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { + self.mode = Mode::Normal; + self.input.clear(); + self.status = Some("Cancelled.".to_string()); + } + KeyCode::Enter => { + if self.link_selected_issue()? { + self.mode = Mode::Normal; + self.input.clear(); + } + } + KeyCode::Down | KeyCode::Char('j') => self.next_link_issue_result(), + KeyCode::Up | KeyCode::Char('k') => self.previous_link_issue_result(), + KeyCode::Backspace => { + self.input.pop(); + self.reset_link_issue_selection(); + } + KeyCode::Char(c) => { + self.input.push(c); + self.reset_link_issue_selection(); + } + _ => {} + } + Ok(()) + } + + fn handle_unlink_issue_select_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { + self.mode = Mode::Normal; + self.status = Some("Cancelled.".to_string()); + } + KeyCode::Enter => { + if self.unlink_selected_issue()? { + self.mode = Mode::Normal; + } + } + KeyCode::Down | KeyCode::Char('j') => self.next_unlink_issue(), + KeyCode::Up | KeyCode::Char('k') => self.previous_unlink_issue(), + _ => {} + } + Ok(()) + } + + fn handle_versions_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Enter | KeyCode::Esc => { + self.mode = Mode::Normal; + } + KeyCode::Down | KeyCode::Char('j') => self.next_version(), + KeyCode::Up | KeyCode::Char('k') => self.previous_version(), + _ => {} + } + } + fn handle_state_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { @@ -2200,11 +2989,11 @@ impl App { } fn begin_manage_tags(&mut self) { - let Some(ticket) = self.selected_ticket() else { - self.status = Some("Select a ticket first.".to_string()); + let Some((_, _, target_tags)) = self.selected_tag_target() else { + self.status = Some("Select an issue or writeup first.".to_string()); return; }; - let tags = self.manageable_tags(&ticket.tags); + let tags = self.manageable_tags(&target_tags); if tags.is_empty() { self.manage_tag_state.select(None); } else { @@ -2402,7 +3191,9 @@ impl App { None => None, }; self.detail = None; + self.writeup_detail = None; self.comments_mode = false; + self.active_tab = TuiTab::Issues; self.view = ViewMode::List; self.reload(None)?; self.status = Some(format!("Loaded view `{name}`.")); @@ -2421,6 +3212,7 @@ impl App { self.tag_filter.clear(); self.tag_filter_match_all = true; self.detail = None; + self.writeup_detail = None; self.comments_mode = false; self.reload(None)?; self.status = Some("Cleared to default view.".to_string()); @@ -2507,6 +3299,187 @@ impl App { Ok(()) } + fn edit_writeup_in_editor( + &mut self, + terminal: &mut Terminal>, + ) -> Result<()> { + let Some(writeup) = self.selected_writeup() else { + self.status = Some("Select a writeup first.".to_string()); + return Ok(()); + }; + let id = writeup.id; + let initial = writeup_edit_body(writeup); + + suspend_terminal(terminal)?; + let edited = editor::capture_with_initial( + "Edit the title on the first line. Remaining non-comment lines become the writeup body.", + &initial, + ); + resume_terminal(terminal)?; + + match edited? { + Some(edited) => { + let (title, body) = editor::parse_ticket_edit(&edited)?; + self.store.set_writeup_title(&id, &title)?; + if let Some(body) = body { + self.store.append_writeup_version(&id, &body)?; + } + self.status = Some("Appended writeup version.".to_string()); + } + _ => { + self.status = Some("Cancelled.".to_string()); + } + } + + self.reload_writeups(Some(id))?; + Ok(()) + } + + fn create_writeup_in_editor( + &mut self, + terminal: &mut Terminal>, + ) -> Result<()> { + suspend_terminal(terminal)?; + let edited = editor::capture( + "Write the title on the first line. Remaining non-comment lines become the writeup body.", + ); + resume_terminal(terminal)?; + + let Some(edited) = edited? else { + self.status = Some("Cancelled.".to_string()); + return Ok(()); + }; + let (title, body) = editor::parse_ticket_edit(&edited)?; + let writeup = self.store.create_writeup( + &title, + NewWriteupOpts { + body, + ..Default::default() + }, + )?; + self.active_tab = TuiTab::Writeups; + self.show_all_writeups = false; + self.reload_writeups(Some(writeup.id))?; + self.jump_to_writeup(writeup.id); + self.status = Some(format!("Created writeup {}.", writeup.short_id())); + Ok(()) + } + + fn promote_selected_writeup(&mut self) -> Result<()> { + let Some(writeup) = self.selected_writeup() else { + self.status = Some("Select a writeup first.".to_string()); + return Ok(()); + }; + let writeup_id = writeup.id; + let ticket = self.store.promote_writeup(&writeup_id)?; + self.reload_all(Some(ticket.id), Some(writeup_id))?; + self.status = Some(format!("Promoted to issue {}.", ticket.short_id())); + Ok(()) + } + + fn set_selected_writeup_status(&mut self, status: WriteupStatus) -> Result<()> { + let Some(writeup) = self.selected_writeup() else { + self.status = Some("Select a writeup first.".to_string()); + return Ok(()); + }; + let id = writeup.id; + self.store.set_writeup_status(&id, status)?; + self.reload_writeups(Some(id))?; + self.status = Some(format!( + "Writeup {} -> {}.", + &id.to_string()[..6], + status.as_str() + )); + Ok(()) + } + + fn toggle_writeup_scope(&mut self) { + let selected_id = self.selected_writeup().map(|writeup| writeup.id); + self.show_all_writeups = !self.show_all_writeups; + self.apply_writeup_filter(); + if let Some(id) = selected_id { + if let Some(visible_pos) = self + .visible_writeups + .iter() + .position(|idx| self.writeups[*idx].id == id) + { + self.writeup_state.select(Some(visible_pos)); + if self.writeup_detail.is_some() { + self.writeup_detail = self.visible_writeups.get(visible_pos).copied(); + } + } + } + self.status = Some(if self.show_all_writeups { + "Showing all writeups.".to_string() + } else { + "Showing open writeups.".to_string() + }); + } + + fn linked_writeups(&self, ticket_id: uuid::Uuid) -> Vec<&Writeup> { + self.writeups + .iter() + .filter(|writeup| writeup.tickets.contains(&ticket_id)) + .collect() + } + + fn jump_linked_item(&mut self, index: usize) { + match self.active_tab { + TuiTab::Issues => { + let Some(ticket) = self.selected_ticket() else { + return; + }; + let ticket_id = ticket.id; + let Some(writeup_id) = self + .linked_writeups(ticket_id) + .get(index) + .map(|writeup| writeup.id) + else { + self.status = Some("No linked writeup at that number.".to_string()); + return; + }; + self.jump_to_writeup(writeup_id); + } + TuiTab::Writeups => { + let Some(writeup) = self.selected_writeup() else { + return; + }; + let Some(ticket_id) = writeup.tickets.iter().nth(index).copied() else { + self.status = Some("No linked issue at that number.".to_string()); + return; + }; + self.jump_to_ticket(ticket_id); + } + } + } + + fn jump_to_writeup(&mut self, id: uuid::Uuid) { + let Some(idx) = self.writeups.iter().position(|writeup| writeup.id == id) else { + self.status = Some("Linked writeup is not loaded.".to_string()); + return; + }; + self.active_tab = TuiTab::Writeups; + self.view = ViewMode::List; + self.comments_mode = false; + self.writeup_detail = Some(idx); + if let Some(visible_pos) = self + .visible_writeups + .iter() + .position(|visible| *visible == idx) + { + self.writeup_state.select(Some(visible_pos)); + } + } + + fn jump_to_ticket(&mut self, id: uuid::Uuid) { + self.active_tab = TuiTab::Issues; + self.view = ViewMode::List; + self.open_ticket_by_id(id); + if self.detail.is_none() { + self.status = Some("Linked issue is hidden by current filters.".to_string()); + } + } + fn start_sync(&mut self) { if self.sync.is_some() { self.status = Some("Sync already running.".to_string()); @@ -2550,7 +3523,10 @@ impl App { self.sync = None; match result { Ok(result) => { - self.reload(selected_id)?; + self.reload_all( + selected_id, + self.selected_writeup().map(|writeup| writeup.id), + )?; self.status = Some(result.summary); } Err(err) => { @@ -2561,15 +3537,27 @@ impl App { } fn begin_input(&mut self, kind: InputKind) { - let Some(ticket) = self.selected_ticket() else { - self.status = Some("Select a ticket first.".to_string()); - return; + let ticket = match kind { + InputKind::AddTags | InputKind::RemoveTags => { + if self.selected_tag_target().is_none() { + self.status = Some("Select an issue or writeup first.".to_string()); + return; + } + None + } + _ => { + let Some(ticket) = self.selected_ticket() else { + self.status = Some("Select a ticket first.".to_string()); + return; + }; + Some(ticket) + } }; self.input = match kind { InputKind::Priority => String::new(), InputKind::Points => ticket - .points + .and_then(|ticket| ticket.points) .map(|value| value.to_string()) .unwrap_or_default(), InputKind::Comment | InputKind::AddTags | InputKind::RemoveTags => String::new(), @@ -2577,6 +3565,52 @@ impl App { self.mode = Mode::Input(kind); } + fn begin_link_issue_search(&mut self) { + let Some(writeup) = self.selected_writeup() else { + self.status = Some("Select a writeup first.".to_string()); + return; + }; + let linked_tickets = writeup.tickets.clone(); + self.input.clear(); + let has_unlinked_issue = self + .tickets + .iter() + .any(|ticket| !linked_tickets.contains(&ticket.id)); + if !has_unlinked_issue { + self.status = Some("No unlinked issues available.".to_string()); + return; + } + self.link_issue_state.select(Some(0)); + self.mode = Mode::LinkIssueSearch; + } + + fn begin_unlink_issue_select(&mut self) { + let Some(writeup) = self.selected_writeup() else { + self.status = Some("Select a writeup first.".to_string()); + return; + }; + if writeup.tickets.is_empty() { + self.status = Some("No linked issues.".to_string()); + return; + } + self.link_issue_state.select(Some(0)); + self.mode = Mode::UnlinkIssueSelect; + } + + fn begin_versions(&mut self) { + let Some(writeup) = self.selected_writeup() else { + self.status = Some("Select a writeup first.".to_string()); + return; + }; + if writeup.versions.is_empty() { + self.version_state.select(None); + } else { + self.version_state + .select(Some(writeup.versions.len().saturating_sub(1))); + } + self.mode = Mode::Versions; + } + fn priority_range_display(&self) -> String { let mut priorities = self .visible @@ -2596,14 +3630,18 @@ impl App { } fn submit_input(&mut self) -> Result { + let Mode::Input(kind) = self.mode else { + return Ok(false); + }; + if matches!(kind, InputKind::AddTags | InputKind::RemoveTags) { + return self.submit_tag_input(kind); + } + let Some(ticket) = self.selected_ticket() else { self.status = Some("Select a ticket first.".to_string()); return Ok(false); }; let id = ticket.id; - let Mode::Input(kind) = self.mode else { - return Ok(false); - }; let preferred_after_reload = if kind == InputKind::Priority { self.adjacent_ticket_for_priority_triage(id) } else { @@ -2648,41 +3686,234 @@ impl App { None => "Cleared points.".to_string(), }); } - InputKind::AddTags => { - let tags = split_tags(&self.input); - if tags.is_empty() { - self.status = Some("Enter at least one tag.".to_string()); - return Ok(false); + InputKind::AddTags | InputKind::RemoveTags => unreachable!("handled above"), + } + + self.reload(preferred_after_reload)?; + if kind == InputKind::Comment { + if let Some(ticket) = self.selected_ticket() { + if !ticket.comments.is_empty() { + self.comment_state.select(Some(ticket.comments.len() - 1)); } + } + } + Ok(true) + } + + fn submit_tag_input(&mut self, kind: InputKind) -> Result { + let Some((target, _, _)) = self.selected_tag_target() else { + self.status = Some("Select an issue or writeup first.".to_string()); + return Ok(false); + }; + let tags = split_tags(&self.input); + if tags.is_empty() { + self.status = Some("Enter at least one tag.".to_string()); + return Ok(false); + } + + match target { + TagTarget::Ticket(id) => { for tag in tags { - self.store.add_tag(&id, &tag)?; + match kind { + InputKind::AddTags => self.store.add_tag(&id, &tag)?, + InputKind::RemoveTags => self.store.remove_tag(&id, &tag)?, + _ => unreachable!("only tag inputs are submitted here"), + } } - self.status = Some("Added tag(s).".to_string()); + self.reload(Some(id))?; } - InputKind::RemoveTags => { - let tags = split_tags(&self.input); - if tags.is_empty() { - self.status = Some("Enter at least one tag.".to_string()); - return Ok(false); - } + TagTarget::Writeup(id) => { for tag in tags { - self.store.remove_tag(&id, &tag)?; + match kind { + InputKind::AddTags => self.store.add_writeup_tag(&id, &tag)?, + InputKind::RemoveTags => self.store.remove_writeup_tag(&id, &tag)?, + _ => unreachable!("only tag inputs are submitted here"), + } } - self.status = Some("Removed tag(s).".to_string()); + self.reload_writeups(Some(id))?; } } + self.status = Some(match kind { + InputKind::AddTags => "Added tag(s).".to_string(), + InputKind::RemoveTags => "Removed tag(s).".to_string(), + _ => unreachable!("only tag inputs are submitted here"), + }); + Ok(true) + } + + fn link_selected_issue(&mut self) -> Result { + let Some(writeup) = self.selected_writeup() else { + self.status = Some("Select a writeup first.".to_string()); + return Ok(false); + }; + let writeup_id = writeup.id; + let results = self.link_issue_search_results(); + let Some(ticket_id) = self + .link_issue_state + .selected() + .and_then(|selected| results.get(selected)) + .map(|idx| self.tickets[*idx].id) + else { + self.status = Some("No issue selected.".to_string()); + return Ok(false); + }; + self.store.link_writeup_ticket(&writeup_id, &ticket_id)?; + self.reload_all(Some(ticket_id), Some(writeup_id))?; + self.status = Some(format!("Linked issue {}.", &ticket_id.to_string()[..6])); + Ok(true) + } + + fn link_issue_search_results(&self) -> Vec { + let Some(writeup) = self.selected_writeup() else { + return Vec::new(); + }; + let needle = self.input.trim().to_ascii_lowercase(); + self.tickets + .iter() + .enumerate() + .filter_map(|(idx, ticket)| { + if writeup.tickets.contains(&ticket.id) { + return None; + } + if needle.is_empty() || ticket_matches(ticket, &needle) { + Some(idx) + } else { + None + } + }) + .collect() + } + + fn next_link_issue_result(&mut self) { + let results = self.link_issue_search_results(); + if results.is_empty() { + self.link_issue_state.select(None); + return; + } + let selected = self.link_issue_state.selected().unwrap_or(0); + self.link_issue_state + .select(Some((selected + 1) % results.len())); + } + + fn previous_link_issue_result(&mut self) { + let results = self.link_issue_search_results(); + if results.is_empty() { + self.link_issue_state.select(None); + return; + } + let selected = self.link_issue_state.selected().unwrap_or(0); + let previous = selected + .checked_sub(1) + .unwrap_or_else(|| results.len().saturating_sub(1)); + self.link_issue_state.select(Some(previous)); + } - self.reload(preferred_after_reload)?; - if kind == InputKind::Comment { - if let Some(ticket) = self.selected_ticket() { - if !ticket.comments.is_empty() { - self.comment_state.select(Some(ticket.comments.len() - 1)); - } - } + fn reset_link_issue_selection(&mut self) { + if self.link_issue_search_results().is_empty() { + self.link_issue_state.select(None); + } else { + self.link_issue_state.select(Some(0)); + } + } + + fn linked_issue_ids_for_selected_writeup(&self) -> Vec { + self.selected_writeup() + .map(|writeup| writeup.tickets.iter().copied().collect()) + .unwrap_or_default() + } + + fn linked_issue_line(&self, ticket_id: uuid::Uuid, width: usize) -> Line<'static> { + if let Some(ticket) = self.tickets.iter().find(|ticket| ticket.id == ticket_id) { + return ticket_list_line(ticket, width, false, self.store.email(), false); } + let short_id = ticket_id.to_string()[..6].to_string(); + ticket_list_line_from_parts( + Some(&short_id), + "missing issue", + &[], + None, + false, + width, + None, + ) + } + + fn unlink_selected_issue(&mut self) -> Result { + let Some(writeup) = self.selected_writeup() else { + self.status = Some("Select a writeup first.".to_string()); + return Ok(false); + }; + let writeup_id = writeup.id; + let linked = self.linked_issue_ids_for_selected_writeup(); + let Some(ticket_id) = self + .link_issue_state + .selected() + .and_then(|selected| linked.get(selected)) + .copied() + else { + self.status = Some("No issue selected.".to_string()); + return Ok(false); + }; + self.store.unlink_writeup_ticket(&writeup_id, &ticket_id)?; + self.reload_all(None, Some(writeup_id))?; + self.status = Some(format!("Unlinked issue {}.", &ticket_id.to_string()[..6])); Ok(true) } + fn next_unlink_issue(&mut self) { + let linked = self.linked_issue_ids_for_selected_writeup(); + if linked.is_empty() { + self.link_issue_state.select(None); + return; + } + let selected = self.link_issue_state.selected().unwrap_or(0); + self.link_issue_state + .select(Some((selected + 1) % linked.len())); + } + + fn previous_unlink_issue(&mut self) { + let linked = self.linked_issue_ids_for_selected_writeup(); + if linked.is_empty() { + self.link_issue_state.select(None); + return; + } + let selected = self.link_issue_state.selected().unwrap_or(0); + let previous = selected + .checked_sub(1) + .unwrap_or_else(|| linked.len().saturating_sub(1)); + self.link_issue_state.select(Some(previous)); + } + + fn next_version(&mut self) { + let Some(writeup) = self.selected_writeup() else { + self.version_state.select(None); + return; + }; + if writeup.versions.is_empty() { + self.version_state.select(None); + return; + } + let selected = self.version_state.selected().unwrap_or(0); + self.version_state + .select(Some((selected + 1) % writeup.versions.len())); + } + + fn previous_version(&mut self) { + let Some(writeup) = self.selected_writeup() else { + self.version_state.select(None); + return; + }; + if writeup.versions.is_empty() { + self.version_state.select(None); + return; + } + let selected = self.version_state.selected().unwrap_or(0); + let previous = selected + .checked_sub(1) + .unwrap_or_else(|| writeup.versions.len().saturating_sub(1)); + self.version_state.select(Some(previous)); + } + fn adjacent_ticket_for_priority_triage(&self, id: uuid::Uuid) -> Option { let selected = self .visible @@ -2690,7 +3921,11 @@ impl App { .position(|idx| self.tickets[*idx].id == id)?; self.visible .get(selected + 1) - .or_else(|| selected.checked_sub(1).and_then(|previous| self.visible.get(previous))) + .or_else(|| { + selected + .checked_sub(1) + .and_then(|previous| self.visible.get(previous)) + }) .map(|idx| self.tickets[*idx].id) } @@ -2886,20 +4121,23 @@ impl App { Ok(()) } - fn manageable_tags(&self, ticket_tags: &BTreeSet) -> Vec { - let mut tags = ticket_tags.clone(); + fn manageable_tags(&self, current_tags: &BTreeSet) -> Vec { + let mut tags = current_tags.clone(); for ticket in &self.tickets { tags.extend(ticket.tags.iter().cloned()); } + for writeup in &self.writeups { + tags.extend(writeup.tags.iter().cloned()); + } tags.into_iter().collect() } fn next_manage_tag(&mut self) { - let Some(ticket) = self.selected_ticket() else { + let Some((_, _, current_tags)) = self.selected_tag_target() else { self.manage_tag_state.select(None); return; }; - let tags = self.manageable_tags(&ticket.tags); + let tags = self.manageable_tags(¤t_tags); if tags.is_empty() { self.manage_tag_state.select(None); return; @@ -2910,11 +4148,11 @@ impl App { } fn previous_manage_tag(&mut self) { - let Some(ticket) = self.selected_ticket() else { + let Some((_, _, current_tags)) = self.selected_tag_target() else { self.manage_tag_state.select(None); return; }; - let tags = self.manageable_tags(&ticket.tags); + let tags = self.manageable_tags(¤t_tags); if tags.is_empty() { self.manage_tag_state.select(None); return; @@ -2926,14 +4164,12 @@ impl App { self.manage_tag_state.select(Some(previous)); } - fn toggle_selected_ticket_tag(&mut self) -> Result<()> { - let Some(ticket) = self.selected_ticket() else { - self.status = Some("Select a ticket first.".to_string()); + fn toggle_selected_target_tag(&mut self) -> Result<()> { + let Some((target, _, current_tags)) = self.selected_tag_target() else { + self.status = Some("Select an issue or writeup first.".to_string()); return Ok(()); }; - let id = ticket.id; - let ticket_tags = ticket.tags.clone(); - let tags = self.manageable_tags(&ticket_tags); + let tags = self.manageable_tags(¤t_tags); let Some(tag) = self .manage_tag_state .selected() @@ -2944,16 +4180,37 @@ impl App { return Ok(()); }; - if ticket_tags.contains(&tag) { - self.store.remove_tag(&id, &tag)?; - self.status = Some(format!("Removed tag `{tag}`.")); - } else { - self.store.add_tag(&id, &tag)?; - self.status = Some(format!("Added tag `{tag}`.")); - } - self.reload(Some(id))?; - if !self.visible.iter().any(|idx| self.tickets[*idx].id == id) { - self.mode = Mode::Normal; + match target { + TagTarget::Ticket(id) => { + if current_tags.contains(&tag) { + self.store.remove_tag(&id, &tag)?; + self.status = Some(format!("Removed tag `{tag}`.")); + } else { + self.store.add_tag(&id, &tag)?; + self.status = Some(format!("Added tag `{tag}`.")); + } + self.reload(Some(id))?; + if !self.visible.iter().any(|idx| self.tickets[*idx].id == id) { + self.mode = Mode::Normal; + } + } + TagTarget::Writeup(id) => { + if current_tags.contains(&tag) { + self.store.remove_writeup_tag(&id, &tag)?; + self.status = Some(format!("Removed tag `{tag}`.")); + } else { + self.store.add_writeup_tag(&id, &tag)?; + self.status = Some(format!("Added tag `{tag}`.")); + } + self.reload_writeups(Some(id))?; + if !self + .visible_writeups + .iter() + .any(|idx| self.writeups[*idx].id == id) + { + self.mode = Mode::Normal; + } + } } Ok(()) } @@ -2991,9 +4248,46 @@ impl App { .min(self.visible.len() - 1); self.list_state.select(Some(selected)); } + self.apply_writeup_filter(); + } + + fn apply_writeup_filter(&mut self) { + let needle = self.filter.to_ascii_lowercase(); + self.visible_writeups = self + .writeups + .iter() + .enumerate() + .filter_map(|(idx, writeup)| { + if (self.show_all_writeups || writeup.status == WriteupStatus::Open) + && (needle.is_empty() || writeup_matches(writeup, &needle)) + { + Some(idx) + } else { + None + } + }) + .collect(); + + self.writeup_detail = self + .writeup_detail + .filter(|idx| self.visible_writeups.contains(idx)); + if self.visible_writeups.is_empty() { + self.writeup_state.select(None); + } else { + let selected = self + .writeup_state + .selected() + .unwrap_or(0) + .min(self.visible_writeups.len() - 1); + self.writeup_state.select(Some(selected)); + } } fn next(&mut self) { + if self.active_tab == TuiTab::Writeups { + self.next_writeup(); + return; + } if self.visible.is_empty() { return; } @@ -3004,6 +4298,10 @@ impl App { } fn previous(&mut self) { + if self.active_tab == TuiTab::Writeups { + self.previous_writeup(); + return; + } if self.visible.is_empty() { return; } @@ -3015,9 +4313,31 @@ impl App { self.sync_open_detail(); } + fn next_writeup(&mut self) { + if self.visible_writeups.is_empty() { + return; + } + let selected = self.writeup_state.selected().unwrap_or(0); + let next = (selected + 1) % self.visible_writeups.len(); + self.writeup_state.select(Some(next)); + self.sync_open_writeup_detail(); + } + + fn previous_writeup(&mut self) { + if self.visible_writeups.is_empty() { + return; + } + let selected = self.writeup_state.selected().unwrap_or(0); + let previous = selected + .checked_sub(1) + .unwrap_or_else(|| self.visible_writeups.len().saturating_sub(1)); + self.writeup_state.select(Some(previous)); + self.sync_open_writeup_detail(); + } + fn resize_detail(&mut self, delta: i16) { - if self.detail.is_none() { - self.status = Some("Open ticket details first.".to_string()); + if self.detail.is_none() && self.writeup_detail.is_none() { + self.status = Some("Open details first.".to_string()); return; } let next = if delta.is_negative() { @@ -3048,8 +4368,9 @@ impl App { fn refresh_data(&mut self) -> Result<()> { let selected_id = self.selected_ticket().map(|ticket| ticket.id); + let selected_writeup_id = self.selected_writeup().map(|writeup| writeup.id); let was_board = self.view == ViewMode::Board && self.detail.is_none(); - self.reload(selected_id)?; + self.reload_all(selected_id, selected_writeup_id)?; if was_board { if let Some(id) = selected_id { self.select_board_ticket_by_id(id); @@ -3059,6 +4380,17 @@ impl App { Ok(()) } + fn toggle_tab(&mut self) { + self.active_tab = match self.active_tab { + TuiTab::Issues => { + self.comments_mode = false; + self.view = ViewMode::List; + TuiTab::Writeups + } + TuiTab::Writeups => TuiTab::Issues, + }; + } + fn toggle_view(&mut self) { self.view = match self.view { ViewMode::List => ViewMode::Board, @@ -3122,6 +4454,10 @@ impl App { } fn open_selected(&mut self) { + if self.active_tab == TuiTab::Writeups { + self.open_selected_writeup(); + return; + } if let Some(idx) = self.selected_ticket_index() { self.detail = Some(idx); if let Some(visible_pos) = self.visible.iter().position(|visible| *visible == idx) { @@ -3132,6 +4468,19 @@ impl App { } } + fn open_selected_writeup(&mut self) { + if let Some(idx) = self.selected_writeup_index() { + self.writeup_detail = Some(idx); + if let Some(visible_pos) = self + .visible_writeups + .iter() + .position(|visible| *visible == idx) + { + self.writeup_state.select(Some(visible_pos)); + } + } + } + fn open_ticket_by_id(&mut self, id: uuid::Uuid) { if let Some(visible_pos) = self .visible @@ -3151,6 +4500,12 @@ impl App { } } + fn sync_open_writeup_detail(&mut self) { + if self.writeup_detail.is_some() { + self.open_selected_writeup(); + } + } + fn board_column_tickets(&self, column: usize) -> Vec<&usize> { let state = BOARD_STATES[column]; self.visible @@ -3274,7 +4629,31 @@ impl App { self.selected_ticket_index().map(|idx| &self.tickets[idx]) } + fn selected_tag_target(&self) -> Option<(TagTarget, String, BTreeSet)> { + match self.active_tab { + TuiTab::Issues => { + let ticket = self.selected_ticket()?; + Some(( + TagTarget::Ticket(ticket.id), + ticket.short_id(), + ticket.tags.clone(), + )) + } + TuiTab::Writeups => { + let writeup = self.selected_writeup()?; + Some(( + TagTarget::Writeup(writeup.id), + format!("writeup {}", writeup.short_id()), + writeup.tags.clone(), + )) + } + } + } + fn selected_ticket_index(&self) -> Option { + if self.active_tab != TuiTab::Issues { + return None; + } if self.view == ViewMode::Board && self.detail.is_none() { let tickets = self.board_column_tickets(self.board_column); return tickets @@ -3286,6 +4665,20 @@ impl App { .and_then(|selected| self.visible.get(selected)) .copied() } + + fn selected_writeup(&self) -> Option<&Writeup> { + self.selected_writeup_index().map(|idx| &self.writeups[idx]) + } + + fn selected_writeup_index(&self) -> Option { + if self.active_tab != TuiTab::Writeups { + return None; + } + self.writeup_state + .selected() + .and_then(|selected| self.visible_writeups.get(selected)) + .copied() + } } impl NewTicketDraft { @@ -3537,11 +4930,26 @@ fn first_non_empty_line(value: &str) -> Option<&str> { value.lines().map(str::trim).find(|line| !line.is_empty()) } +fn tabs_title(active: TuiTab, title: &str) -> String { + let issues = if active == TuiTab::Issues { + "[issues]" + } else { + " issues " + }; + let writeups = if active == TuiTab::Writeups { + "[writeups]" + } else { + " writeups " + }; + format!("{issues} {writeups} {title}") +} + fn ticket_list_line( ticket: &Ticket, width: usize, compact: bool, current_user: &str, + has_writeups: bool, ) -> Line<'static> { let short_id = ticket .short_id() @@ -3556,6 +4964,7 @@ fn ticket_list_line( &list_meta_display(ticket), ticket.assigned.as_deref() == Some(current_user), width, + has_writeups.then(|| ("[w]".to_string(), Style::default().fg(Color::Yellow))), ); } @@ -3566,9 +4975,89 @@ fn ticket_list_line( Some(&ticket.tags), ticket.assigned.as_deref() == Some(current_user), width, + has_writeups.then(|| ("[w]".to_string(), Style::default().fg(Color::Yellow))), + ) +} + +fn writeup_list_line(writeup: &Writeup, width: usize, compact: bool) -> Line<'static> { + let short_id = writeup + .short_id() + .chars() + .take(LIST_ID_WIDTH) + .collect::(); + let title = flatten_display(&writeup.title); + let meta = vec![ + ( + fit_display( + &relative_date(writeup_recent_at(writeup), OffsetDateTime::now_utc()), + LIST_AGE_WIDTH, + ), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + ), + ( + fit_display(&format!("v{}", writeup.versions.len()), LIST_STATE_WIDTH), + Style::default().fg(Color::LightBlue), + ), + ( + fit_display(writeup_status_abbrev(writeup.status), LIST_STATE_WIDTH), + writeup_status_style(writeup.status), + ), + ]; + let issue_indicator = (!writeup.tickets.is_empty()).then(|| { + ( + format!("[{}]", writeup.tickets.len()), + Style::default().fg(Color::Magenta), + ) + }); + + if compact { + return ticket_list_line_from_parts( + Some(&short_id), + &title, + &meta, + None, + false, + width, + issue_indicator, + ); + } + + ticket_list_line_from_parts( + Some(&short_id), + &title, + &meta, + Some(&writeup.tags), + false, + width, + issue_indicator, ) } +fn writeup_recent_at(writeup: &Writeup) -> OffsetDateTime { + writeup + .versions + .last() + .map(|version| version.at) + .unwrap_or(writeup.created_at) +} + +fn writeup_status_abbrev(status: WriteupStatus) -> &'static str { + match status { + WriteupStatus::Open => "op", + WriteupStatus::Closed => "cl", + } +} + +fn writeup_status_style(status: WriteupStatus) -> Style { + match status { + WriteupStatus::Open => Style::default().fg(Color::LightGreen), + WriteupStatus::Closed => Style::default().fg(Color::DarkGray), + } + .add_modifier(Modifier::BOLD) +} + fn board_ticket_line(ticket: &Ticket, width: usize) -> Line<'static> { let meta = priority_points_display(ticket); let meta_width = meta @@ -3607,6 +5096,7 @@ fn ticket_list_line_from_parts( tags: Option<&BTreeSet>, assigned_to_current_user: bool, width: usize, + right_indicator: Option<(String, Style)>, ) -> Line<'static> { let mut leading = Vec::new(); let mut used_width = 0; @@ -3638,7 +5128,14 @@ fn ticket_list_line_from_parts( 0 }; let meta_gap = " ".repeat(meta_gap_width); - let content_width = width.saturating_sub(used_width + meta_width + meta_gap_width); + let indicator_width = right_indicator + .as_ref() + .map(|(label, _)| UnicodeWidthStr::width(label.as_str()).min(width)) + .unwrap_or_default(); + let indicator_gap_width = usize::from(indicator_width > 0); + let content_width = width + .saturating_sub(used_width + meta_width + meta_gap_width) + .saturating_sub(indicator_width + indicator_gap_width); if meta_width > 0 { leading.push(Span::raw(meta_gap)); @@ -3646,6 +5143,7 @@ fn ticket_list_line_from_parts( let Some(tags) = tags.filter(|tags| !tags.is_empty()) else { leading.push(Span::raw(truncate_display(title, content_width))); + push_right_indicator(&mut leading, right_indicator, width); return Line::from(leading); }; @@ -3670,15 +5168,38 @@ fn ticket_list_line_from_parts( leading.push(Span::raw(title)); leading.push(Span::raw(" ".repeat(padding_width))); leading.extend(tag_spans); + push_right_indicator(&mut leading, right_indicator, width); Line::from(leading) } +fn push_right_indicator( + spans: &mut Vec>, + right_indicator: Option<(String, Style)>, + width: usize, +) { + let Some((label, style)) = right_indicator else { + return; + }; + let used_width = spans_width(spans); + if used_width >= width { + return; + } + let available_width = width - used_width; + let label = truncate_display(&label, available_width); + let label_width = UnicodeWidthStr::width(label.as_str()); + spans.push(Span::raw( + " ".repeat(available_width.saturating_sub(label_width)), + )); + spans.push(Span::styled(label, style)); +} + fn compact_ticket_list_line( short_id: &str, title: &str, meta: &[(String, Style)], assigned_to_current_user: bool, width: usize, + right_indicator: Option<(String, Style)>, ) -> Line<'static> { let title_target_width = COMPACT_LIST_MIN_TITLE_WIDTH.min(width).max(1); let mut short_id = Some(short_id); @@ -3703,14 +5224,11 @@ fn compact_ticket_list_line( None, assigned_to_current_user, width, + right_indicator, ) } -fn compact_title_width( - short_id: Option<&str>, - meta: &[(String, Style)], - width: usize, -) -> usize { +fn compact_title_width(short_id: Option<&str>, meta: &[(String, Style)], width: usize) -> usize { let id_width = short_id .map(|id| UnicodeWidthStr::width(id).min(width)) .unwrap_or_default(); @@ -3864,6 +5382,18 @@ fn ticket_matches(ticket: &Ticket, needle: &str) -> bool { .contains(needle) } +fn writeup_matches(writeup: &Writeup, needle: &str) -> bool { + writeup.title.to_ascii_lowercase().contains(needle) + || writeup + .latest_body() + .map(|body| body.to_ascii_lowercase().contains(needle)) + .unwrap_or(false) + || writeup + .tags + .iter() + .any(|tag| tag.to_ascii_lowercase().contains(needle)) +} + fn ticket_edit_body(ticket: &Ticket) -> String { let mut body = ticket.title.clone(); if let Some(description) = &ticket.description { @@ -3873,6 +5403,15 @@ fn ticket_edit_body(ticket: &Ticket) -> String { body } +fn writeup_edit_body(writeup: &Writeup) -> String { + let mut body = writeup.title.clone(); + if let Some(latest_body) = writeup.latest_body() { + body.push_str("\n\n"); + body.push_str(latest_body); + } + body +} + fn first_spec_line(spec: &str) -> &str { spec.lines() .map(str::trim) @@ -4072,6 +5611,101 @@ fn field_line(label: &str, value: &str) -> Line<'static> { ]) } +#[derive(Clone)] +struct MetadataField { + key: &'static str, + label: &'static str, + value: String, +} + +fn writeup_metadata_lines(writeup: &Writeup, width: usize) -> Vec> { + let mut fields = vec![ + MetadataField { + key: "updated", + label: "Updated", + value: format!( + "{} ago", + relative_date(writeup_recent_at(writeup), OffsetDateTime::now_utc()) + ), + }, + MetadataField { + key: "status", + label: "Status", + value: writeup.status.as_str().to_string(), + }, + MetadataField { + key: "versions", + label: "Versions", + value: writeup.versions.len().to_string(), + }, + MetadataField { + key: "authors", + label: "Authors", + value: writeup + .authors + .iter() + .cloned() + .collect::>() + .join(", "), + }, + ]; + + for key in ["versions", "status", "authors"] { + if metadata_fields_width(&fields) <= width { + break; + } + if let Some(idx) = fields.iter().position(|field| field.key == key) { + fields.remove(idx); + } + } + + metadata_lines(&fields, width) +} + +fn metadata_fields_width(fields: &[MetadataField]) -> usize { + fields.iter().map(metadata_field_width).sum::() + fields.len().saturating_sub(1) * 2 +} + +fn metadata_field_width(field: &MetadataField) -> usize { + UnicodeWidthStr::width(field.label).max(UnicodeWidthStr::width(field.value.as_str())) +} + +fn metadata_lines(fields: &[MetadataField], width: usize) -> Vec> { + if fields.is_empty() || width == 0 { + return Vec::new(); + } + + let mut widths = fields.iter().map(metadata_field_width).collect::>(); + let total_width = widths.iter().sum::() + fields.len().saturating_sub(1) * 2; + if total_width > width { + let overflow = total_width - width; + if let Some(last_width) = widths.last_mut() { + *last_width = last_width.saturating_sub(overflow).max(1); + } + } + + let mut label_spans = Vec::new(); + let mut value_spans = Vec::new(); + for (idx, (field, column_width)) in fields.iter().zip(widths).enumerate() { + if idx > 0 { + label_spans.push(Span::raw(" ")); + value_spans.push(Span::raw(" ")); + } + label_spans.push(Span::styled( + fit_display(field.label, column_width), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + value_spans.push(Span::styled( + fit_display(&field.value, column_width), + Style::default().fg(Color::Cyan), + )); + } + + vec![Line::from(label_spans), Line::from(value_spans)] +} + fn spec_field_line(spec: &str, width: usize) -> Line<'static> { let label_width = 10; let separator_width = 3; diff --git a/crates/ticgit/src/commands/writeup.rs b/crates/ticgit/src/commands/writeup.rs new file mode 100644 index 00000000..6c495ebc --- /dev/null +++ b/crates/ticgit/src/commands/writeup.rs @@ -0,0 +1,350 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use ticgit_lib::{NewWriteupOpts, Writeup, WriteupStatus}; +use time::format_description::well_known::Rfc3339; + +use crate::commands::open_store; +use crate::editor; + +#[derive(Debug, Parser)] +pub struct Args { + #[command(subcommand)] + pub action: Action, +} + +#[derive(Debug, Subcommand)] +pub enum Action { + /// Create a new writeup. + New(NewArgs), + /// List writeups. + List(ListArgs), + /// Show a writeup. + Show(ShowArgs), + /// Append a new version to a writeup. + Edit(EditArgs), + /// Promote a writeup into a ticket. + Promote(PromoteArgs), + /// Close a writeup. + Close(IdArgs), + /// Archive a writeup (alias for close). + Archive(IdArgs), + /// Link a writeup to an existing ticket. + Link(LinkArgs), + /// Remove a writeup-ticket link. + Unlink(LinkArgs), +} + +#[derive(Debug, Parser)] +pub struct NewArgs { + /// Writeup title. If omitted, your `$EDITOR` is opened to write one. + #[arg(short = 't', long = "title")] + pub title: Option, + + /// Initial writeup body. + #[arg(long = "body", conflicts_with = "file")] + pub body: Option, + + /// Read the initial writeup body from a file. + #[arg(short = 'F', long = "file", conflicts_with = "body")] + pub file: Option, + + /// Comma- or space-separated list of tags to apply on creation. + #[arg(short = 'g', long = "tags")] + pub tags: Option, + + /// Don't print the new writeup; just print the new id. + #[arg(long = "id-only")] + pub id_only: bool, +} + +#[derive(Debug, Parser)] +pub struct ListArgs { + /// Include closed writeups. + #[arg(long = "all")] + pub all: bool, +} + +#[derive(Debug, Parser)] +pub struct ShowArgs { + /// Writeup id or unique prefix. + pub id: String, + + /// Show every version instead of only the latest version. + #[arg(long = "all")] + pub all: bool, +} + +#[derive(Debug, Parser)] +pub struct EditArgs { + /// Writeup id or unique prefix. + pub id: String, + + /// New version body. + #[arg(long = "body", conflicts_with = "file")] + pub body: Option, + + /// Read the new version body from a file. + #[arg(short = 'F', long = "file", conflicts_with = "body")] + pub file: Option, +} + +#[derive(Debug, Parser)] +pub struct PromoteArgs { + /// Writeup id or unique prefix. + pub id: String, +} + +#[derive(Debug, Parser)] +pub struct IdArgs { + /// Writeup id or unique prefix. + pub id: String, +} + +#[derive(Debug, Parser)] +pub struct LinkArgs { + /// Writeup id or unique prefix. + pub writeup: String, + /// Ticket id or unique prefix. + pub ticket: String, +} + +pub fn run(args: Args) -> Result<()> { + match args.action { + Action::New(args) => run_new(args), + Action::List(args) => run_list(args), + Action::Show(args) => run_show(args), + Action::Edit(args) => run_edit(args), + Action::Promote(args) => run_promote(args), + Action::Close(args) | Action::Archive(args) => run_close(args), + Action::Link(args) => run_link(args), + Action::Unlink(args) => run_unlink(args), + } +} + +fn run_new(args: NewArgs) -> Result<()> { + let store = open_store()?; + let title = match args.title { + Some(title) if !title.trim().is_empty() => title.trim().to_string(), + _ => editor::capture("Writeup title")?.context("writeup title cannot be empty")?, + }; + let body = body_from_args(args.body, args.file, "Writeup body")?; + let writeup = store.create_writeup( + &title, + NewWriteupOpts { + body, + tags: parse_tags(args.tags.as_deref()), + ..Default::default() + }, + )?; + + if args.id_only { + println!("{}", writeup.id); + } else { + println!("Created writeup {} ({})", writeup.short_id(), writeup.title); + println!("Full id: {}", writeup.id); + } + Ok(()) +} + +fn run_list(args: ListArgs) -> Result<()> { + let store = open_store()?; + let writeups = store.list_writeups()?; + let mut shown = 0; + for writeup in writeups { + if !args.all && writeup.status == WriteupStatus::Closed { + continue; + } + shown += 1; + let tags = if writeup.tags.is_empty() { + String::new() + } else { + format!( + " [{}]", + writeup.tags.iter().cloned().collect::>().join(",") + ) + }; + println!( + "{} {:<6} {:<6} v{} {}{}", + writeup.short_id(), + writeup.status.as_str(), + writeup.authors.len(), + writeup.versions.len(), + writeup.title, + tags + ); + } + if shown == 0 { + println!("(no writeups)"); + } + Ok(()) +} + +fn run_show(args: ShowArgs) -> Result<()> { + let store = open_store()?; + let id = store.resolve_writeup_id(&args.id)?; + let writeup = store.load_writeup(&id)?; + print_writeup(&writeup, args.all) +} + +fn run_edit(args: EditArgs) -> Result<()> { + let store = open_store()?; + let id = store.resolve_writeup_id(&args.id)?; + let current = store.load_writeup(&id)?; + let initial = current.latest_body().unwrap_or(""); + let body = match (args.body, args.file) { + (Some(body), None) => body, + (None, Some(path)) => std::fs::read_to_string(&path) + .with_context(|| format!("reading writeup body from `{}`", path.display()))?, + (None, None) => editor::capture_with_initial("Writeup body", initial)? + .context("writeup edit cancelled")?, + (Some(_), Some(_)) => unreachable!("clap enforces conflicts"), + }; + store.append_writeup_version(&id, &body)?; + let writeup = store.load_writeup(&id)?; + println!( + "Appended version {} to writeup {}.", + writeup.versions.len(), + writeup.short_id() + ); + Ok(()) +} + +fn run_promote(args: PromoteArgs) -> Result<()> { + let store = open_store()?; + let id = store.resolve_writeup_id(&args.id)?; + let ticket = store.promote_writeup(&id)?; + println!( + "Promoted writeup {} to ticket {} ({})", + &id.to_string()[..6], + ticket.short_id(), + ticket.title + ); + println!("Full ticket id: {}", ticket.id); + Ok(()) +} + +fn run_close(args: IdArgs) -> Result<()> { + let store = open_store()?; + let id = store.resolve_writeup_id(&args.id)?; + store.set_writeup_status(&id, WriteupStatus::Closed)?; + println!("Closed writeup {}.", &id.to_string()[..6]); + Ok(()) +} + +fn run_link(args: LinkArgs) -> Result<()> { + let store = open_store()?; + let writeup_id = store.resolve_writeup_id(&args.writeup)?; + let ticket_id = store.resolve_id(&args.ticket)?; + store.link_writeup_ticket(&writeup_id, &ticket_id)?; + println!( + "Linked writeup {} to ticket {}.", + &writeup_id.to_string()[..6], + &ticket_id.to_string()[..6] + ); + Ok(()) +} + +fn run_unlink(args: LinkArgs) -> Result<()> { + let store = open_store()?; + let writeup_id = store.resolve_writeup_id(&args.writeup)?; + let ticket_id = store.resolve_id(&args.ticket)?; + store.unlink_writeup_ticket(&writeup_id, &ticket_id)?; + println!( + "Unlinked writeup {} from ticket {}.", + &writeup_id.to_string()[..6], + &ticket_id.to_string()[..6] + ); + Ok(()) +} + +fn print_writeup(writeup: &Writeup, all: bool) -> Result<()> { + println!("# Writeup: {}", writeup.title); + println!(); + println!("- Id: `{}`", writeup.id); + println!("- Short id: `{}`", writeup.short_id()); + println!("- Status: `{}`", writeup.status.as_str()); + println!( + "- Created: `{}` by {}", + writeup.created_at.format(&Rfc3339)?, + writeup.created_by + ); + println!( + "- Authors: {}", + display_list(writeup.authors.iter().map(String::as_str)) + ); + println!( + "- Tags: {}", + display_list(writeup.tags.iter().map(String::as_str)) + ); + let ticket_ids = writeup + .tickets + .iter() + .map(|id| id.to_string()) + .collect::>(); + println!( + "- Tickets: {}", + display_list(ticket_ids.iter().map(String::as_str)) + ); + println!("- Versions: {}", writeup.versions.len()); + println!(); + + if all { + for (index, version) in writeup.versions.iter().enumerate() { + println!("## Version {}", index + 1); + println!(); + println!("- Author: {}", version.author); + println!("- Date: `{}`", version.at.format(&Rfc3339)?); + println!(); + println!("{}", version.body); + println!(); + } + } else if let Some(version) = writeup.versions.last() { + println!("## Latest Version"); + println!(); + println!("- Author: {}", version.author); + println!("- Date: `{}`", version.at.format(&Rfc3339)?); + println!(); + println!("{}", version.body); + } else { + println!("_No versions yet._"); + } + Ok(()) +} + +fn body_from_args( + body: Option, + file: Option, + prompt: &str, +) -> Result> { + match (body, file) { + (Some(body), None) => Ok(Some(body)), + (None, Some(path)) => { + Ok(Some(std::fs::read_to_string(&path).with_context(|| { + format!("reading writeup body from `{}`", path.display()) + })?)) + } + (None, None) => Ok(editor::capture(prompt)?), + (Some(_), Some(_)) => unreachable!("clap enforces conflicts"), + } +} + +fn display_list<'a>(items: impl Iterator) -> String { + let items = items.filter(|item| !item.is_empty()).collect::>(); + if items.is_empty() { + "none".to_string() + } else { + items.join(", ") + } +} + +fn parse_tags(raw: Option<&str>) -> Vec { + raw.map(|s| { + s.split(|c: char| c == ',' || c.is_whitespace()) + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect() + }) + .unwrap_or_default() +} diff --git a/crates/ticgit/tests/cli.rs b/crates/ticgit/tests/cli.rs index 548ccd8e..b93b8e3a 100644 --- a/crates/ticgit/tests/cli.rs +++ b/crates/ticgit/tests/cli.rs @@ -958,6 +958,116 @@ fn list_filters_and_saved_views_work() { .success(); } +#[test] +fn writeup_workflow_creates_versions_links_and_promotes() { + let repo = TestRepo::new(); + let output = repo + .ti() + .args([ + "writeup", + "new", + "--title", + "Rethink sync", + "--body", + "Initial notes", + "--tags", + "design", + "--id-only", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let writeup = String::from_utf8(output).unwrap().trim().to_string(); + let writeup_prefix = &writeup[..6]; + + repo.ti() + .args(["writeup", "list"]) + .assert() + .success() + .stdout(predicate::str::contains(writeup_prefix)) + .stdout(predicate::str::contains("Rethink sync")) + .stdout(predicate::str::contains("[design]")); + + repo.ti() + .args(["writeup", "edit", writeup_prefix, "--body", "Second notes"]) + .assert() + .success() + .stdout(predicate::str::contains("Appended version 2")); + + repo.ti() + .args(["tag", "--writeup", writeup_prefix, "review"]) + .assert() + .success() + .stdout(predicate::str::contains("review")); + repo.ti() + .args(["tag", "--writeup", writeup_prefix, "--remove", "design"]) + .assert() + .success(); + + repo.ti() + .args(["writeup", "show", writeup_prefix]) + .assert() + .success() + .stdout(predicate::str::contains("# Writeup: Rethink sync")) + .stdout(predicate::str::contains("- Tags: review")) + .stdout(predicate::str::contains("Second notes")) + .stdout(predicate::str::contains("Initial notes").not()); + + let ticket = create_ticket(&repo, "related ticket"); + repo.ti() + .args(["writeup", "link", writeup_prefix, &ticket[..6]]) + .assert() + .success(); + repo.ti() + .args(["writeup", "show", writeup_prefix]) + .assert() + .success() + .stdout(predicate::str::contains(&ticket)); + repo.ti() + .args(["writeup", "unlink", writeup_prefix, &ticket[..6]]) + .assert() + .success(); + + let promoted_output = repo + .ti() + .args(["writeup", "promote", writeup_prefix]) + .assert() + .success() + .stdout(predicate::str::contains("Promoted writeup")) + .get_output() + .stdout + .clone(); + let promoted_stdout = String::from_utf8(promoted_output).unwrap(); + let promoted_id = promoted_stdout + .lines() + .find_map(|line| line.strip_prefix("Full ticket id: ")) + .expect("promoted ticket id"); + repo.ti() + .args(["show", promoted_id, "--markdown"]) + .assert() + .success() + .stdout(predicate::str::contains("# Ticket: Rethink sync")) + .stdout(predicate::str::contains("review")) + .stdout(predicate::str::contains("Second notes")); + + repo.ti() + .args(["writeup", "close", writeup_prefix]) + .assert() + .success(); + repo.ti() + .args(["writeup", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("Rethink sync").not()); + repo.ti() + .args(["writeup", "list", "--all"]) + .assert() + .success() + .stdout(predicate::str::contains("Rethink sync")); +} + #[test] fn list_search_filters_title_description_and_comments() { let repo = TestRepo::new();