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 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,7 @@
1
+ import { type QueryResultData } from '../types.js';
2
+ interface QueryResultsProps {
3
+ data: QueryResultData;
4
+ onBack: () => void;
5
+ }
6
+ export declare const QueryResults: ({ data, onBack }: QueryResultsProps) => import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -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,6 @@
1
+ interface ResultCellProps {
2
+ value: unknown;
3
+ width: number;
4
+ }
5
+ export declare const ResultCell: ({ value, width }: ResultCellProps) => import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -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,6 @@
1
+ import type { ColumnInfo } from '../types.js';
2
+ interface ResultHeaderProps {
3
+ columns: ColumnInfo[];
4
+ }
5
+ export declare const ResultHeader: ({ columns }: ResultHeaderProps) => import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -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
- // Query execution will be implemented next
69
- console.log('Query:', query);
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
- const databaseUrl = args[0];
89
- if (!databaseUrl) {
90
- console.error('Usage: qq <database-url>');
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
- let config;
95
- try {
96
- config = parseJdbcUrl(databaseUrl);
97
- }
98
- catch (err) {
99
- console.error(err.message);
100
- process.exit(1);
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 }));
@@ -0,0 +1,2 @@
1
+ import type { QueryResultData } from './types.js';
2
+ export declare const TEST_QUERY_RESULT: QueryResultData;
@@ -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
+ };
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quickerquery",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "A terminal-based database query tool inspired by JetBrains DataGrip",
5
5
  "license": "MIT",
6
6
  "type": "module",