quickerquery 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/dist/components/QueryResults.d.ts +7 -0
- package/dist/components/QueryResults.js +80 -0
- package/dist/components/ResultCell.d.ts +6 -0
- package/dist/components/ResultCell.js +25 -0
- package/dist/components/ResultFooter.d.ts +10 -0
- package/dist/components/ResultFooter.js +10 -0
- package/dist/components/ResultHeader.d.ts +6 -0
- package/dist/components/ResultHeader.js +12 -0
- package/dist/components/ResultRow.d.ts +8 -0
- package/dist/components/ResultRow.js +6 -0
- package/dist/components/ScrollIndicator.d.ts +7 -0
- package/dist/components/ScrollIndicator.js +19 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +6 -0
- package/dist/index.js +70 -16
- package/dist/testdata.d.ts +2 -0
- package/dist/testdata.js +266 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.js +131 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# QuickQuery
|
|
2
|
+
|
|
3
|
+
A terminal-based database query tool inspired by JetBrains DataGrip. Query databases directly from the terminal with syntax highlighting, result tables, and connection management—without leaving your workflow.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **PostgreSQL support** - Connect to PostgreSQL databases via JDBC URL
|
|
8
|
+
- **Syntax highlighting** - SQL editor with code highlighting in the terminal
|
|
9
|
+
- **Secure authentication** - Masked password input for secure credential entry
|
|
10
|
+
- **Keyboard-driven** - Fast, distraction-free interface built for developers
|
|
11
|
+
- **Lightweight** - Minimal dependencies, quick startup time
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Install globally via npm
|
|
17
|
+
npm install -g quickerquery
|
|
18
|
+
|
|
19
|
+
# Or with pnpm
|
|
20
|
+
pnpm add -g quickerquery
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
qq <database-url>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Example
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
qq jdbc:postgresql://localhost:5432/mydb
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The tool will prompt you for:
|
|
36
|
+
1. **Username** - Your database username
|
|
37
|
+
2. **Password** - Your database password (input is masked)
|
|
38
|
+
|
|
39
|
+
Once connected, you'll have a SQL editor where you can write and execute queries.
|
|
40
|
+
|
|
41
|
+
### Controls
|
|
42
|
+
|
|
43
|
+
- **Enter** - Execute query
|
|
44
|
+
- **Ctrl+C** - Exit the application
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
### Prerequisites
|
|
49
|
+
|
|
50
|
+
- Node.js 22+
|
|
51
|
+
- pnpm
|
|
52
|
+
|
|
53
|
+
### Setup
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Clone the repository
|
|
57
|
+
git clone https://github.com/your-username/quickquery.git
|
|
58
|
+
cd quickquery
|
|
59
|
+
|
|
60
|
+
# Install dependencies
|
|
61
|
+
pnpm install
|
|
62
|
+
|
|
63
|
+
# Run in development mode
|
|
64
|
+
pnpm dev
|
|
65
|
+
|
|
66
|
+
# Build for production
|
|
67
|
+
pnpm build
|
|
68
|
+
|
|
69
|
+
# Run the built version
|
|
70
|
+
pnpm start
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Scripts
|
|
74
|
+
|
|
75
|
+
| Command | Description |
|
|
76
|
+
|---------|-------------|
|
|
77
|
+
| `pnpm dev` | Run in development mode with hot reload |
|
|
78
|
+
| `pnpm build` | Compile TypeScript to JavaScript |
|
|
79
|
+
| `pnpm start` | Run the compiled application |
|
|
80
|
+
|
|
81
|
+
## Tech Stack
|
|
82
|
+
|
|
83
|
+
- **React** + **Ink** - Terminal UI framework
|
|
84
|
+
- **TypeScript** - Type-safe development
|
|
85
|
+
- **pg** - PostgreSQL client
|
|
86
|
+
- **ink-mini-code-editor** - Syntax-highlighted code editor for terminal
|
|
87
|
+
|
|
88
|
+
## Roadmap
|
|
89
|
+
|
|
90
|
+
- [ ] Query execution and result display
|
|
91
|
+
- [ ] Query history
|
|
92
|
+
- [ ] Multi-line query support
|
|
93
|
+
- [ ] Result pagination
|
|
94
|
+
- [ ] MySQL support
|
|
95
|
+
- [ ] SQLite support
|
|
96
|
+
- [ ] Connection profiles/saved connections
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useMemo } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
+
import { ResultHeader } from './ResultHeader.js';
|
|
5
|
+
import { ResultRow } from './ResultRow.js';
|
|
6
|
+
import { ResultFooter } from './ResultFooter.js';
|
|
7
|
+
import { ScrollIndicator } from './ScrollIndicator.js';
|
|
8
|
+
import { fitColumnsToWidth, calculateTableWidth } from '../types.js';
|
|
9
|
+
const DEFAULT_VISIBLE_ROWS = 15;
|
|
10
|
+
const PAGE_SIZE = 10;
|
|
11
|
+
export const QueryResults = ({ data, onBack }) => {
|
|
12
|
+
const { stdout } = useStdout();
|
|
13
|
+
const [view, setView] = useState({ selectedRow: 0, scrollOffset: 0 });
|
|
14
|
+
// Get terminal dimensions
|
|
15
|
+
const terminalHeight = stdout?.rows ?? 24;
|
|
16
|
+
const terminalWidth = stdout?.columns ?? 80;
|
|
17
|
+
// Calculate visible rows based on terminal height (reserve space for header/footer)
|
|
18
|
+
const visibleRows = Math.max(5, Math.min(DEFAULT_VISIBLE_ROWS, terminalHeight - 12));
|
|
19
|
+
// Fit columns to terminal width (memoized to avoid recalculating on every render)
|
|
20
|
+
const fittedColumns = useMemo(() => fitColumnsToWidth(data.columns, terminalWidth - 2), // -2 for scroll indicator
|
|
21
|
+
[data.columns, terminalWidth]);
|
|
22
|
+
// Check if terminal is too narrow to display anything useful
|
|
23
|
+
const minRequiredWidth = calculateTableWidth(data.columns.map((c) => ({ ...c, width: 3 })) // Minimum 3 chars per column
|
|
24
|
+
);
|
|
25
|
+
const isTooNarrow = terminalWidth < minRequiredWidth + 2;
|
|
26
|
+
const maxScroll = Math.max(0, data.rows.length - visibleRows);
|
|
27
|
+
const lastRow = data.rows.length - 1;
|
|
28
|
+
// Helper to update view state while keeping selection in viewport
|
|
29
|
+
const navigate = (newSelected) => {
|
|
30
|
+
setView((prev) => {
|
|
31
|
+
const selected = Math.max(0, Math.min(lastRow, newSelected));
|
|
32
|
+
let offset = prev.scrollOffset;
|
|
33
|
+
// Scroll up if selection moves above viewport
|
|
34
|
+
if (selected < offset) {
|
|
35
|
+
offset = selected;
|
|
36
|
+
}
|
|
37
|
+
// Scroll down if selection moves below viewport
|
|
38
|
+
else if (selected >= offset + visibleRows) {
|
|
39
|
+
offset = Math.min(maxScroll, selected - visibleRows + 1);
|
|
40
|
+
}
|
|
41
|
+
return { selectedRow: selected, scrollOffset: offset };
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
useInput((input, key) => {
|
|
45
|
+
if (input === 'q' || key.escape) {
|
|
46
|
+
onBack();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (key.upArrow) {
|
|
50
|
+
navigate(view.selectedRow - 1);
|
|
51
|
+
}
|
|
52
|
+
if (key.downArrow) {
|
|
53
|
+
navigate(view.selectedRow + 1);
|
|
54
|
+
}
|
|
55
|
+
if (key.pageUp) {
|
|
56
|
+
navigate(view.selectedRow - PAGE_SIZE);
|
|
57
|
+
}
|
|
58
|
+
if (key.pageDown) {
|
|
59
|
+
navigate(view.selectedRow + PAGE_SIZE);
|
|
60
|
+
}
|
|
61
|
+
// Home - go to first row
|
|
62
|
+
if (key.ctrl && input === 'a') {
|
|
63
|
+
navigate(0);
|
|
64
|
+
}
|
|
65
|
+
// End - go to last row
|
|
66
|
+
if (key.ctrl && input === 'e') {
|
|
67
|
+
navigate(lastRow);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
const { selectedRow, scrollOffset } = view;
|
|
71
|
+
const visibleData = data.rows.slice(scrollOffset, scrollOffset + visibleRows);
|
|
72
|
+
// Terminal too narrow to display table
|
|
73
|
+
if (isTooNarrow) {
|
|
74
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "yellow", children: "Terminal too narrow to display results." }), _jsxs(Text, { dimColor: true, children: ["Need at least ", minRequiredWidth + 2, " columns, have ", terminalWidth, "."] }), _jsx(Text, { dimColor: true, children: "Resize terminal or reduce number of columns in query." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press q to go back" }) })] }));
|
|
75
|
+
}
|
|
76
|
+
if (data.rows.length === 0) {
|
|
77
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "yellow", children: "Query executed successfully." }), _jsx(Text, { dimColor: true, children: "No rows returned." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press q to go back" }) })] }));
|
|
78
|
+
}
|
|
79
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ResultHeader, { columns: fittedColumns }), _jsxs(Box, { children: [_jsx(Box, { flexDirection: "column", children: visibleData.map((row, idx) => (_jsx(ResultRow, { row: row, columns: fittedColumns, isSelected: scrollOffset + idx === selectedRow }, scrollOffset + idx))) }), _jsx(ScrollIndicator, { currentRow: scrollOffset, visibleRows: visibleRows, totalRows: data.rows.length })] }), _jsx(ResultFooter, { columns: fittedColumns, rowCount: data.rowCount, executionTime: data.executionTime, viewStart: scrollOffset, viewEnd: scrollOffset + visibleRows })] }));
|
|
80
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { formatValue, getCellType, padCell } from '../types.js';
|
|
4
|
+
export const ResultCell = ({ value, width }) => {
|
|
5
|
+
const type = getCellType(value);
|
|
6
|
+
const formatted = formatValue(value);
|
|
7
|
+
const padded = padCell(formatted, width, type);
|
|
8
|
+
return (_jsx(Text, { color: getCellColor(type), dimColor: type === 'null', children: padded }));
|
|
9
|
+
};
|
|
10
|
+
function getCellColor(type) {
|
|
11
|
+
switch (type) {
|
|
12
|
+
case 'null':
|
|
13
|
+
return 'gray';
|
|
14
|
+
case 'number':
|
|
15
|
+
return 'cyan';
|
|
16
|
+
case 'boolean':
|
|
17
|
+
return 'yellow';
|
|
18
|
+
case 'date':
|
|
19
|
+
return 'magenta';
|
|
20
|
+
case 'json':
|
|
21
|
+
return 'green';
|
|
22
|
+
default:
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ColumnInfo } from '../types.js';
|
|
2
|
+
interface ResultFooterProps {
|
|
3
|
+
columns: ColumnInfo[];
|
|
4
|
+
rowCount: number;
|
|
5
|
+
executionTime: number;
|
|
6
|
+
viewStart: number;
|
|
7
|
+
viewEnd: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const ResultFooter: ({ columns, rowCount, executionTime, viewStart, viewEnd }: ResultFooterProps) => import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export const ResultFooter = ({ columns, rowCount, executionTime, viewStart, viewEnd }) => {
|
|
4
|
+
const bottomBorder = buildBorder(columns, '└', '┴', '┘');
|
|
5
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", children: ' ' + bottomBorder }), _jsxs(Box, { marginTop: 1, gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["Showing ", viewStart + 1, "-", Math.min(viewEnd, rowCount), " of ", rowCount, " rows"] }), _jsxs(Text, { dimColor: true, children: ["(", executionTime.toFixed(0), "ms)"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 scroll \u2022 PgUp/PgDn page \u2022 q back to query" }) })] }));
|
|
6
|
+
};
|
|
7
|
+
function buildBorder(columns, left, mid, right) {
|
|
8
|
+
const segments = columns.map((col) => '─'.repeat(col.width + 2));
|
|
9
|
+
return left + segments.join(mid) + right;
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { truncate } from '../types.js';
|
|
4
|
+
export const ResultHeader = ({ columns }) => {
|
|
5
|
+
const topBorder = buildBorder(columns, '┌', '┬', '┐');
|
|
6
|
+
const bottomBorder = buildBorder(columns, '├', '┼', '┤');
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", children: ' ' + topBorder }), _jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "gray", children: "\u2502" }), columns.map((col) => (_jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: "white", children: truncate(col.name, col.width).padEnd(col.width) }), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", children: "\u2502" })] }, col.name)))] }), _jsx(Text, { color: "gray", children: ' ' + bottomBorder })] }));
|
|
8
|
+
};
|
|
9
|
+
function buildBorder(columns, left, mid, right) {
|
|
10
|
+
const segments = columns.map((col) => '─'.repeat(col.width + 2));
|
|
11
|
+
return left + segments.join(mid) + right;
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ColumnInfo } from '../types.js';
|
|
2
|
+
interface ResultRowProps {
|
|
3
|
+
row: Record<string, unknown>;
|
|
4
|
+
columns: ColumnInfo[];
|
|
5
|
+
isSelected?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare const ResultRow: ({ row, columns, isSelected }: ResultRowProps) => import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { ResultCell } from './ResultCell.js';
|
|
4
|
+
export const ResultRow = ({ row, columns, isSelected = false }) => {
|
|
5
|
+
return (_jsxs(Box, { children: [_jsx(Text, { children: isSelected ? '▶' : ' ' }), _jsx(Text, { color: "gray", children: "\u2502" }), columns.map((col, idx) => (_jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsx(ResultCell, { value: row[col.name], width: col.width }), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", children: "\u2502" })] }, col.name)))] }));
|
|
6
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface ScrollIndicatorProps {
|
|
2
|
+
currentRow: number;
|
|
3
|
+
visibleRows: number;
|
|
4
|
+
totalRows: number;
|
|
5
|
+
}
|
|
6
|
+
export declare const ScrollIndicator: ({ currentRow, visibleRows, totalRows }: ScrollIndicatorProps) => import("react/jsx-runtime").JSX.Element | null;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export const ScrollIndicator = ({ currentRow, visibleRows, totalRows }) => {
|
|
4
|
+
if (totalRows <= visibleRows) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
const scrollbarHeight = Math.max(1, Math.floor((visibleRows / totalRows) * visibleRows));
|
|
8
|
+
const scrollPosition = Math.floor((currentRow / (totalRows - visibleRows)) * (visibleRows - scrollbarHeight));
|
|
9
|
+
const lines = [];
|
|
10
|
+
for (let i = 0; i < visibleRows; i++) {
|
|
11
|
+
if (i >= scrollPosition && i < scrollPosition + scrollbarHeight) {
|
|
12
|
+
lines.push('█');
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
lines.push('░');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return (_jsx(Box, { flexDirection: "column", marginLeft: 1, children: lines.map((char, idx) => (_jsx(Text, { color: "gray", children: char }, idx))) }));
|
|
19
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { QueryResults } from './QueryResults.js';
|
|
2
|
+
export { ResultHeader } from './ResultHeader.js';
|
|
3
|
+
export { ResultRow } from './ResultRow.js';
|
|
4
|
+
export { ResultCell } from './ResultCell.js';
|
|
5
|
+
export { ResultFooter } from './ResultFooter.js';
|
|
6
|
+
export { ScrollIndicator } from './ScrollIndicator.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { QueryResults } from './QueryResults.js';
|
|
2
|
+
export { ResultHeader } from './ResultHeader.js';
|
|
3
|
+
export { ResultRow } from './ResultRow.js';
|
|
4
|
+
export { ResultCell } from './ResultCell.js';
|
|
5
|
+
export { ResultFooter } from './ResultFooter.js';
|
|
6
|
+
export { ScrollIndicator } from './ScrollIndicator.js';
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,9 @@ import { useState, useEffect } from 'react';
|
|
|
4
4
|
import { render, Box, Text, useApp, useInput } from 'ink';
|
|
5
5
|
import TextInput from 'ink-mini-code-editor';
|
|
6
6
|
import pg from 'pg';
|
|
7
|
+
import { QueryResults } from './components/index.js';
|
|
8
|
+
import { parseQueryResult } from './types.js';
|
|
9
|
+
import { TEST_QUERY_RESULT } from './testdata.js';
|
|
7
10
|
function parseJdbcUrl(url) {
|
|
8
11
|
// Parse jdbc:postgresql://host:port/database
|
|
9
12
|
const match = url.match(/^jdbc:postgresql:\/\/([^:]+):(\d+)\/(.+)$/);
|
|
@@ -24,6 +27,8 @@ const App = ({ config }) => {
|
|
|
24
27
|
const [error, setError] = useState('');
|
|
25
28
|
const [client, setClient] = useState(null);
|
|
26
29
|
const [query, setQuery] = useState('SELECT 1');
|
|
30
|
+
const [results, setResults] = useState(null);
|
|
31
|
+
const [queryError, setQueryError] = useState('');
|
|
27
32
|
useInput((input, key) => {
|
|
28
33
|
if (key.ctrl && input === 'c') {
|
|
29
34
|
if (client) {
|
|
@@ -65,8 +70,26 @@ const App = ({ config }) => {
|
|
|
65
70
|
};
|
|
66
71
|
}, [state, config, username, password]);
|
|
67
72
|
const handleQuerySubmit = async () => {
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
if (!client || !query.trim())
|
|
74
|
+
return;
|
|
75
|
+
setQueryError('');
|
|
76
|
+
setState('executing');
|
|
77
|
+
const startTime = performance.now();
|
|
78
|
+
try {
|
|
79
|
+
const result = await client.query(query);
|
|
80
|
+
const executionTime = performance.now() - startTime;
|
|
81
|
+
const parsed = parseQueryResult(result, executionTime);
|
|
82
|
+
setResults(parsed);
|
|
83
|
+
setState('results');
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
setQueryError(err.message);
|
|
87
|
+
setState('connected');
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const handleBackToQuery = () => {
|
|
91
|
+
setResults(null);
|
|
92
|
+
setState('connected');
|
|
70
93
|
};
|
|
71
94
|
// Error state
|
|
72
95
|
if (state === 'error') {
|
|
@@ -76,27 +99,58 @@ const App = ({ config }) => {
|
|
|
76
99
|
if (state === 'connecting') {
|
|
77
100
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, children: "QuickQuery" }), _jsxs(Text, { dimColor: true, children: [config.host, ":", config.port, "/", config.database] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: "Connecting..." }) })] }));
|
|
78
101
|
}
|
|
102
|
+
// Executing query state
|
|
103
|
+
if (state === 'executing') {
|
|
104
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "Connected" }), _jsxs(Text, { dimColor: true, children: [" ", username, "@", config.host, ":", config.port, "/", config.database] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: "Executing query..." }) })] }));
|
|
105
|
+
}
|
|
106
|
+
// Results state
|
|
107
|
+
if (state === 'results' && results) {
|
|
108
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "green", children: "Connected" }), _jsxs(Text, { dimColor: true, children: [" ", username, "@", config.host, ":", config.port, "/", config.database] })] }), _jsx(QueryResults, { data: results, onBack: handleBackToQuery })] }));
|
|
109
|
+
}
|
|
79
110
|
// Connected - show query editor
|
|
80
111
|
if (state === 'connected') {
|
|
81
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "Connected" }), _jsxs(Text, { dimColor: true, children: [" ", username, "@", config.host, ":", config.port, "/", config.database] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Enter SQL query (press Enter to execute):" }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "> " }), _jsx(TextInput, { value: query, onChange: setQuery, onSubmit: handleQuerySubmit, language: "sql", placeholder: "SELECT * FROM ..." })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Ctrl+C to exit" }) })] }));
|
|
112
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "Connected" }), _jsxs(Text, { dimColor: true, children: [" ", username, "@", config.host, ":", config.port, "/", config.database] })] }), queryError && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "red", children: "Query Error" }), _jsx(Text, { color: "red", children: queryError })] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Enter SQL query (press Enter to execute):" }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "> " }), _jsx(TextInput, { value: query, onChange: setQuery, onSubmit: handleQuerySubmit, language: "sql", placeholder: "SELECT * FROM ..." })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Ctrl+C to exit" }) })] }));
|
|
82
113
|
}
|
|
83
114
|
// Login prompts
|
|
84
115
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, children: "QuickQuery" }), _jsxs(Text, { dimColor: true, children: [config.host, ":", config.port, "/", config.database] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { children: "Username: " }), state === 'username' ? (_jsx(TextInput, { value: username, onChange: setUsername, onSubmit: handleUsernameSubmit, placeholder: "Enter username" })) : (_jsx(Text, { children: username }))] }), state === 'password' && (_jsxs(Box, { children: [_jsx(Text, { children: "Password: " }), _jsx(TextInput, { value: password, onChange: setPassword, onSubmit: handlePasswordSubmit, placeholder: "Enter password", mask: "*" })] }))] })] }));
|
|
85
116
|
};
|
|
117
|
+
// Test mode component
|
|
118
|
+
const TestApp = () => {
|
|
119
|
+
const { exit } = useApp();
|
|
120
|
+
const handleBack = () => {
|
|
121
|
+
exit();
|
|
122
|
+
};
|
|
123
|
+
useInput((input, key) => {
|
|
124
|
+
if (key.ctrl && input === 'c') {
|
|
125
|
+
exit();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "Test Mode" }), _jsx(Text, { dimColor: true, children: " - Displaying sample data" })] }), _jsx(QueryResults, { data: TEST_QUERY_RESULT, onBack: handleBack })] }));
|
|
129
|
+
};
|
|
86
130
|
// Parse CLI arguments
|
|
87
131
|
const args = process.argv.slice(2);
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
console.error('Example: qq jdbc:postgresql://localhost:5432/postgres');
|
|
92
|
-
process.exit(1);
|
|
132
|
+
// Check for --test-table flag
|
|
133
|
+
if (args.includes('--test-table')) {
|
|
134
|
+
render(_jsx(TestApp, {}));
|
|
93
135
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
136
|
+
else {
|
|
137
|
+
const databaseUrl = args[0];
|
|
138
|
+
if (!databaseUrl) {
|
|
139
|
+
console.error('Usage: qq <database-url>');
|
|
140
|
+
console.error(' qq --test-table');
|
|
141
|
+
console.error('');
|
|
142
|
+
console.error('Examples:');
|
|
143
|
+
console.error(' qq jdbc:postgresql://localhost:5432/postgres');
|
|
144
|
+
console.error(' qq --test-table # Test table display with sample data');
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
let config;
|
|
148
|
+
try {
|
|
149
|
+
config = parseJdbcUrl(databaseUrl);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.error(err.message);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
render(_jsx(App, { config: config }));
|
|
101
156
|
}
|
|
102
|
-
render(_jsx(App, { config: config }));
|
package/dist/testdata.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
export const TEST_QUERY_RESULT = {
|
|
2
|
+
columns: [
|
|
3
|
+
{ name: 'id', dataTypeID: 23, width: 4 },
|
|
4
|
+
{ name: 'name', dataTypeID: 25, width: 20 },
|
|
5
|
+
{ name: 'email', dataTypeID: 25, width: 30 },
|
|
6
|
+
{ name: 'age', dataTypeID: 23, width: 4 },
|
|
7
|
+
{ name: 'active', dataTypeID: 16, width: 6 },
|
|
8
|
+
{ name: 'balance', dataTypeID: 1700, width: 10 },
|
|
9
|
+
{ name: 'created_at', dataTypeID: 1184, width: 24 },
|
|
10
|
+
{ name: 'metadata', dataTypeID: 114, width: 20 },
|
|
11
|
+
],
|
|
12
|
+
rows: [
|
|
13
|
+
{
|
|
14
|
+
id: 1,
|
|
15
|
+
name: 'Alice Johnson',
|
|
16
|
+
email: 'alice@example.com',
|
|
17
|
+
age: 28,
|
|
18
|
+
active: true,
|
|
19
|
+
balance: 1523.45,
|
|
20
|
+
created_at: new Date('2024-01-15T10:30:00Z'),
|
|
21
|
+
metadata: { role: 'admin', tier: 'gold' },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 2,
|
|
25
|
+
name: 'Bob Smith',
|
|
26
|
+
email: 'bob.smith@company.org',
|
|
27
|
+
age: 35,
|
|
28
|
+
active: true,
|
|
29
|
+
balance: 892.0,
|
|
30
|
+
created_at: new Date('2024-02-20T14:45:00Z'),
|
|
31
|
+
metadata: { role: 'user', tier: 'silver' },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 3,
|
|
35
|
+
name: 'Charlie Brown',
|
|
36
|
+
email: 'charlie.b@email.net',
|
|
37
|
+
age: 42,
|
|
38
|
+
active: false,
|
|
39
|
+
balance: 0,
|
|
40
|
+
created_at: new Date('2023-11-05T09:15:00Z'),
|
|
41
|
+
metadata: null,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 4,
|
|
45
|
+
name: 'Diana Prince',
|
|
46
|
+
email: 'diana@wonderwoman.io',
|
|
47
|
+
age: 31,
|
|
48
|
+
active: true,
|
|
49
|
+
balance: 15000.99,
|
|
50
|
+
created_at: new Date('2024-03-01T00:00:00Z'),
|
|
51
|
+
metadata: { role: 'superuser', tier: 'platinum' },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 5,
|
|
55
|
+
name: 'Edward Norton',
|
|
56
|
+
email: null,
|
|
57
|
+
age: 55,
|
|
58
|
+
active: false,
|
|
59
|
+
balance: 234.56,
|
|
60
|
+
created_at: new Date('2022-06-10T16:20:00Z'),
|
|
61
|
+
metadata: { role: 'user' },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 6,
|
|
65
|
+
name: 'Fiona Green',
|
|
66
|
+
email: 'fiona.green@startup.co',
|
|
67
|
+
age: 24,
|
|
68
|
+
active: true,
|
|
69
|
+
balance: 5678.9,
|
|
70
|
+
created_at: new Date('2024-04-12T11:00:00Z'),
|
|
71
|
+
metadata: { role: 'user', tier: 'gold' },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 7,
|
|
75
|
+
name: 'George Washington',
|
|
76
|
+
email: 'george@presidents.gov',
|
|
77
|
+
age: null,
|
|
78
|
+
active: true,
|
|
79
|
+
balance: 1776.0,
|
|
80
|
+
created_at: new Date('1776-07-04T12:00:00Z'),
|
|
81
|
+
metadata: { role: 'founder' },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 8,
|
|
85
|
+
name: 'Hannah Montana',
|
|
86
|
+
email: 'hannah@disney.com',
|
|
87
|
+
age: 19,
|
|
88
|
+
active: false,
|
|
89
|
+
balance: null,
|
|
90
|
+
created_at: new Date('2024-01-01T00:00:00Z'),
|
|
91
|
+
metadata: null,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 9,
|
|
95
|
+
name: 'Ivan Drago',
|
|
96
|
+
email: 'ivan@boxing.ru',
|
|
97
|
+
age: 38,
|
|
98
|
+
active: true,
|
|
99
|
+
balance: 999999.99,
|
|
100
|
+
created_at: new Date('2023-12-25T18:30:00Z'),
|
|
101
|
+
metadata: { role: 'champion', wins: 50 },
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 10,
|
|
105
|
+
name: 'Julia Roberts',
|
|
106
|
+
email: 'julia@hollywood.com',
|
|
107
|
+
age: 56,
|
|
108
|
+
active: true,
|
|
109
|
+
balance: 50000.0,
|
|
110
|
+
created_at: new Date('2020-08-15T20:00:00Z'),
|
|
111
|
+
metadata: { role: 'vip', tier: 'diamond' },
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 11,
|
|
115
|
+
name: 'Kevin Hart',
|
|
116
|
+
email: 'kevin@comedy.com',
|
|
117
|
+
age: 44,
|
|
118
|
+
active: true,
|
|
119
|
+
balance: 75000.5,
|
|
120
|
+
created_at: new Date('2021-03-20T10:00:00Z'),
|
|
121
|
+
metadata: { role: 'entertainer' },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 12,
|
|
125
|
+
name: 'Lisa Simpson',
|
|
126
|
+
email: 'lisa@springfield.edu',
|
|
127
|
+
age: 8,
|
|
128
|
+
active: true,
|
|
129
|
+
balance: 12.34,
|
|
130
|
+
created_at: new Date('2024-05-01T08:00:00Z'),
|
|
131
|
+
metadata: { role: 'student', grade: 'A+' },
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 13,
|
|
135
|
+
name: 'Michael Scott',
|
|
136
|
+
email: 'michael@dundermifflin.com',
|
|
137
|
+
age: 49,
|
|
138
|
+
active: false,
|
|
139
|
+
balance: 100.0,
|
|
140
|
+
created_at: new Date('2023-09-15T09:00:00Z'),
|
|
141
|
+
metadata: { role: 'manager', branch: 'Scranton' },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 14,
|
|
145
|
+
name: 'Nancy Drew',
|
|
146
|
+
email: 'nancy@detective.org',
|
|
147
|
+
age: 18,
|
|
148
|
+
active: true,
|
|
149
|
+
balance: 567.89,
|
|
150
|
+
created_at: new Date('2024-06-01T14:00:00Z'),
|
|
151
|
+
metadata: { role: 'investigator' },
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 15,
|
|
155
|
+
name: 'Oscar Martinez',
|
|
156
|
+
email: 'oscar@accounting.com',
|
|
157
|
+
age: 41,
|
|
158
|
+
active: true,
|
|
159
|
+
balance: 8901.23,
|
|
160
|
+
created_at: new Date('2022-11-30T17:00:00Z'),
|
|
161
|
+
metadata: { role: 'accountant', certified: true },
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 16,
|
|
165
|
+
name: 'Pam Beesly',
|
|
166
|
+
email: 'pam@art.com',
|
|
167
|
+
age: 36,
|
|
168
|
+
active: true,
|
|
169
|
+
balance: 456.78,
|
|
170
|
+
created_at: new Date('2023-04-10T12:30:00Z'),
|
|
171
|
+
metadata: { role: 'artist' },
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 17,
|
|
175
|
+
name: 'Quincy Jones',
|
|
176
|
+
email: 'quincy@music.com',
|
|
177
|
+
age: 91,
|
|
178
|
+
active: true,
|
|
179
|
+
balance: 1000000.0,
|
|
180
|
+
created_at: new Date('2019-01-01T00:00:00Z'),
|
|
181
|
+
metadata: { role: 'legend', grammys: 28 },
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: 18,
|
|
185
|
+
name: 'Rachel Green',
|
|
186
|
+
email: 'rachel@fashion.nyc',
|
|
187
|
+
age: 34,
|
|
188
|
+
active: false,
|
|
189
|
+
balance: 2345.67,
|
|
190
|
+
created_at: new Date('2024-02-14T19:00:00Z'),
|
|
191
|
+
metadata: { role: 'buyer' },
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: 19,
|
|
195
|
+
name: 'Steve Rogers',
|
|
196
|
+
email: 'cap@avengers.org',
|
|
197
|
+
age: 105,
|
|
198
|
+
active: true,
|
|
199
|
+
balance: 1945.0,
|
|
200
|
+
created_at: new Date('1945-05-08T15:00:00Z'),
|
|
201
|
+
metadata: { role: 'hero', shield: true },
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: 20,
|
|
205
|
+
name: 'Tina Turner',
|
|
206
|
+
email: null,
|
|
207
|
+
age: null,
|
|
208
|
+
active: false,
|
|
209
|
+
balance: null,
|
|
210
|
+
created_at: new Date('2023-05-24T00:00:00Z'),
|
|
211
|
+
metadata: null,
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
id: 21,
|
|
215
|
+
name: 'Uma Thurman',
|
|
216
|
+
email: 'uma@killbill.com',
|
|
217
|
+
age: 54,
|
|
218
|
+
active: true,
|
|
219
|
+
balance: 30000.0,
|
|
220
|
+
created_at: new Date('2024-04-01T10:00:00Z'),
|
|
221
|
+
metadata: { role: 'actress' },
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: 22,
|
|
225
|
+
name: 'Vin Diesel',
|
|
226
|
+
email: 'vin@fastandfurious.com',
|
|
227
|
+
age: 57,
|
|
228
|
+
active: true,
|
|
229
|
+
balance: 200000000.0,
|
|
230
|
+
created_at: new Date('2001-06-22T00:00:00Z'),
|
|
231
|
+
metadata: { role: 'family', cars: 100 },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: 23,
|
|
235
|
+
name: 'Wanda Maximoff',
|
|
236
|
+
email: 'wanda@westview.com',
|
|
237
|
+
age: 29,
|
|
238
|
+
active: true,
|
|
239
|
+
balance: null,
|
|
240
|
+
created_at: new Date('2024-03-15T21:00:00Z'),
|
|
241
|
+
metadata: { role: 'witch', power: 'chaos magic' },
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 24,
|
|
245
|
+
name: 'Xavier Charles',
|
|
246
|
+
email: 'professor@xmen.edu',
|
|
247
|
+
age: 67,
|
|
248
|
+
active: true,
|
|
249
|
+
balance: 5000000.0,
|
|
250
|
+
created_at: new Date('2000-07-14T00:00:00Z'),
|
|
251
|
+
metadata: { role: 'telepath', school: 'gifted' },
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: 25,
|
|
255
|
+
name: 'Yoda',
|
|
256
|
+
email: null,
|
|
257
|
+
age: 900,
|
|
258
|
+
active: false,
|
|
259
|
+
balance: 0,
|
|
260
|
+
created_at: new Date('1980-05-21T00:00:00Z'),
|
|
261
|
+
metadata: { role: 'master', wisdom: 'infinite' },
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
rowCount: 25,
|
|
265
|
+
executionTime: 42.5,
|
|
266
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { QueryResult } from 'pg';
|
|
2
|
+
export interface ColumnInfo {
|
|
3
|
+
name: string;
|
|
4
|
+
dataTypeID: number;
|
|
5
|
+
width: number;
|
|
6
|
+
}
|
|
7
|
+
export interface QueryResultData {
|
|
8
|
+
columns: ColumnInfo[];
|
|
9
|
+
rows: Record<string, unknown>[];
|
|
10
|
+
rowCount: number;
|
|
11
|
+
executionTime: number;
|
|
12
|
+
}
|
|
13
|
+
export interface CellValue {
|
|
14
|
+
value: unknown;
|
|
15
|
+
type: 'string' | 'number' | 'boolean' | 'null' | 'date' | 'json' | 'unknown';
|
|
16
|
+
}
|
|
17
|
+
export declare function parseQueryResult(result: QueryResult, executionTime: number): QueryResultData;
|
|
18
|
+
export declare function formatValue(value: unknown): string;
|
|
19
|
+
export declare function getCellType(value: unknown): CellValue['type'];
|
|
20
|
+
export declare function truncate(str: string, maxLength: number): string;
|
|
21
|
+
export declare function padCell(value: string, width: number, type: CellValue['type']): string;
|
|
22
|
+
/**
|
|
23
|
+
* Calculate the total width a table would take given columns.
|
|
24
|
+
* Each column takes: 1 space + content + 1 space + 1 border (│)
|
|
25
|
+
* Plus: 1 selection indicator + 1 initial border
|
|
26
|
+
*/
|
|
27
|
+
export declare function calculateTableWidth(columns: ColumnInfo[]): number;
|
|
28
|
+
/**
|
|
29
|
+
* Fit columns to available terminal width by proportionally shrinking if needed.
|
|
30
|
+
* Returns new column array with adjusted widths.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fitColumnsToWidth(columns: ColumnInfo[], terminalWidth: number): ColumnInfo[];
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export function parseQueryResult(result, executionTime) {
|
|
2
|
+
const columns = result.fields.map((field) => ({
|
|
3
|
+
name: field.name,
|
|
4
|
+
dataTypeID: field.dataTypeID,
|
|
5
|
+
width: field.name.length,
|
|
6
|
+
}));
|
|
7
|
+
// Calculate column widths based on data
|
|
8
|
+
for (const row of result.rows) {
|
|
9
|
+
for (let i = 0; i < columns.length; i++) {
|
|
10
|
+
const col = columns[i];
|
|
11
|
+
const value = row[col.name];
|
|
12
|
+
const strValue = formatValue(value);
|
|
13
|
+
col.width = Math.max(col.width, strValue.length);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Cap column widths at a reasonable maximum
|
|
17
|
+
const MAX_COL_WIDTH = 40;
|
|
18
|
+
for (const col of columns) {
|
|
19
|
+
col.width = Math.min(col.width, MAX_COL_WIDTH);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
columns,
|
|
23
|
+
rows: result.rows,
|
|
24
|
+
rowCount: result.rowCount ?? result.rows.length,
|
|
25
|
+
executionTime,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function formatValue(value) {
|
|
29
|
+
if (value === null) {
|
|
30
|
+
return 'NULL';
|
|
31
|
+
}
|
|
32
|
+
if (value === undefined) {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === 'boolean') {
|
|
36
|
+
return value ? 'true' : 'false';
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'number') {
|
|
39
|
+
return String(value);
|
|
40
|
+
}
|
|
41
|
+
if (value instanceof Date) {
|
|
42
|
+
return value.toISOString();
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === 'object') {
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
}
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
export function getCellType(value) {
|
|
50
|
+
if (value === null)
|
|
51
|
+
return 'null';
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
return 'null';
|
|
54
|
+
if (typeof value === 'boolean')
|
|
55
|
+
return 'boolean';
|
|
56
|
+
if (typeof value === 'number')
|
|
57
|
+
return 'number';
|
|
58
|
+
if (value instanceof Date)
|
|
59
|
+
return 'date';
|
|
60
|
+
if (typeof value === 'object')
|
|
61
|
+
return 'json';
|
|
62
|
+
if (typeof value === 'string')
|
|
63
|
+
return 'string';
|
|
64
|
+
return 'unknown';
|
|
65
|
+
}
|
|
66
|
+
export function truncate(str, maxLength) {
|
|
67
|
+
if (str.length <= maxLength) {
|
|
68
|
+
return str;
|
|
69
|
+
}
|
|
70
|
+
return str.slice(0, maxLength - 1) + '…';
|
|
71
|
+
}
|
|
72
|
+
export function padCell(value, width, type) {
|
|
73
|
+
const truncated = truncate(value, width);
|
|
74
|
+
// Right-align numbers, left-align everything else
|
|
75
|
+
if (type === 'number') {
|
|
76
|
+
return truncated.padStart(width);
|
|
77
|
+
}
|
|
78
|
+
return truncated.padEnd(width);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Calculate the total width a table would take given columns.
|
|
82
|
+
* Each column takes: 1 space + content + 1 space + 1 border (│)
|
|
83
|
+
* Plus: 1 selection indicator + 1 initial border
|
|
84
|
+
*/
|
|
85
|
+
export function calculateTableWidth(columns) {
|
|
86
|
+
// Selection indicator (1) + initial border (1) + each column (width + 3 for " content │")
|
|
87
|
+
return 2 + columns.reduce((sum, col) => sum + col.width + 3, 0);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Fit columns to available terminal width by proportionally shrinking if needed.
|
|
91
|
+
* Returns new column array with adjusted widths.
|
|
92
|
+
*/
|
|
93
|
+
export function fitColumnsToWidth(columns, terminalWidth) {
|
|
94
|
+
const MIN_COL_WIDTH = 3; // Minimum readable width
|
|
95
|
+
const FIXED_OVERHEAD = 2; // Selection indicator + initial border
|
|
96
|
+
const PER_COL_OVERHEAD = 3; // " " + content + " │"
|
|
97
|
+
// Available width for actual content
|
|
98
|
+
const totalOverhead = FIXED_OVERHEAD + columns.length * PER_COL_OVERHEAD;
|
|
99
|
+
const availableContentWidth = terminalWidth - totalOverhead;
|
|
100
|
+
// Current total content width
|
|
101
|
+
const currentContentWidth = columns.reduce((sum, col) => sum + col.width, 0);
|
|
102
|
+
// If it fits, return as-is
|
|
103
|
+
if (currentContentWidth <= availableContentWidth) {
|
|
104
|
+
return columns;
|
|
105
|
+
}
|
|
106
|
+
// Need to shrink - calculate proportionally
|
|
107
|
+
const scaleFactor = availableContentWidth / currentContentWidth;
|
|
108
|
+
// First pass: scale proportionally, enforce minimum
|
|
109
|
+
let adjusted = columns.map((col) => ({
|
|
110
|
+
...col,
|
|
111
|
+
width: Math.max(MIN_COL_WIDTH, Math.floor(col.width * scaleFactor)),
|
|
112
|
+
}));
|
|
113
|
+
// Second pass: if we're still over, trim largest columns first
|
|
114
|
+
let adjustedTotal = adjusted.reduce((sum, col) => sum + col.width, 0);
|
|
115
|
+
while (adjustedTotal > availableContentWidth && adjustedTotal > columns.length * MIN_COL_WIDTH) {
|
|
116
|
+
// Find the widest column that's above minimum
|
|
117
|
+
let maxIdx = -1;
|
|
118
|
+
let maxWidth = MIN_COL_WIDTH;
|
|
119
|
+
for (let i = 0; i < adjusted.length; i++) {
|
|
120
|
+
if (adjusted[i].width > maxWidth) {
|
|
121
|
+
maxWidth = adjusted[i].width;
|
|
122
|
+
maxIdx = i;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (maxIdx === -1)
|
|
126
|
+
break; // All at minimum
|
|
127
|
+
adjusted[maxIdx] = { ...adjusted[maxIdx], width: adjusted[maxIdx].width - 1 };
|
|
128
|
+
adjustedTotal--;
|
|
129
|
+
}
|
|
130
|
+
return adjusted;
|
|
131
|
+
}
|