dbn-cli 0.3.0 → 0.5.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 +1 -1
- package/package.json +5 -5
- package/src/adapter/adapter.test.ts +207 -0
- package/src/adapter/base.ts +14 -1
- package/src/adapter/sqlite.ts +71 -1
- package/src/index.ts +59 -2
- package/src/repro_bug.test.ts +101 -0
- package/src/types.ts +54 -1
- package/src/ui/grit/README.md +58 -0
- package/src/ui/grit/index.test.ts +67 -0
- package/src/ui/grit/index.ts +101 -0
- package/src/ui/grit/types.ts +16 -0
- package/src/ui/grit/utils.ts +35 -0
- package/src/ui/navigator.test.ts +434 -0
- package/src/ui/navigator.ts +385 -27
- package/src/ui/navigator_sync.test.ts +95 -0
- package/src/ui/renderer.ts +247 -409
- package/src/ui/screen.ts +6 -6
- package/src/ui/theme.test.ts +30 -0
- package/src/ui/theme.ts +20 -46
- package/src/utils/format.test.ts +209 -0
- package/src/utils/format.ts +94 -21
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dbn-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "A lightweight terminal-based database browser",
|
|
5
5
|
"repository": "https://github.com/amio/dbn-cli",
|
|
6
6
|
"type": "module",
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"dbn": "bin/dbn.ts"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
-
"test": "node --test --experimental-strip-types test/*.test.ts",
|
|
13
|
-
"test:watch": "node --test --watch --experimental-strip-types test/*.test.ts",
|
|
14
|
-
"test:coverage": "node --test --experimental-test-coverage --experimental-strip-types test/*.test.ts",
|
|
15
|
-
"dev": "node bin/
|
|
12
|
+
"test": "node --test --experimental-strip-types src/**/*.test.ts src/*.test.ts",
|
|
13
|
+
"test:watch": "node --test --watch --experimental-strip-types src/**/*.test.ts src/*.test.ts",
|
|
14
|
+
"test:coverage": "node --test --experimental-test-coverage --experimental-strip-types src/**/*.test.ts src/*.test.ts",
|
|
15
|
+
"dev": "node bin/dbn.ts"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"database",
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { SQLiteAdapter } from '../adapter/sqlite.ts';
|
|
8
|
+
|
|
9
|
+
const TEST_DB = join(tmpdir(), 'dbn-cli-test.db');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Set up test database
|
|
13
|
+
*/
|
|
14
|
+
function setupTestDB(): void {
|
|
15
|
+
// Remove old test db if exists
|
|
16
|
+
if (existsSync(TEST_DB)) {
|
|
17
|
+
unlinkSync(TEST_DB);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Create test database
|
|
21
|
+
const db = new DatabaseSync(TEST_DB);
|
|
22
|
+
|
|
23
|
+
// Create test tables
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE users (
|
|
26
|
+
id INTEGER PRIMARY KEY,
|
|
27
|
+
name TEXT NOT NULL,
|
|
28
|
+
email TEXT,
|
|
29
|
+
age INTEGER
|
|
30
|
+
);
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
db.exec(`
|
|
34
|
+
CREATE TABLE posts (
|
|
35
|
+
id INTEGER PRIMARY KEY,
|
|
36
|
+
user_id INTEGER,
|
|
37
|
+
title TEXT NOT NULL,
|
|
38
|
+
content TEXT,
|
|
39
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
40
|
+
);
|
|
41
|
+
`);
|
|
42
|
+
|
|
43
|
+
// Insert test data
|
|
44
|
+
const insertUser = db.prepare('INSERT INTO users (name, email, age) VALUES (?, ?, ?)');
|
|
45
|
+
insertUser.run('Alice', 'alice@example.com', 30);
|
|
46
|
+
insertUser.run('Bob', 'bob@example.com', 25);
|
|
47
|
+
insertUser.run('Charlie', 'charlie@example.com', 35);
|
|
48
|
+
|
|
49
|
+
const insertPost = db.prepare('INSERT INTO posts (user_id, title, content) VALUES (?, ?, ?)');
|
|
50
|
+
insertPost.run(1, 'Hello World', 'This is my first post');
|
|
51
|
+
insertPost.run(1, 'Second Post', 'Another post by Alice');
|
|
52
|
+
insertPost.run(2, 'Bobs Post', 'Post by Bob');
|
|
53
|
+
|
|
54
|
+
db.close();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clean up test database
|
|
59
|
+
*/
|
|
60
|
+
function cleanupTestDB(): void {
|
|
61
|
+
if (existsSync(TEST_DB)) {
|
|
62
|
+
unlinkSync(TEST_DB);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Test suite for SQLiteAdapter
|
|
68
|
+
*/
|
|
69
|
+
describe('SQLiteAdapter', () => {
|
|
70
|
+
before(() => {
|
|
71
|
+
setupTestDB();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
after(() => {
|
|
75
|
+
cleanupTestDB();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should connect to database', () => {
|
|
79
|
+
const adapter = new SQLiteAdapter();
|
|
80
|
+
adapter.connect(TEST_DB);
|
|
81
|
+
assert.ok((adapter as any).db !== null, 'Database should be connected');
|
|
82
|
+
assert.strictEqual((adapter as any).path, TEST_DB, 'Path should be set correctly');
|
|
83
|
+
adapter.close();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should throw error when connecting to invalid path', () => {
|
|
87
|
+
const adapter = new SQLiteAdapter();
|
|
88
|
+
// Try to connect to an invalid path (directory that doesn't exist)
|
|
89
|
+
assert.throws(
|
|
90
|
+
() => adapter.connect('/nonexistent/path/to/database.db'),
|
|
91
|
+
/Failed to open database/,
|
|
92
|
+
'Should throw error for invalid path'
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should get all tables', () => {
|
|
97
|
+
const adapter = new SQLiteAdapter();
|
|
98
|
+
adapter.connect(TEST_DB);
|
|
99
|
+
|
|
100
|
+
const tables = adapter.getTables();
|
|
101
|
+
assert.strictEqual(tables.length, 2, 'Should have 2 tables');
|
|
102
|
+
assert.strictEqual(tables[0].name, 'posts', 'First table should be posts');
|
|
103
|
+
assert.strictEqual(tables[1].name, 'users', 'Second table should be users');
|
|
104
|
+
assert.ok(tables[0].row_count !== undefined, 'Should have row_count property');
|
|
105
|
+
|
|
106
|
+
adapter.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should get table schema', () => {
|
|
110
|
+
const adapter = new SQLiteAdapter();
|
|
111
|
+
adapter.connect(TEST_DB);
|
|
112
|
+
|
|
113
|
+
const schema = adapter.getTableSchema('users');
|
|
114
|
+
assert.strictEqual(schema.length, 4, 'Users table should have 4 columns');
|
|
115
|
+
assert.strictEqual(schema[0].name, 'id', 'First column should be id');
|
|
116
|
+
assert.strictEqual(schema[0].type, 'INTEGER', 'ID should be INTEGER');
|
|
117
|
+
assert.strictEqual(schema[0].pk, 1, 'ID should be primary key');
|
|
118
|
+
assert.strictEqual(schema[1].name, 'name', 'Second column should be name');
|
|
119
|
+
assert.strictEqual(schema[1].type, 'TEXT', 'Name should be TEXT');
|
|
120
|
+
assert.strictEqual(schema[1].notnull, 1, 'Name should be NOT NULL');
|
|
121
|
+
|
|
122
|
+
adapter.close();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should get table data', () => {
|
|
126
|
+
const adapter = new SQLiteAdapter();
|
|
127
|
+
adapter.connect(TEST_DB);
|
|
128
|
+
|
|
129
|
+
const data = adapter.getTableData('users');
|
|
130
|
+
assert.strictEqual(data.length, 3, 'Should have 3 users');
|
|
131
|
+
assert.strictEqual(data[0].name, 'Alice', 'First user should be Alice');
|
|
132
|
+
assert.strictEqual(data[0].email, 'alice@example.com', 'Alice email should match');
|
|
133
|
+
assert.strictEqual(data[0].age, 30, 'Alice age should be 30');
|
|
134
|
+
|
|
135
|
+
adapter.close();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should get table data with pagination', () => {
|
|
139
|
+
const adapter = new SQLiteAdapter();
|
|
140
|
+
adapter.connect(TEST_DB);
|
|
141
|
+
|
|
142
|
+
// Get 2 rows starting from offset 1
|
|
143
|
+
const data = adapter.getTableData('users', { limit: 2, offset: 1 });
|
|
144
|
+
assert.strictEqual(data.length, 2, 'Should return 2 users');
|
|
145
|
+
assert.strictEqual(data[0].name, 'Bob', 'First result should be Bob');
|
|
146
|
+
assert.strictEqual(data[1].name, 'Charlie', 'Second result should be Charlie');
|
|
147
|
+
|
|
148
|
+
adapter.close();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should get table data with custom limit', () => {
|
|
152
|
+
const adapter = new SQLiteAdapter();
|
|
153
|
+
adapter.connect(TEST_DB);
|
|
154
|
+
|
|
155
|
+
const data = adapter.getTableData('users', { limit: 1 });
|
|
156
|
+
assert.strictEqual(data.length, 1, 'Should return 1 user');
|
|
157
|
+
assert.strictEqual(data[0].name, 'Alice', 'Should return Alice');
|
|
158
|
+
|
|
159
|
+
adapter.close();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should get row count', () => {
|
|
163
|
+
const adapter = new SQLiteAdapter();
|
|
164
|
+
adapter.connect(TEST_DB);
|
|
165
|
+
|
|
166
|
+
const userCount = adapter.getRowCount('users');
|
|
167
|
+
assert.strictEqual(userCount, 3, 'Should have 3 users');
|
|
168
|
+
|
|
169
|
+
const postCount = adapter.getRowCount('posts');
|
|
170
|
+
assert.strictEqual(postCount, 3, 'Should have 3 posts');
|
|
171
|
+
|
|
172
|
+
adapter.close();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should close database connection', () => {
|
|
176
|
+
const adapter = new SQLiteAdapter();
|
|
177
|
+
adapter.connect(TEST_DB);
|
|
178
|
+
assert.ok((adapter as any).db !== null, 'Database should be connected');
|
|
179
|
+
|
|
180
|
+
adapter.close();
|
|
181
|
+
assert.strictEqual((adapter as any).db, null, 'Database should be null after close');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should handle closing when not connected', () => {
|
|
185
|
+
const adapter = new SQLiteAdapter();
|
|
186
|
+
assert.doesNotThrow(() => adapter.close(), 'Should not throw when closing unconnected adapter');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should delete a row', () => {
|
|
190
|
+
const adapter = new SQLiteAdapter();
|
|
191
|
+
adapter.connect(TEST_DB);
|
|
192
|
+
|
|
193
|
+
const initialCount = adapter.getRowCount('users');
|
|
194
|
+
const user = adapter.getTableData('users', { limit: 1, offset: 0 })[0];
|
|
195
|
+
assert.ok(user.id, 'User should have an id');
|
|
196
|
+
|
|
197
|
+
adapter.deleteRow('users', { id: user.id });
|
|
198
|
+
|
|
199
|
+
const newCount = adapter.getRowCount('users');
|
|
200
|
+
assert.strictEqual(newCount, initialCount - 1, 'Row count should decrease by 1');
|
|
201
|
+
|
|
202
|
+
const remainingUsers = adapter.getTableData('users');
|
|
203
|
+
assert.ok(!remainingUsers.find(u => u.id === user.id), 'Deleted user should not be in results');
|
|
204
|
+
|
|
205
|
+
adapter.close();
|
|
206
|
+
});
|
|
207
|
+
});
|
package/src/adapter/base.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TableInfo, ColumnSchema, QueryOptions } from '../types.ts';
|
|
1
|
+
import type { TableInfo, ColumnSchema, QueryOptions, HealthInfo } from '../types.ts';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Base class for database adapters
|
|
@@ -39,6 +39,19 @@ export abstract class DatabaseAdapter {
|
|
|
39
39
|
*/
|
|
40
40
|
abstract getRowCount(tableName: string): number;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Delete a single row from a table by its primary key values
|
|
44
|
+
* @param tableName - Name of the table
|
|
45
|
+
* @param keyValues - Primary key column/value mapping
|
|
46
|
+
*/
|
|
47
|
+
abstract deleteRow(tableName: string, keyValues: Record<string, any>): void;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get core database health info
|
|
51
|
+
* @returns Health information
|
|
52
|
+
*/
|
|
53
|
+
abstract getHealthInfo(): HealthInfo;
|
|
54
|
+
|
|
42
55
|
/**
|
|
43
56
|
* Close the database connection
|
|
44
57
|
*/
|
package/src/adapter/sqlite.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { DatabaseAdapter } from './base.ts';
|
|
3
|
-
import type { TableInfo, ColumnSchema, QueryOptions } from '../types.ts';
|
|
3
|
+
import type { TableInfo, ColumnSchema, QueryOptions, HealthInfo } from '../types.ts';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* SQLite adapter using Node.js 22+ built-in node:sqlite
|
|
@@ -98,6 +98,76 @@ export class SQLiteAdapter extends DatabaseAdapter {
|
|
|
98
98
|
return result.count;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Delete a single row from a table by its primary key values
|
|
103
|
+
* @param tableName - Name of the table
|
|
104
|
+
* @param keyValues - Primary key column/value mapping
|
|
105
|
+
*/
|
|
106
|
+
deleteRow(tableName: string, keyValues: Record<string, any>): void {
|
|
107
|
+
if (!this.db) {
|
|
108
|
+
throw new Error('Database not connected');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const keys = Object.keys(keyValues);
|
|
112
|
+
if (keys.length === 0) {
|
|
113
|
+
throw new Error(`No primary key values provided for delete on ${tableName}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const conditions: string[] = [];
|
|
117
|
+
const values: any[] = [];
|
|
118
|
+
|
|
119
|
+
for (const key of keys) {
|
|
120
|
+
const value = keyValues[key];
|
|
121
|
+
if (value === null || value === undefined) {
|
|
122
|
+
conditions.push(`"${key}" IS NULL`);
|
|
123
|
+
} else {
|
|
124
|
+
conditions.push(`"${key}" = ?`);
|
|
125
|
+
values.push(value);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const whereClause = conditions.join(' AND ');
|
|
130
|
+
const stmt = this.db.prepare(`DELETE FROM "${tableName}" WHERE ${whereClause}`);
|
|
131
|
+
stmt.run(...values);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get core database health info
|
|
136
|
+
*/
|
|
137
|
+
getHealthInfo(): HealthInfo {
|
|
138
|
+
if (!this.db) {
|
|
139
|
+
throw new Error('Database not connected');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const singleValue = <T = string>(sql: string): T => {
|
|
143
|
+
const stmt = this.db!.prepare(sql);
|
|
144
|
+
const row = stmt.get() as Record<string, any> | undefined;
|
|
145
|
+
if (!row) return '' as T;
|
|
146
|
+
const value = Object.values(row)[0];
|
|
147
|
+
return value as T;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
sqlite_version: singleValue<string>('SELECT sqlite_version()'),
|
|
152
|
+
journal_mode: singleValue<string>('PRAGMA journal_mode'),
|
|
153
|
+
synchronous: String(singleValue<number>('PRAGMA synchronous')),
|
|
154
|
+
locking_mode: singleValue<string>('PRAGMA locking_mode'),
|
|
155
|
+
page_size: singleValue<number>('PRAGMA page_size'),
|
|
156
|
+
page_count: singleValue<number>('PRAGMA page_count'),
|
|
157
|
+
freelist_count: singleValue<number>('PRAGMA freelist_count'),
|
|
158
|
+
cache_size: singleValue<number>('PRAGMA cache_size'),
|
|
159
|
+
wal_autocheckpoint: singleValue<number>('PRAGMA wal_autocheckpoint'),
|
|
160
|
+
auto_vacuum: String(singleValue<number>('PRAGMA auto_vacuum')),
|
|
161
|
+
user_version: singleValue<number>('PRAGMA user_version'),
|
|
162
|
+
application_id: singleValue<number>('PRAGMA application_id'),
|
|
163
|
+
encoding: singleValue<string>('PRAGMA encoding'),
|
|
164
|
+
foreign_keys: String(singleValue<number>('PRAGMA foreign_keys')),
|
|
165
|
+
temp_store: String(singleValue<number>('PRAGMA temp_store')),
|
|
166
|
+
mmap_size: singleValue<number>('PRAGMA mmap_size'),
|
|
167
|
+
busy_timeout: singleValue<number>('PRAGMA busy_timeout')
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
101
171
|
/**
|
|
102
172
|
* Close the database connection
|
|
103
173
|
*/
|
package/src/index.ts
CHANGED
|
@@ -86,7 +86,9 @@ export class DBPeek {
|
|
|
86
86
|
*/
|
|
87
87
|
private setupInput(): void {
|
|
88
88
|
// Enable raw mode for key-by-key input
|
|
89
|
-
stdin.
|
|
89
|
+
if (stdin.isTTY) {
|
|
90
|
+
stdin.setRawMode(true);
|
|
91
|
+
}
|
|
90
92
|
stdin.resume();
|
|
91
93
|
stdin.setEncoding('utf8');
|
|
92
94
|
|
|
@@ -112,6 +114,25 @@ export class DBPeek {
|
|
|
112
114
|
return;
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
if (key.name === 'y' && this.navigator.hasPendingDelete()) {
|
|
118
|
+
this.navigator.confirmDelete();
|
|
119
|
+
this.render();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (
|
|
124
|
+
this.navigator.hasPendingDelete() &&
|
|
125
|
+
(key.name === 'escape' || key.name === 'h' || key.name === 'left')
|
|
126
|
+
) {
|
|
127
|
+
this.navigator.cancelDelete();
|
|
128
|
+
this.render();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.navigator.hasPendingDelete()) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
115
136
|
// Handle different keys
|
|
116
137
|
switch (key.name) {
|
|
117
138
|
case 'q':
|
|
@@ -119,12 +140,28 @@ export class DBPeek {
|
|
|
119
140
|
break;
|
|
120
141
|
|
|
121
142
|
case 'j':
|
|
143
|
+
if (this.navigator.getState().type === 'row-detail') {
|
|
144
|
+
(this.navigator as any).nextRecord();
|
|
145
|
+
} else {
|
|
146
|
+
this.navigator.moveDown();
|
|
147
|
+
}
|
|
148
|
+
this.render();
|
|
149
|
+
break;
|
|
150
|
+
|
|
151
|
+
case 'k':
|
|
152
|
+
if (this.navigator.getState().type === 'row-detail') {
|
|
153
|
+
(this.navigator as any).prevRecord();
|
|
154
|
+
} else {
|
|
155
|
+
this.navigator.moveUp();
|
|
156
|
+
}
|
|
157
|
+
this.render();
|
|
158
|
+
break;
|
|
159
|
+
|
|
122
160
|
case 'down':
|
|
123
161
|
this.navigator.moveDown();
|
|
124
162
|
this.render();
|
|
125
163
|
break;
|
|
126
164
|
|
|
127
|
-
case 'k':
|
|
128
165
|
case 'up':
|
|
129
166
|
this.navigator.moveUp();
|
|
130
167
|
this.render();
|
|
@@ -161,6 +198,15 @@ export class DBPeek {
|
|
|
161
198
|
this.render();
|
|
162
199
|
break;
|
|
163
200
|
|
|
201
|
+
case 'backspace': {
|
|
202
|
+
const deleteState = this.navigator.getState();
|
|
203
|
+
if (deleteState.type === 'table-detail' || deleteState.type === 'row-detail') {
|
|
204
|
+
this.navigator.requestDelete();
|
|
205
|
+
this.render();
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
|
|
164
210
|
case 's':
|
|
165
211
|
// Toggle schema view in full screen mode
|
|
166
212
|
const currentState = this.navigator.getState();
|
|
@@ -172,6 +218,17 @@ export class DBPeek {
|
|
|
172
218
|
this.render();
|
|
173
219
|
}
|
|
174
220
|
break;
|
|
221
|
+
|
|
222
|
+
case 'i':
|
|
223
|
+
// Toggle core health overview
|
|
224
|
+
const infoState = this.navigator.getState();
|
|
225
|
+
if (infoState.type === 'health') {
|
|
226
|
+
this.navigator.back();
|
|
227
|
+
} else if (infoState.type === 'tables') {
|
|
228
|
+
this.navigator.viewHealth();
|
|
229
|
+
}
|
|
230
|
+
this.render();
|
|
231
|
+
break;
|
|
175
232
|
}
|
|
176
233
|
}
|
|
177
234
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { unlinkSync, existsSync } from 'node:fs';
|
|
5
|
+
import { SQLiteAdapter } from './adapter/sqlite.ts';
|
|
6
|
+
import { Navigator } from './ui/navigator.ts';
|
|
7
|
+
|
|
8
|
+
const REPRO_DB = './repro-bug.db';
|
|
9
|
+
|
|
10
|
+
describe('Navigator Bug Reproduction & Edge Cases', () => {
|
|
11
|
+
let adapter: SQLiteAdapter;
|
|
12
|
+
let navigator: Navigator;
|
|
13
|
+
|
|
14
|
+
before(() => {
|
|
15
|
+
const db = new DatabaseSync(REPRO_DB);
|
|
16
|
+
db.exec(`
|
|
17
|
+
CREATE TABLE items (
|
|
18
|
+
id INTEGER PRIMARY KEY,
|
|
19
|
+
name TEXT
|
|
20
|
+
);
|
|
21
|
+
`);
|
|
22
|
+
|
|
23
|
+
const insert = db.prepare('INSERT INTO items (name) VALUES (?)');
|
|
24
|
+
for (let i = 1; i <= 100; i++) {
|
|
25
|
+
insert.run(`Item ${i}`);
|
|
26
|
+
}
|
|
27
|
+
db.close();
|
|
28
|
+
|
|
29
|
+
adapter = new SQLiteAdapter();
|
|
30
|
+
adapter.connect(REPRO_DB);
|
|
31
|
+
navigator = new Navigator(adapter);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
after(() => {
|
|
35
|
+
adapter.close();
|
|
36
|
+
if (existsSync(REPRO_DB)) {
|
|
37
|
+
unlinkSync(REPRO_DB);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should enter the correct row when scrolled down', () => {
|
|
42
|
+
navigator.init();
|
|
43
|
+
// Enter table detail for 'items'
|
|
44
|
+
navigator.enter();
|
|
45
|
+
|
|
46
|
+
let state = navigator.getState() as any;
|
|
47
|
+
assert.strictEqual(state.type, 'table-detail');
|
|
48
|
+
assert.strictEqual(state.dataCursor, 0);
|
|
49
|
+
assert.strictEqual(state.dataOffset, 0);
|
|
50
|
+
|
|
51
|
+
// Move down 30 times.
|
|
52
|
+
// Default visibleRows is 20, so this should push dataOffset to 11 (if cursor hits bottom at 19)
|
|
53
|
+
for (let i = 0; i < 30; i++) {
|
|
54
|
+
navigator.moveDown();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
state = navigator.getState() as any;
|
|
58
|
+
const expectedRowIndex = state.dataOffset + state.dataCursor;
|
|
59
|
+
const expectedName = `Item ${expectedRowIndex + 1}`;
|
|
60
|
+
|
|
61
|
+
navigator.enter(); // Enter row detail
|
|
62
|
+
const rowState = navigator.getState() as any;
|
|
63
|
+
|
|
64
|
+
assert.strictEqual(rowState.type, 'row-detail');
|
|
65
|
+
assert.strictEqual(rowState.rowIndex, expectedRowIndex, `Row index should be ${expectedRowIndex}`);
|
|
66
|
+
assert.strictEqual(rowState.row.name, expectedName, `Row name should be ${expectedName}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should handle jumping to bottom and moving up', () => {
|
|
70
|
+
navigator.init();
|
|
71
|
+
navigator.enter();
|
|
72
|
+
|
|
73
|
+
navigator.jumpToBottom();
|
|
74
|
+
let state = navigator.getState() as any;
|
|
75
|
+
assert.strictEqual(state.dataOffset + state.dataCursor, 99);
|
|
76
|
+
|
|
77
|
+
navigator.moveUp();
|
|
78
|
+
state = navigator.getState() as any;
|
|
79
|
+
assert.strictEqual(state.dataOffset + state.dataCursor, 98);
|
|
80
|
+
|
|
81
|
+
navigator.enter();
|
|
82
|
+
const rowState = navigator.getState() as any;
|
|
83
|
+
assert.strictEqual(rowState.row.name, 'Item 99');
|
|
84
|
+
assert.strictEqual(rowState.rowIndex, 98);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should handle jumping to top after scrolling', () => {
|
|
88
|
+
navigator.init();
|
|
89
|
+
navigator.enter();
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < 50; i++) navigator.moveDown();
|
|
92
|
+
navigator.jumpToTop();
|
|
93
|
+
|
|
94
|
+
let state = navigator.getState() as any;
|
|
95
|
+
assert.strictEqual(state.dataOffset, 0);
|
|
96
|
+
assert.strictEqual(state.dataCursor, 0);
|
|
97
|
+
|
|
98
|
+
const selectedRow = (navigator as any).getSelectedRow(state);
|
|
99
|
+
assert.strictEqual(selectedRow.name, 'Item 1');
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -30,6 +30,39 @@ export interface QueryOptions {
|
|
|
30
30
|
offset?: number;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Core database health overview info
|
|
35
|
+
*/
|
|
36
|
+
export interface HealthInfo {
|
|
37
|
+
sqlite_version: string;
|
|
38
|
+
journal_mode: string;
|
|
39
|
+
synchronous: string;
|
|
40
|
+
locking_mode: string;
|
|
41
|
+
page_size: number;
|
|
42
|
+
page_count: number;
|
|
43
|
+
freelist_count: number;
|
|
44
|
+
cache_size: number;
|
|
45
|
+
wal_autocheckpoint: number;
|
|
46
|
+
auto_vacuum: string;
|
|
47
|
+
user_version: number;
|
|
48
|
+
application_id: number;
|
|
49
|
+
encoding: string;
|
|
50
|
+
foreign_keys: string;
|
|
51
|
+
temp_store: string;
|
|
52
|
+
mmap_size: number;
|
|
53
|
+
busy_timeout: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Delete confirmation state for write actions
|
|
58
|
+
*/
|
|
59
|
+
export interface DeleteConfirmationState {
|
|
60
|
+
tableName: string;
|
|
61
|
+
rowIndex: number;
|
|
62
|
+
keyValues: Record<string, any>;
|
|
63
|
+
step: 1 | 2;
|
|
64
|
+
}
|
|
65
|
+
|
|
33
66
|
/**
|
|
34
67
|
* Base view state properties
|
|
35
68
|
*/
|
|
@@ -58,8 +91,14 @@ export interface TableDetailViewState extends BaseViewState {
|
|
|
58
91
|
totalRows: number;
|
|
59
92
|
dataOffset: number;
|
|
60
93
|
dataCursor: number;
|
|
94
|
+
bufferOffset: number;
|
|
61
95
|
visibleRows: number;
|
|
62
96
|
showSchema?: boolean;
|
|
97
|
+
deleteConfirm?: DeleteConfirmationState;
|
|
98
|
+
notice?: string;
|
|
99
|
+
columnWeights?: number[];
|
|
100
|
+
cachedColWidths?: number[];
|
|
101
|
+
cachedScreenWidth?: number;
|
|
63
102
|
}
|
|
64
103
|
|
|
65
104
|
/**
|
|
@@ -81,13 +120,27 @@ export interface RowDetailViewState extends BaseViewState {
|
|
|
81
120
|
tableName: string;
|
|
82
121
|
row: Record<string, any>;
|
|
83
122
|
rowIndex: number;
|
|
123
|
+
totalRows: number;
|
|
84
124
|
schema: ColumnSchema[];
|
|
125
|
+
scrollOffset: number;
|
|
126
|
+
totalLines: number;
|
|
127
|
+
visibleHeight: number;
|
|
128
|
+
deleteConfirm?: DeleteConfirmationState;
|
|
129
|
+
notice?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Health overview view state
|
|
134
|
+
*/
|
|
135
|
+
export interface HealthViewState extends BaseViewState {
|
|
136
|
+
type: 'health';
|
|
137
|
+
info: HealthInfo;
|
|
85
138
|
}
|
|
86
139
|
|
|
87
140
|
/**
|
|
88
141
|
* Union type for all possible view states
|
|
89
142
|
*/
|
|
90
|
-
export type ViewState = TablesViewState | TableDetailViewState | SchemaViewState | RowDetailViewState;
|
|
143
|
+
export type ViewState = TablesViewState | TableDetailViewState | SchemaViewState | RowDetailViewState | HealthViewState;
|
|
91
144
|
|
|
92
145
|
/**
|
|
93
146
|
* Screen dimensions
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# GRIT (Grid-TUI)
|
|
2
|
+
|
|
3
|
+
GRIT is a lightweight TUI layout library built for Node.js. It focuses on a modern "OpenCode" aesthetic using solid background blocks and Unicode half-height characters for smooth transitions, rather than traditional box-drawing characters.
|
|
4
|
+
|
|
5
|
+
## Design Philosophy
|
|
6
|
+
|
|
7
|
+
- **Grid-based**: Everything is a grid or a box.
|
|
8
|
+
- **No Lines**: Uses background colors and Unicode blocks (`▀`, `▄`) for visual separation.
|
|
9
|
+
- **TrueColor**: Native support for 24-bit ANSI colors.
|
|
10
|
+
- **Width-Aware**: Correctly handles CJK characters and emojis.
|
|
11
|
+
|
|
12
|
+
## API
|
|
13
|
+
|
|
14
|
+
### `Box`
|
|
15
|
+
|
|
16
|
+
A container that handles background color, padding, and alignment.
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { Box } from './grit/index.ts';
|
|
20
|
+
|
|
21
|
+
const box = new Box({
|
|
22
|
+
width: 80,
|
|
23
|
+
background: '#1A1A1A',
|
|
24
|
+
padding: 1
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const line = box.render("Hello World", { align: 'center' });
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### `Transition`
|
|
31
|
+
|
|
32
|
+
Creates a half-height vertical transition between two background colors.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { Transition } from './grit/index.ts';
|
|
36
|
+
|
|
37
|
+
// Top half is #1A1A1A, bottom half is #0D0D0D
|
|
38
|
+
const line = Transition.draw(80, '#1A1A1A', '#0D0D0D');
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### `Grid`
|
|
42
|
+
|
|
43
|
+
Calculates weighted column widths for tabular layouts.
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { Grid } from './grit/index.ts';
|
|
47
|
+
|
|
48
|
+
const columns = Grid.calculateWidths(100, [
|
|
49
|
+
{ weight: 1 }, // Flexible
|
|
50
|
+
{ weight: 2 }, // Twice as wide
|
|
51
|
+
{ minWidth: 10 } // Fixed minimum
|
|
52
|
+
]);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Internal Utilities
|
|
56
|
+
|
|
57
|
+
- `ANSI`: Low-level ANSI escape sequence generators.
|
|
58
|
+
- `wrapAnsiBg`: Helper to persist background colors across ANSI resets.
|