diff --git a/Cargo.toml b/Cargo.toml
index f9d78fa8..b4b49e98 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -56,7 +56,9 @@ serde_json = "1.0"
dirs = "5.0"
reqwest = { version = "0.11", features = ["json", "blocking", "multipart"] }
tokio = { version = "1.35", features = ["full"] }
-comfy-table = "7.0"
+# custom_styling: measure cell width with ANSI escapes stripped, so ANSI_COLOR
+# output lines up under --table-style (the default renderer already does this)
+comfy-table = { version = "7.0", features = ["custom_styling"] }
csv = "1.3"
ratatui = "0.29"
tui-input = { version = "0.11", features = ["crossterm"] }
diff --git a/scripts/lrt.ps1 b/scripts/lrt.ps1
new file mode 100644
index 00000000..d9c0b55b
--- /dev/null
+++ b/scripts/lrt.ps1
@@ -0,0 +1,70 @@
+# lrt - most recently modified files, an `ls -alrt` built on sql-cli.
+#
+# Load it from your PowerShell profile:
+# . C:\path\to\sql-cli\scripts\lrt.ps1
+#
+# Usage:
+# lrt # 20 newest files in the current directory
+# lrt 50 src -r # 50 newest under src, recursive
+# lrt -g '*.log' # only files matching a glob
+# lrt -d # include directories
+# lrt -style utf8 # any style from `sql-cli --list-table-styles`
+#
+# The default style is `plain` (no borders). It needs a sql-cli built with the
+# ANSI width fix (after v1.85.4); on older builds coloured cells misalign in
+# every style except `default`, so use `lrt -style default` there.
+function lrt {
+ param(
+ [int]$n = 20,
+ [string]$path = '.',
+ [switch]$r,
+ [string]$g,
+ [switch]$d,
+ [string]$style = 'plain'
+ )
+ $p = $path -replace "'", "''"
+ $opts = @()
+ if ($r) { $opts += 'RECURSIVE' }
+ if ($g) { $opts += "GLOB '$($g -replace "'", "''")'" }
+ $dirFilter = if ($d) { '' } else { 'AND is_dir = false' }
+ # recursive: show the path relative to the root, not the full \\?\C:\... path
+ $root = ('\\?\' + (Resolve-Path $path).ProviderPath.TrimEnd('\') + '\') -replace "'", "''"
+ $nameCol = if ($r) { "REPLACE(path, '$root', '')" } else { 'name' }
+ # The date functions work in UTC; shift to local time for display. This uses
+ # today's offset, so files from the other side of a DST change show an hour out.
+ $off = [int][TimeZoneInfo]::Local.GetUtcOffset((Get-Date)).TotalMinutes
+
+ $sql = @"
+WITH f AS (FILE PATH '$p' $($opts -join ' ')),
+g AS (
+ SELECT $nameCol AS name, ext, size, is_dir,
+ DATEADD('minute', $off, modified) AS lm,
+ DATEDIFF('second', modified, NOW()) AS secs
+ FROM f WHERE depth > 0 $dirFilter
+)
+SELECT
+ FORMAT_DATE(lm, '%Y-%m-%d') AS date,
+ FORMAT_DATE(lm, '%H:%M:%S') AS time,
+ CASE
+ WHEN secs < 60 THEN ANSI_COLOR('bright_green', secs || 's')
+ WHEN secs < 3600 THEN ANSI_COLOR('green', FLOOR(secs / 60) || 'm')
+ WHEN secs < 86400 THEN ANSI_COLOR('yellow', FLOOR(secs / 3600) || 'h')
+ WHEN secs < 604800 THEN FLOOR(secs / 86400) || 'd'
+ ELSE ANSI_COLOR('bright_black', FLOOR(secs / 86400) || 'd')
+ END AS age,
+ IIF(is_dir, '
', FORMAT_BYTES(size)) AS size,
+ CASE
+ WHEN is_dir THEN ANSI_COLOR('bright_blue', name)
+ WHEN ext IN ('rs', 'py', 'sql', 'ps1', 'cs', 'lua') THEN ANSI_COLOR('cyan', name)
+ WHEN ext IN ('md', 'txt') THEN ANSI_COLOR('magenta', name)
+ WHEN ext IN ('log', 'csv', 'json') THEN ANSI_COLOR('yellow', name)
+ WHEN ext IN ('exe', 'dll', 'zip') THEN ANSI_COLOR('red', name)
+ ELSE name
+ END AS name
+FROM g
+ORDER BY secs ASC
+LIMIT $n
+"@
+ # The "# Query completed" footer goes to stderr
+ sql-cli -q $sql -o table --table-style $style --max-col-width 0 2>$null
+}
diff --git a/tests/python_tests/test_table_output_alignment.py b/tests/python_tests/test_table_output_alignment.py
index 1ef08466..ee8e7e86 100644
--- a/tests/python_tests/test_table_output_alignment.py
+++ b/tests/python_tests/test_table_output_alignment.py
@@ -11,6 +11,7 @@
"""
import os
+import re
import subprocess
import sys
import unicodedata
@@ -26,8 +27,15 @@ def _sql_cli_binary():
return base_dir / "target" / "release" / f"sql-cli{suffix}"
+ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
+
+
def _display_width(text):
- """Column count of a string, matching the Rust side's unicode-width use."""
+ """Column count of a string, matching the Rust side's unicode-width use.
+
+ ANSI escapes (from ANSI_COLOR and friends) take no columns on a terminal.
+ """
+ text = ANSI_ESCAPE.sub("", text)
return sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in text)
@@ -147,6 +155,56 @@ def test_markdown_style_is_also_aligned(tmp_path):
assert_lines_aligned(lines)
+# Coloured cells: the default renderer always sized columns by display width, but
+# the comfy-table styles counted the escape bytes, so a cell like
+# ANSI_COLOR('yellow', '3h') was padded as if it were 11 columns wide.
+
+ALL_TABLE_STYLES = [
+ "default",
+ "ascii",
+ "ascii-condensed",
+ "ascii-borders",
+ "ascii-horizontal",
+ "ascii-noborders",
+ "markdown",
+ "utf8",
+ "utf8-condensed",
+ "utf8-borders",
+ "utf8-horizontal",
+ "utf8-noborders",
+ "plain",
+]
+
+COLOURED_QUERY = (
+ "SELECT ANSI_COLOR('yellow', Project) AS project, "
+ "ANSI_COLOR('green', DurationSecs) AS secs, DurationSecs AS raw "
+ "FROM projects"
+)
+
+
+@pytest.mark.parametrize("style", ALL_TABLE_STYLES)
+def test_coloured_cells_are_aligned_in_every_style(tmp_path, style):
+ lines = render_table(
+ PROJECTS_CSV,
+ COLOURED_QUERY,
+ extra_args=["--table-style", style, "--max-col-width", "0"],
+ tmp_path=tmp_path,
+ )
+ assert any("\x1b[" in line for line in lines), "expected ANSI colour in the output"
+ assert_lines_aligned(lines)
+
+
+@pytest.mark.parametrize("style", ["default", "ascii", "utf8-noborders", "plain"])
+def test_coloured_cells_stay_aligned_when_truncated(tmp_path, style):
+ lines = render_table(
+ PROJECTS_CSV,
+ COLOURED_QUERY,
+ extra_args=["--table-style", style, "--max-col-width", "12"],
+ tmp_path=tmp_path,
+ )
+ assert_lines_aligned(lines)
+
+
# Markdown is a --table-style, but it reads as an output format, so `-o markdown`
# used to be rejected outright. It is now shorthand for the table + markdown style.