feat(kanban): add dew kanban tui interactive board TUI
Add an interactive Trello-style kanban TUI powered by dart_console. Features: - Side-by-side column layout, adapts to terminal width - Keyboard navigation: j/k (up/down), h/l or ←/→ (switch column) - Move selected ticket between columns with < and > - Ticket cards show ID, type badge (colour-coded), title, labels/milestone - Scroll indicators (↑ N above / ↓ N below) when a column overflows - Column headers highlighted in their configured colour; active column uses filled background + double-line border - Live filter with / (fuzzy search across id, title, type, labels, body) - Detail view (Enter): full ticket info, body and comments with word-wrap, scrollable with j/k - r to reload tickets from disk without leaving the TUI - Graceful terminal restoration on exit (Esc / q) - Requires an interactive terminal; prints a clear error otherwise Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
dff35598f5
commit
c25f32dc12
4 changed files with 821 additions and 0 deletions
770
packages/kanban/lib/src/commands/tui_command.dart
Normal file
770
packages/kanban/lib/src/commands/tui_command.dart
Normal file
|
|
@ -0,0 +1,770 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:dart_console/dart_console.dart';
|
||||||
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
import 'package:file/file.dart';
|
||||||
|
import 'package:file/local.dart';
|
||||||
|
|
||||||
|
import '../kanban_config.dart';
|
||||||
|
import '../ticket.dart';
|
||||||
|
import '../ticket_store.dart';
|
||||||
|
|
||||||
|
enum _Mode { board, detail }
|
||||||
|
|
||||||
|
/// A single rendered cell: text + optional styling.
|
||||||
|
class _Cell {
|
||||||
|
final String text;
|
||||||
|
final ConsoleColor? fg;
|
||||||
|
final ConsoleColor? bg;
|
||||||
|
final bool bold;
|
||||||
|
|
||||||
|
const _Cell(this.text, {this.fg, this.bg, this.bold = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interactive Trello-style kanban TUI.
|
||||||
|
class TuiCommand extends DewCommand {
|
||||||
|
final FileSystem _fs;
|
||||||
|
|
||||||
|
TuiCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String name = 'tui';
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String description = 'Launch the interactive kanban board TUI.';
|
||||||
|
|
||||||
|
// Layout constants
|
||||||
|
static const _colSep = 1; // gap between columns (chars)
|
||||||
|
static const _minColW = 26; // minimum column width
|
||||||
|
static const _colHeaderH = 3; // rows used by column header box
|
||||||
|
static const _ticketH = 3; // rows per ticket card
|
||||||
|
static const _indicatorRows = 2; // rows reserved for scroll indicators
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> run() async {
|
||||||
|
final context = await ProjectContext.find(fs: _fs);
|
||||||
|
final config = context.config.kanban;
|
||||||
|
final store = TicketStore(
|
||||||
|
kanbanDir: context.dirs.kanban,
|
||||||
|
prefix: config.prefix,
|
||||||
|
fs: context.fs,
|
||||||
|
);
|
||||||
|
|
||||||
|
final console = Console();
|
||||||
|
if (!console.hasTerminal) {
|
||||||
|
print('dew kanban tui requires an interactive terminal.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tickets = await store.list();
|
||||||
|
var byColumn = _groupByColumn(tickets, config);
|
||||||
|
|
||||||
|
var colIdx = 0;
|
||||||
|
var ticketIdx = 0;
|
||||||
|
var mode = _Mode.board;
|
||||||
|
var detailScroll = 0;
|
||||||
|
var statusMsg = '';
|
||||||
|
var searchQuery = '';
|
||||||
|
var searchMode = false;
|
||||||
|
|
||||||
|
console.hideCursor();
|
||||||
|
console.rawMode = true;
|
||||||
|
|
||||||
|
void redraw() {
|
||||||
|
final w = max(console.windowWidth, 40);
|
||||||
|
final h = max(console.windowHeight, 12);
|
||||||
|
console.clearScreen();
|
||||||
|
console.resetCursorPosition();
|
||||||
|
|
||||||
|
if (mode == _Mode.board) {
|
||||||
|
_renderBoard(
|
||||||
|
console: console,
|
||||||
|
config: config,
|
||||||
|
byColumn: byColumn,
|
||||||
|
colIdx: colIdx,
|
||||||
|
ticketIdx: ticketIdx,
|
||||||
|
statusMsg: statusMsg,
|
||||||
|
searchQuery: searchQuery,
|
||||||
|
searchMode: searchMode,
|
||||||
|
w: w,
|
||||||
|
h: h,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final col = config.columns[colIdx];
|
||||||
|
final colTickets = _filtered(byColumn[col.id] ?? [], searchQuery);
|
||||||
|
if (colTickets.isEmpty) {
|
||||||
|
mode = _Mode.board;
|
||||||
|
} else {
|
||||||
|
_renderDetail(
|
||||||
|
console: console,
|
||||||
|
ticket: colTickets[ticketIdx.clamp(0, colTickets.length - 1)],
|
||||||
|
config: config,
|
||||||
|
scroll: detailScroll,
|
||||||
|
w: w,
|
||||||
|
h: h,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
redraw();
|
||||||
|
|
||||||
|
loop:
|
||||||
|
while (true) {
|
||||||
|
final key = console.readKey();
|
||||||
|
|
||||||
|
// ── Search mode ────────────────────────────────────────────────────
|
||||||
|
if (searchMode) {
|
||||||
|
if (key.isControl) {
|
||||||
|
switch (key.controlChar) {
|
||||||
|
case ControlCharacter.enter:
|
||||||
|
searchMode = false;
|
||||||
|
ticketIdx = 0;
|
||||||
|
case ControlCharacter.escape:
|
||||||
|
searchMode = false;
|
||||||
|
searchQuery = '';
|
||||||
|
ticketIdx = 0;
|
||||||
|
case ControlCharacter.backspace:
|
||||||
|
if (searchQuery.isNotEmpty) {
|
||||||
|
searchQuery = searchQuery.substring(0, searchQuery.length - 1);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
{}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
searchQuery += key.char;
|
||||||
|
}
|
||||||
|
redraw();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board mode ─────────────────────────────────────────────────────
|
||||||
|
if (mode == _Mode.board) {
|
||||||
|
final col = config.columns[colIdx];
|
||||||
|
final colTickets = _filtered(byColumn[col.id] ?? [], searchQuery);
|
||||||
|
|
||||||
|
if (!key.isControl) {
|
||||||
|
switch (key.char) {
|
||||||
|
case 'q':
|
||||||
|
break loop;
|
||||||
|
case 'j':
|
||||||
|
if (ticketIdx < colTickets.length - 1) ticketIdx++;
|
||||||
|
case 'k':
|
||||||
|
if (ticketIdx > 0) ticketIdx--;
|
||||||
|
case 'h':
|
||||||
|
if (colIdx > 0) {
|
||||||
|
colIdx--;
|
||||||
|
ticketIdx = 0;
|
||||||
|
}
|
||||||
|
case 'l':
|
||||||
|
if (colIdx < config.columns.length - 1) {
|
||||||
|
colIdx++;
|
||||||
|
ticketIdx = 0;
|
||||||
|
}
|
||||||
|
case '<':
|
||||||
|
if (colIdx > 0 && colTickets.isNotEmpty) {
|
||||||
|
final t = colTickets[ticketIdx];
|
||||||
|
final newColId = config.columns[colIdx - 1].id;
|
||||||
|
try {
|
||||||
|
await store.update(t.id, column: newColId);
|
||||||
|
tickets = await store.list();
|
||||||
|
byColumn = _groupByColumn(tickets, config);
|
||||||
|
colIdx--;
|
||||||
|
final nt = _filtered(byColumn[newColId] ?? [], searchQuery);
|
||||||
|
ticketIdx = max(0, nt.length - 1);
|
||||||
|
statusMsg = 'Moved ${t.id} → ${config.columns[colIdx].name}';
|
||||||
|
} on ArgumentError catch (e) {
|
||||||
|
statusMsg = 'Error: ${e.message ?? e}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case '>':
|
||||||
|
if (colIdx < config.columns.length - 1 && colTickets.isNotEmpty) {
|
||||||
|
final t = colTickets[ticketIdx];
|
||||||
|
final newColId = config.columns[colIdx + 1].id;
|
||||||
|
try {
|
||||||
|
await store.update(t.id, column: newColId);
|
||||||
|
tickets = await store.list();
|
||||||
|
byColumn = _groupByColumn(tickets, config);
|
||||||
|
colIdx++;
|
||||||
|
final nt = _filtered(byColumn[newColId] ?? [], searchQuery);
|
||||||
|
ticketIdx = max(0, nt.length - 1);
|
||||||
|
statusMsg = 'Moved ${t.id} → ${config.columns[colIdx].name}';
|
||||||
|
} on ArgumentError catch (e) {
|
||||||
|
statusMsg = 'Error: ${e.message ?? e}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'r':
|
||||||
|
tickets = await store.list();
|
||||||
|
byColumn = _groupByColumn(tickets, config);
|
||||||
|
statusMsg = 'Reloaded.';
|
||||||
|
case '/':
|
||||||
|
searchMode = true;
|
||||||
|
searchQuery = '';
|
||||||
|
ticketIdx = 0;
|
||||||
|
default:
|
||||||
|
continue loop; // skip redraw
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (key.controlChar) {
|
||||||
|
case ControlCharacter.arrowUp:
|
||||||
|
if (ticketIdx > 0) ticketIdx--;
|
||||||
|
case ControlCharacter.arrowDown:
|
||||||
|
if (ticketIdx < colTickets.length - 1) ticketIdx++;
|
||||||
|
case ControlCharacter.arrowLeft:
|
||||||
|
if (colIdx > 0) {
|
||||||
|
colIdx--;
|
||||||
|
ticketIdx = 0;
|
||||||
|
}
|
||||||
|
case ControlCharacter.arrowRight:
|
||||||
|
if (colIdx < config.columns.length - 1) {
|
||||||
|
colIdx++;
|
||||||
|
ticketIdx = 0;
|
||||||
|
}
|
||||||
|
case ControlCharacter.enter:
|
||||||
|
if (colTickets.isNotEmpty) {
|
||||||
|
mode = _Mode.detail;
|
||||||
|
detailScroll = 0;
|
||||||
|
}
|
||||||
|
case ControlCharacter.escape:
|
||||||
|
searchQuery = '';
|
||||||
|
statusMsg = '';
|
||||||
|
default:
|
||||||
|
continue loop; // skip redraw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ── Detail mode ────────────────────────────────────────────────────
|
||||||
|
else {
|
||||||
|
if (!key.isControl) {
|
||||||
|
switch (key.char) {
|
||||||
|
case 'q':
|
||||||
|
break loop;
|
||||||
|
case 'b':
|
||||||
|
mode = _Mode.board;
|
||||||
|
detailScroll = 0;
|
||||||
|
case 'j':
|
||||||
|
detailScroll++;
|
||||||
|
case 'k':
|
||||||
|
if (detailScroll > 0) detailScroll--;
|
||||||
|
default:
|
||||||
|
continue loop;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (key.controlChar) {
|
||||||
|
case ControlCharacter.escape:
|
||||||
|
mode = _Mode.board;
|
||||||
|
detailScroll = 0;
|
||||||
|
case ControlCharacter.arrowUp:
|
||||||
|
if (detailScroll > 0) detailScroll--;
|
||||||
|
case ControlCharacter.arrowDown:
|
||||||
|
detailScroll++;
|
||||||
|
default:
|
||||||
|
continue loop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.rawMode = false;
|
||||||
|
console.showCursor();
|
||||||
|
console.clearScreen();
|
||||||
|
console.resetCursorPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Board rendering
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
static void _renderBoard({
|
||||||
|
required Console console,
|
||||||
|
required KanbanConfig config,
|
||||||
|
required Map<String, List<Ticket>> byColumn,
|
||||||
|
required int colIdx,
|
||||||
|
required int ticketIdx,
|
||||||
|
required String statusMsg,
|
||||||
|
required String searchQuery,
|
||||||
|
required bool searchMode,
|
||||||
|
required int w,
|
||||||
|
required int h,
|
||||||
|
}) {
|
||||||
|
_writeHeaderBar(console, config, searchQuery, searchMode, w);
|
||||||
|
console.writeLine(); // blank line after header
|
||||||
|
|
||||||
|
final numCols = config.columns.length;
|
||||||
|
// How many columns fit side-by-side?
|
||||||
|
final numVisible = max(1, min(numCols, (w + _colSep) ~/ (_minColW + _colSep)));
|
||||||
|
final colW = (w - (numVisible - 1) * _colSep) ~/ numVisible;
|
||||||
|
|
||||||
|
// Keep selected column in viewport
|
||||||
|
var viewStart = colIdx - numVisible ~/ 2;
|
||||||
|
viewStart = viewStart.clamp(0, max(0, numCols - numVisible));
|
||||||
|
if (colIdx < viewStart) viewStart = colIdx;
|
||||||
|
if (colIdx >= viewStart + numVisible) viewStart = colIdx - numVisible + 1;
|
||||||
|
viewStart = viewStart.clamp(0, max(0, numCols - numVisible));
|
||||||
|
|
||||||
|
final viewCols = config.columns.sublist(viewStart, min(viewStart + numVisible, numCols));
|
||||||
|
|
||||||
|
// h - 2 (header+blank) - 2 (footer separator+help) = content height
|
||||||
|
final colAreaH = h - 4;
|
||||||
|
|
||||||
|
// Build column cell buffers (one list per column, each list has colAreaH cells)
|
||||||
|
final colCells = <List<_Cell>>[];
|
||||||
|
for (int i = 0; i < viewCols.length; i++) {
|
||||||
|
final col = viewCols[i];
|
||||||
|
final isSelected = (viewStart + i) == colIdx;
|
||||||
|
final tickets = _filtered(byColumn[col.id] ?? [], searchQuery);
|
||||||
|
colCells.add(
|
||||||
|
_buildColumnCells(
|
||||||
|
col: col,
|
||||||
|
tickets: tickets,
|
||||||
|
colW: colW,
|
||||||
|
areaH: colAreaH,
|
||||||
|
isSelected: isSelected,
|
||||||
|
selectedIdx: isSelected ? ticketIdx : -1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render side-by-side, row by row
|
||||||
|
for (int row = 0; row < colAreaH; row++) {
|
||||||
|
for (int ci = 0; ci < colCells.length; ci++) {
|
||||||
|
if (ci > 0) console.write(' ' * _colSep);
|
||||||
|
final cell = colCells[ci][row];
|
||||||
|
_applyCell(console, cell);
|
||||||
|
console.resetColorAttributes();
|
||||||
|
}
|
||||||
|
if (row < colAreaH - 1) {
|
||||||
|
console.writeLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_writeFooterBar(
|
||||||
|
console: console,
|
||||||
|
statusMsg: statusMsg,
|
||||||
|
searchMode: searchMode,
|
||||||
|
searchQuery: searchQuery,
|
||||||
|
w: w,
|
||||||
|
colIdx: colIdx,
|
||||||
|
numCols: numCols,
|
||||||
|
numVisible: numVisible,
|
||||||
|
columns: config.columns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<_Cell> _buildColumnCells({
|
||||||
|
required ColumnConfig col,
|
||||||
|
required List<Ticket> tickets,
|
||||||
|
required int colW,
|
||||||
|
required int areaH,
|
||||||
|
required bool isSelected,
|
||||||
|
required int selectedIdx,
|
||||||
|
}) {
|
||||||
|
final cells = <_Cell>[];
|
||||||
|
final color = _colColor(col.color);
|
||||||
|
final innerW = colW - 2;
|
||||||
|
|
||||||
|
// ── Column header (3 rows) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Top border
|
||||||
|
cells.add(_Cell(
|
||||||
|
'┌${'─' * innerW}┐',
|
||||||
|
fg: isSelected ? color : ConsoleColor.brightBlack,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Name + count
|
||||||
|
final count = tickets.length;
|
||||||
|
final label = _trunc(' ${col.name.toUpperCase()} ($count) ', innerW);
|
||||||
|
cells.add(_Cell(
|
||||||
|
'│${label.padRight(innerW)}│',
|
||||||
|
fg: isSelected ? ConsoleColor.black : color,
|
||||||
|
bg: isSelected ? color : null,
|
||||||
|
bold: isSelected,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Separator (double line for selected, single for others)
|
||||||
|
cells.add(_Cell(
|
||||||
|
isSelected ? '╞${'═' * innerW}╡' : '└${'─' * innerW}┘',
|
||||||
|
fg: isSelected ? color : ConsoleColor.brightBlack,
|
||||||
|
));
|
||||||
|
|
||||||
|
// ── Ticket area ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final ticketAreaH = areaH - _colHeaderH - 1; // reserve 1 for bottom border
|
||||||
|
final maxVisible = max(1, (ticketAreaH - _indicatorRows) ~/ _ticketH);
|
||||||
|
|
||||||
|
// Compute scroll to keep selectedIdx in view
|
||||||
|
var scroll = 0;
|
||||||
|
if (isSelected && selectedIdx >= 0 && tickets.isNotEmpty) {
|
||||||
|
scroll = (selectedIdx - maxVisible + 1).clamp(0, max(0, tickets.length - maxVisible));
|
||||||
|
if (selectedIdx < scroll) scroll = selectedIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
final showAbove = scroll > 0;
|
||||||
|
final showBelow = tickets.isNotEmpty && (scroll + maxVisible) < tickets.length;
|
||||||
|
|
||||||
|
// "More above" indicator
|
||||||
|
if (showAbove) {
|
||||||
|
final msg = _trunc(' ↑ $scroll above', innerW);
|
||||||
|
cells.add(_Cell('│${msg.padRight(innerW)}│', fg: ConsoleColor.brightYellow));
|
||||||
|
} else {
|
||||||
|
cells.add(_Cell('│${' ' * innerW}│', fg: ConsoleColor.brightBlack));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visible tickets
|
||||||
|
final visEnd = min(scroll + maxVisible, tickets.length);
|
||||||
|
for (int ti = scroll; ti < visEnd; ti++) {
|
||||||
|
_addTicketCells(cells, tickets[ti], innerW, ti == selectedIdx && isSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (tickets.isEmpty) {
|
||||||
|
cells.add(_Cell('│${'·' * (innerW ~/ 2)}${' ' * (innerW - innerW ~/ 2)}│', fg: ConsoleColor.brightBlack));
|
||||||
|
cells.add(_Cell('│ (empty)${' ' * (innerW - 9)}│', fg: ConsoleColor.brightBlack));
|
||||||
|
cells.add(_Cell('│${'·' * (innerW ~/ 2)}${' ' * (innerW - innerW ~/ 2)}│', fg: ConsoleColor.brightBlack));
|
||||||
|
}
|
||||||
|
|
||||||
|
// "More below" indicator
|
||||||
|
if (showBelow) {
|
||||||
|
final remaining = tickets.length - scroll - maxVisible;
|
||||||
|
final msg = _trunc(' ↓ $remaining below', innerW);
|
||||||
|
cells.add(_Cell('│${msg.padRight(innerW)}│', fg: ConsoleColor.brightYellow));
|
||||||
|
} else {
|
||||||
|
cells.add(_Cell('│${' ' * innerW}│', fg: ConsoleColor.brightBlack));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill remaining space before bottom border
|
||||||
|
while (cells.length < areaH - 1) {
|
||||||
|
cells.add(_Cell('│${' ' * innerW}│', fg: ConsoleColor.brightBlack));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom border (always at areaH - 1)
|
||||||
|
if (cells.length > areaH - 1) cells.length = areaH - 1;
|
||||||
|
cells.add(_Cell(
|
||||||
|
isSelected ? '╘${'═' * innerW}╛' : '└${'─' * innerW}┘',
|
||||||
|
fg: isSelected ? color : ConsoleColor.brightBlack,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Pad to exact height
|
||||||
|
while (cells.length < areaH) {
|
||||||
|
cells.add(_Cell(' ' * colW));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _addTicketCells(List<_Cell> cells, Ticket ticket, int innerW, bool isSel) {
|
||||||
|
final bg = isSel ? ConsoleColor.blue : null;
|
||||||
|
final titleFg = isSel ? ConsoleColor.brightWhite : null;
|
||||||
|
final bullet = isSel ? '▶' : ' ';
|
||||||
|
final typeColor = _typeColor(ticket.type);
|
||||||
|
|
||||||
|
// Row 1: bullet + ID + type badge
|
||||||
|
final badge = ' [${_trunc(ticket.type, 7)}]';
|
||||||
|
final idLine = '$bullet ${ticket.id}$badge';
|
||||||
|
cells.add(_Cell(
|
||||||
|
'│${_trunc(idLine, innerW).padRight(innerW)}│',
|
||||||
|
fg: isSel ? ConsoleColor.brightWhite : typeColor,
|
||||||
|
bg: bg,
|
||||||
|
bold: isSel,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Row 2: title
|
||||||
|
final titleLine = ' ${_trunc(ticket.title, innerW - 2)}';
|
||||||
|
cells.add(_Cell(
|
||||||
|
'│${titleLine.padRight(innerW)}│',
|
||||||
|
fg: titleFg,
|
||||||
|
bg: bg,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Row 3: labels / milestone / blank
|
||||||
|
final String tagLine;
|
||||||
|
if (ticket.labels.isNotEmpty) {
|
||||||
|
tagLine = ' ${ticket.labels.take(3).map((l) => '#$l').join(' ')}';
|
||||||
|
} else if (ticket.milestones.isNotEmpty) {
|
||||||
|
tagLine = ' @ ${ticket.milestones.first}';
|
||||||
|
} else {
|
||||||
|
tagLine = '';
|
||||||
|
}
|
||||||
|
cells.add(_Cell(
|
||||||
|
'│${_trunc(tagLine, innerW).padRight(innerW)}│',
|
||||||
|
fg: isSel ? ConsoleColor.brightCyan : ConsoleColor.brightBlack,
|
||||||
|
bg: bg,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _writeHeaderBar(
|
||||||
|
Console console,
|
||||||
|
KanbanConfig config,
|
||||||
|
String searchQuery,
|
||||||
|
bool searchMode,
|
||||||
|
int w,
|
||||||
|
) {
|
||||||
|
console.setBackgroundColor(ConsoleColor.blue);
|
||||||
|
console.setForegroundColor(ConsoleColor.brightWhite);
|
||||||
|
console.setTextStyle(bold: true);
|
||||||
|
|
||||||
|
final left = ' DEW Kanban [${config.prefix}]';
|
||||||
|
final right = searchMode
|
||||||
|
? ' / ${searchQuery}_ '
|
||||||
|
: (searchQuery.isNotEmpty ? ' filter: "$searchQuery" ' : '');
|
||||||
|
final line = '$left${right.padLeft(max(0, w - left.length))}';
|
||||||
|
console.write(_trunc(line, w).padRight(w));
|
||||||
|
console.resetColorAttributes();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _writeFooterBar({
|
||||||
|
required Console console,
|
||||||
|
required String statusMsg,
|
||||||
|
required bool searchMode,
|
||||||
|
required String searchQuery,
|
||||||
|
required int w,
|
||||||
|
required int colIdx,
|
||||||
|
required int numCols,
|
||||||
|
required int numVisible,
|
||||||
|
required List<ColumnConfig> columns,
|
||||||
|
}) {
|
||||||
|
console.writeLine();
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
console.write('─' * w);
|
||||||
|
console.resetColorAttributes();
|
||||||
|
console.writeLine();
|
||||||
|
|
||||||
|
if (statusMsg.isNotEmpty) {
|
||||||
|
console.setForegroundColor(ConsoleColor.brightYellow);
|
||||||
|
console.write(' $statusMsg');
|
||||||
|
console.resetColorAttributes();
|
||||||
|
} else if (searchMode) {
|
||||||
|
console.setForegroundColor(ConsoleColor.brightCyan);
|
||||||
|
console.write(' Search: ${searchQuery}_ (Enter to apply, Esc to clear)');
|
||||||
|
console.resetColorAttributes();
|
||||||
|
} else {
|
||||||
|
// Column position indicator + help
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
final pos = numCols > numVisible ? ' [${colIdx + 1}/$numCols cols]' : '';
|
||||||
|
const help = ' [j/k] nav [h/l] col [</>] move [enter] detail [/] filter [r] reload [q] quit';
|
||||||
|
console.write(_trunc('$pos$help', w).padRight(w));
|
||||||
|
console.resetColorAttributes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Detail rendering
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
static void _renderDetail({
|
||||||
|
required Console console,
|
||||||
|
required Ticket ticket,
|
||||||
|
required KanbanConfig config,
|
||||||
|
required int scroll,
|
||||||
|
required int w,
|
||||||
|
required int h,
|
||||||
|
}) {
|
||||||
|
_writeHeaderBar(console, config, '', false, w);
|
||||||
|
console.writeLine();
|
||||||
|
|
||||||
|
// Ticket title bar
|
||||||
|
console.setBackgroundColor(ConsoleColor.blue);
|
||||||
|
console.setForegroundColor(ConsoleColor.brightWhite);
|
||||||
|
console.setTextStyle(bold: true);
|
||||||
|
final titleBar = ' ${ticket.id} ${_trunc(ticket.title, w - ticket.id.length - 6)}';
|
||||||
|
console.write(titleBar.padRight(w));
|
||||||
|
console.resetColorAttributes();
|
||||||
|
console.writeLine();
|
||||||
|
|
||||||
|
// Build scrollable content lines
|
||||||
|
final lines = _buildDetailLines(ticket, w - 4);
|
||||||
|
final contentH = h - 5; // header + blank + title + separator + footer
|
||||||
|
final maxScroll = max(0, lines.length - contentH);
|
||||||
|
final s = scroll.clamp(0, maxScroll);
|
||||||
|
|
||||||
|
final visible = lines.sublist(s, min(s + contentH, lines.length));
|
||||||
|
|
||||||
|
for (final line in visible) {
|
||||||
|
_applyCell(console, line);
|
||||||
|
console.resetColorAttributes();
|
||||||
|
console.writeLine();
|
||||||
|
}
|
||||||
|
for (int i = visible.length; i < contentH; i++) {
|
||||||
|
console.writeLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
console.write('─' * w);
|
||||||
|
console.resetColorAttributes();
|
||||||
|
console.writeLine();
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
final scrollInfo = lines.isNotEmpty
|
||||||
|
? ' [${s + 1}-${min(s + contentH, lines.length)}/${lines.length}]'
|
||||||
|
: '';
|
||||||
|
console.write(' [j/k↑↓] scroll$scrollInfo [b/Esc] back [q] quit'.padRight(w));
|
||||||
|
console.resetColorAttributes();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<_Cell> _buildDetailLines(Ticket ticket, int w) {
|
||||||
|
final lines = <_Cell>[];
|
||||||
|
|
||||||
|
void hr([String? label]) {
|
||||||
|
if (label != null) {
|
||||||
|
final tag = ' $label ';
|
||||||
|
final dashes = max(0, w - tag.length + 2);
|
||||||
|
lines.add(_Cell(' ─$tag${'─' * dashes}', fg: ConsoleColor.brightBlue, bold: true));
|
||||||
|
} else {
|
||||||
|
lines.add(_Cell(' ${'─' * w}', fg: ConsoleColor.brightBlack));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void kv(String key, String value) {
|
||||||
|
lines.add(
|
||||||
|
_Cell(
|
||||||
|
' ${key.padRight(12)}: ${_trunc(value, w - 16)}',
|
||||||
|
fg: ConsoleColor.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void blank() => lines.add(const _Cell(''));
|
||||||
|
|
||||||
|
// Metadata section
|
||||||
|
hr('INFO');
|
||||||
|
kv('Type', ticket.type);
|
||||||
|
kv('Column', ticket.column);
|
||||||
|
kv('Created', _fmtDate(ticket.created));
|
||||||
|
if (ticket.milestones.isNotEmpty) kv('Milestones', ticket.milestones.join(', '));
|
||||||
|
if (ticket.labels.isNotEmpty) kv('Labels', ticket.labels.map((l) => '#$l').join(' '));
|
||||||
|
if (ticket.links.isNotEmpty) {
|
||||||
|
for (final link in ticket.links) {
|
||||||
|
kv(link.type, link.targetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body
|
||||||
|
if (ticket.body.isNotEmpty) {
|
||||||
|
blank();
|
||||||
|
hr('BODY');
|
||||||
|
blank();
|
||||||
|
for (final para in ticket.body.split('\n')) {
|
||||||
|
for (final wl in _wordWrap(para, w)) {
|
||||||
|
lines.add(_Cell(' $wl', fg: ConsoleColor.white));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
for (int ci = 0; ci < ticket.comments.length; ci++) {
|
||||||
|
blank();
|
||||||
|
hr('COMMENT ${ci + 1}');
|
||||||
|
blank();
|
||||||
|
for (final para in ticket.comments[ci].split('\n')) {
|
||||||
|
for (final wl in _wordWrap(para, w)) {
|
||||||
|
lines.add(_Cell(' $wl', fg: ConsoleColor.white));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blank();
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Utilities
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
static void _applyCell(Console console, _Cell cell) {
|
||||||
|
if (cell.bg != null) console.setBackgroundColor(cell.bg!);
|
||||||
|
if (cell.fg != null) console.setForegroundColor(cell.fg!);
|
||||||
|
if (cell.bold) console.setTextStyle(bold: true);
|
||||||
|
console.write(cell.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, List<Ticket>> _groupByColumn(
|
||||||
|
List<Ticket> tickets,
|
||||||
|
KanbanConfig config,
|
||||||
|
) {
|
||||||
|
final map = <String, List<Ticket>>{};
|
||||||
|
for (final col in config.columns) {
|
||||||
|
map[col.id] = [];
|
||||||
|
}
|
||||||
|
for (final t in tickets) {
|
||||||
|
(map[t.column] ??= []).add(t);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Ticket> _filtered(List<Ticket> tickets, String query) {
|
||||||
|
if (query.isEmpty) return tickets;
|
||||||
|
final q = query.toLowerCase();
|
||||||
|
return tickets
|
||||||
|
.where(
|
||||||
|
(t) =>
|
||||||
|
t.id.toLowerCase().contains(q) ||
|
||||||
|
t.title.toLowerCase().contains(q) ||
|
||||||
|
t.type.toLowerCase().contains(q) ||
|
||||||
|
t.labels.any((l) => l.toLowerCase().contains(q)) ||
|
||||||
|
t.body.toLowerCase().contains(q),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ConsoleColor _colColor(String color) => switch (color.toLowerCase()) {
|
||||||
|
'red' => ConsoleColor.red,
|
||||||
|
'green' => ConsoleColor.green,
|
||||||
|
'yellow' => ConsoleColor.yellow,
|
||||||
|
'blue' => ConsoleColor.blue,
|
||||||
|
'magenta' || 'purple' => ConsoleColor.magenta,
|
||||||
|
'cyan' || 'teal' => ConsoleColor.cyan,
|
||||||
|
'white' => ConsoleColor.white,
|
||||||
|
'brightred' || 'bright_red' => ConsoleColor.brightRed,
|
||||||
|
'brightgreen' || 'bright_green' => ConsoleColor.brightGreen,
|
||||||
|
'brightyellow' || 'bright_yellow' => ConsoleColor.brightYellow,
|
||||||
|
'brightblue' || 'bright_blue' => ConsoleColor.brightBlue,
|
||||||
|
'brightmagenta' || 'bright_magenta' => ConsoleColor.brightMagenta,
|
||||||
|
'brightcyan' || 'bright_cyan' => ConsoleColor.brightCyan,
|
||||||
|
'brightwhite' || 'bright_white' => ConsoleColor.brightWhite,
|
||||||
|
_ => ConsoleColor.cyan,
|
||||||
|
};
|
||||||
|
|
||||||
|
static ConsoleColor _typeColor(String type) => switch (type.toLowerCase()) {
|
||||||
|
'bug' => ConsoleColor.red,
|
||||||
|
'task' => ConsoleColor.blue,
|
||||||
|
'feature' || 'feat' => ConsoleColor.green,
|
||||||
|
'chore' || 'spike' => ConsoleColor.yellow,
|
||||||
|
'epic' => ConsoleColor.magenta,
|
||||||
|
'story' => ConsoleColor.cyan,
|
||||||
|
_ => ConsoleColor.white,
|
||||||
|
};
|
||||||
|
|
||||||
|
static String _trunc(String s, int maxLen) {
|
||||||
|
if (maxLen <= 0) return '';
|
||||||
|
if (s.length <= maxLen) return s;
|
||||||
|
if (maxLen == 1) return s[0];
|
||||||
|
return '${s.substring(0, maxLen - 1)}…';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _fmtDate(DateTime dt) =>
|
||||||
|
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
static List<String> _wordWrap(String text, int width) {
|
||||||
|
if (text.isEmpty) return [''];
|
||||||
|
if (text.length <= width) return [text];
|
||||||
|
final words = text.split(' ');
|
||||||
|
final result = <String>[];
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (final word in words) {
|
||||||
|
if (buf.isNotEmpty && buf.length + 1 + word.length > width) {
|
||||||
|
result.add(buf.toString());
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
if (buf.isNotEmpty) buf.write(' ');
|
||||||
|
buf.write(word);
|
||||||
|
}
|
||||||
|
if (buf.isNotEmpty) result.add(buf.toString());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import 'commands/list_command.dart';
|
||||||
import 'commands/move_command.dart';
|
import 'commands/move_command.dart';
|
||||||
import 'commands/search_command.dart';
|
import 'commands/search_command.dart';
|
||||||
import 'commands/stats_command.dart';
|
import 'commands/stats_command.dart';
|
||||||
|
import 'commands/tui_command.dart';
|
||||||
import 'commands/unarchive_command.dart';
|
import 'commands/unarchive_command.dart';
|
||||||
import 'commands/unlink_command.dart';
|
import 'commands/unlink_command.dart';
|
||||||
import 'commands/update_command.dart';
|
import 'commands/update_command.dart';
|
||||||
|
|
@ -34,6 +35,7 @@ class KanbanCommand extends DewCommand {
|
||||||
addSubcommand(AddCommentCommand(fs: fs));
|
addSubcommand(AddCommentCommand(fs: fs));
|
||||||
addSubcommand(GetConfigCommand(fs: fs));
|
addSubcommand(GetConfigCommand(fs: fs));
|
||||||
addSubcommand(StatsCommand(fs: fs));
|
addSubcommand(StatsCommand(fs: fs));
|
||||||
|
addSubcommand(TuiCommand(fs: fs));
|
||||||
addSubcommand(LinkCommand(fs: fs));
|
addSubcommand(LinkCommand(fs: fs));
|
||||||
addSubcommand(UnlinkCommand(fs: fs));
|
addSubcommand(UnlinkCommand(fs: fs));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
dew_core:
|
dew_core:
|
||||||
path: ../core
|
path: ../core
|
||||||
|
dart_console: ^4.1.2
|
||||||
file: ^7.0.1
|
file: ^7.0.1
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
yaml: ^3.1.0
|
yaml: ^3.1.0
|
||||||
|
|
|
||||||
48
pubspec.lock
48
pubspec.lock
|
|
@ -49,6 +49,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
charcode:
|
charcode:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -89,6 +97,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.2"
|
version: "0.4.2"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -129,6 +145,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
dart_console:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_console
|
||||||
|
sha256: bf62b8016530fef83557c1f01867c281d0937dceb84204128819e6e925ddf73f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.4"
|
||||||
dart_mcp:
|
dart_mcp:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -137,6 +161,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.0"
|
version: "0.5.0"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
file:
|
file:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -193,6 +225,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
intl:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.18.1"
|
||||||
io:
|
io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -537,6 +577,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.2.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue