elio 1.4.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
use super::*;
use rusqlite::Connection;
use std::{fs, io::Write};

// ── SQLite ────────────────────────────────────────────────────────────────────

#[test]
fn sqlite_preview_shows_header_and_tables() {
    let root = temp_path("sqlite-basic");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("app.sqlite");

    let conn = Connection::open(&path).expect("failed to open sqlite db");
    conn.execute_batch(
        "CREATE TABLE accounts (
             id    INTEGER PRIMARY KEY,
             name  TEXT NOT NULL,
             email TEXT
         );
         CREATE TABLE posts (
             id         INTEGER PRIMARY KEY,
             account_id INTEGER NOT NULL,
             body       TEXT
         );",
    )
    .expect("failed to create tables");
    // Insert into `accounts`, which sorts before `posts` alphabetically and will
    // be the first table shown, triggering sample-row rendering.
    conn.execute(
        "INSERT INTO accounts (name, email) VALUES (?1, ?2)",
        ["Alice", "alice@example.com"],
    )
    .expect("failed to insert row");
    drop(conn);

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);

    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);
    assert_eq!(preview.detail.as_deref(), Some("SQLite database"));

    // Details section header
    assert!(
        text.iter().any(|l| l == "Details"),
        "expected 'Details' section header; got: {text:?}"
    );
    // Page size field
    assert!(
        text.iter().any(|l| l.contains("Page size")),
        "expected 'Page size' field; got: {text:?}"
    );
    // Both tables listed
    assert!(
        text.iter().any(|l| l.contains("accounts")),
        "expected 'accounts' table; got: {text:?}"
    );
    assert!(
        text.iter().any(|l| l.contains("posts")),
        "expected 'posts' table; got: {text:?}"
    );
    // Column names and constraint badges for accounts table.
    // INTEGER PRIMARY KEY is a rowid alias — must show PK but no null badge.
    assert!(
        text.iter()
            .any(|l| l.trim_start().starts_with("id ") && l.contains("PK")),
        "expected 'id' column with PK badge; got: {text:?}"
    );
    assert!(
        !text
            .iter()
            .any(|l| l.trim_start().starts_with("id ") && l.contains("NULL")),
        "INTEGER PRIMARY KEY 'id' must not carry a NULL or NOT NULL badge; got: {text:?}"
    );
    assert!(
        text.iter()
            .any(|l| l.contains("name") && l.contains("NOT NULL")),
        "expected 'name' column with NOT NULL badge; got: {text:?}"
    );
    assert!(
        text.iter()
            .any(|l| l.contains("email") && l.contains("NULL")),
        "expected 'email' column with NULL badge; got: {text:?}"
    );
    // Sample row value
    assert!(
        text.iter().any(|l| l.contains("Alice")),
        "expected sample row with 'Alice'; got: {text:?}"
    );

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn sqlite_preview_shows_views() {
    let root = temp_path("sqlite-view");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("app.sqlite");

    let conn = Connection::open(&path).expect("failed to open sqlite db");
    conn.execute_batch(
        "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);
         CREATE VIEW active_items AS SELECT * FROM items WHERE value IS NOT NULL;",
    )
    .expect("failed to create schema");
    drop(conn);

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);
    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);
    assert!(
        text.iter().any(|l| l.contains("active_items")),
        "expected view name; got: {text:?}"
    );

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn non_sqlite_db_file_falls_through_to_binary_preview() {
    let root = temp_path("sqlite-not-sqlite");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("custom.db");
    // Write a file that is clearly not SQLite (no magic bytes).
    fs::write(&path, b"\x00\x01\x02\x03not sqlite at all\x00").expect("failed to write file");

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);

    // Must NOT produce a Data/SQLite preview.
    assert_ne!(
        preview.kind,
        PreviewKind::Data,
        "non-SQLite .db file should not get a Data preview"
    );

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn sqlite_preview_shows_generated_columns() {
    let root = temp_path("sqlite-generated");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("calc.sqlite");

    let conn = Connection::open(&path).expect("failed to open sqlite db");
    conn.execute_batch(
        "CREATE TABLE products (
             id        INTEGER PRIMARY KEY,
             price     REAL NOT NULL,
             tax_rate  REAL NOT NULL DEFAULT 0.2,
             -- VIRTUAL generated column (hidden = 2 in table_xinfo)
             price_inc REAL GENERATED ALWAYS AS (price * (1 + tax_rate)) VIRTUAL
         );",
    )
    .expect("failed to create table with generated column");
    drop(conn);

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);
    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);
    assert!(
        text.iter().any(|l| l.contains("price_inc")),
        "generated column 'price_inc' should be visible; got: {text:?}"
    );
    assert!(
        text.iter()
            .any(|l| l.contains("price_inc") && l.contains("GENERATED")),
        "generated column 'price_inc' should carry the GENERATED badge; got: {text:?}"
    );

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn sqlite_preview_shows_nullability_for_text_primary_key() {
    // TEXT PRIMARY KEY is nullable in SQLite — the column is NOT a rowid alias,
    // so notnull=0 in table_xinfo and NULL values are genuinely accepted.
    // The preview must show both the PK badge and the NULL badge for such columns,
    // rather than silently omitting nullability because the column is a PK.
    let root = temp_path("sqlite-nullable-pk");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("catalog.sqlite");

    let conn = Connection::open(&path).expect("failed to open sqlite db");
    conn.execute_batch(
        "CREATE TABLE entries (
             code     TEXT PRIMARY KEY,
             value    INTEGER NOT NULL,
             note     TEXT
         );",
    )
    .expect("failed to create table");
    drop(conn);

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);
    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);

    // TEXT PRIMARY KEY: is_pk=true, notnull=0 → must show both PK and NULL.
    assert!(
        text.iter().any(|l| l.contains("code") && l.contains("PK")),
        "expected 'code' to show PK badge; got: {text:?}"
    );
    assert!(
        text.iter()
            .any(|l| l.contains("code") && l.contains("NULL") && !l.contains("NOT NULL")),
        "expected 'code' TEXT PRIMARY KEY to show NULL (not NOT NULL); got: {text:?}"
    );

    // Sanity-check the other columns.
    assert!(
        text.iter()
            .any(|l| l.contains("value") && l.contains("NOT NULL")),
        "expected 'value' to show NOT NULL; got: {text:?}"
    );
    assert!(
        text.iter()
            .any(|l| l.contains("note") && l.contains("NULL") && !l.contains("NOT NULL")),
        "expected 'note' to show NULL; got: {text:?}"
    );

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn sqlite_preview_shows_null_for_integer_pk_desc() {
    // INTEGER PRIMARY KEY DESC is not a rowid alias in SQLite — it creates an
    // explicit primary-key index and the column genuinely accepts NULL values.
    // The preview must show both PK and NULL, not just PK.
    let root = temp_path("sqlite-int-pk-desc");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("app.sqlite");

    let conn = Connection::open(&path).expect("failed to open sqlite db");
    conn.execute_batch(
        "CREATE TABLE items (
             id    INTEGER PRIMARY KEY DESC,
             label TEXT NOT NULL
         );",
    )
    .expect("failed to create table");
    drop(conn);

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);
    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);
    assert!(
        text.iter()
            .any(|l| l.trim_start().starts_with("id ") && l.contains("PK")),
        "expected PK badge for 'id'; got: {text:?}"
    );
    // DESC prevents the rowid alias — the column can hold NULL, so the preview
    // must show the NULL badge rather than silently hiding nullability.
    assert!(
        text.iter().any(|l| l.trim_start().starts_with("id ")
            && l.contains("NULL")
            && !l.contains("NOT NULL")),
        "INTEGER PRIMARY KEY DESC must show NULL badge; got: {text:?}"
    );

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

// ── CSV ───────────────────────────────────────────────────────────────────────

#[test]
fn csv_preview_renders_aligned_table_with_detected_header() {
    let root = temp_path("csv-header");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("data.csv");
    fs::write(&path, "name,age,city\nAlice,28,New York\nBob,34,London\n")
        .expect("failed to write csv");

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);
    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);
    assert_eq!(preview.detail.as_deref(), Some("CSV file"));

    // Header values present
    assert!(text.iter().any(|l| l.contains("name")), "{text:?}");
    assert!(text.iter().any(|l| l.contains("age")), "{text:?}");
    assert!(text.iter().any(|l| l.contains("city")), "{text:?}");
    // Data values present
    assert!(text.iter().any(|l| l.contains("Alice")), "{text:?}");
    assert!(text.iter().any(|l| l.contains("London")), "{text:?}");
    // Footer present
    assert!(text.iter().any(|l| l.contains("rows")), "{text:?}");

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn csv_preview_synthesizes_headers_for_all_text_data() {
    let root = temp_path("csv-no-header");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("words.csv");
    // All-text file — ambiguous, should get synthetic col1/col2 headers.
    fs::write(&path, "foo,bar\nbaz,qux\n").expect("failed to write csv");

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);
    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);
    assert!(
        text.iter().any(|l| l.contains("col1")),
        "expected synthetic 'col1' header; got: {text:?}"
    );
    assert!(
        text.iter().any(|l| l.contains("col2")),
        "expected synthetic 'col2' header; got: {text:?}"
    );

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn tsv_preview_uses_tab_delimiter() {
    let root = temp_path("tsv-basic");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("data.tsv");
    fs::write(&path, "product\tprice\nApple\t1.20\nBanana\t0.50\n").expect("failed to write tsv");

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);
    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);
    assert_eq!(preview.detail.as_deref(), Some("TSV file"));
    assert!(text.iter().any(|l| l.contains("product")), "{text:?}");
    assert!(text.iter().any(|l| l.contains("Apple")), "{text:?}");

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn csv_preview_handles_quoted_fields_with_embedded_commas() {
    let root = temp_path("csv-quoted");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("places.csv");
    fs::write(
        &path,
        "city,country\n\"New York, NY\",USA\n\"London, UK\",UK\n",
    )
    .expect("failed to write csv");

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);
    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);
    assert!(
        text.iter()
            .any(|l| l.contains("New York, NY") || l.contains("New York")),
        "expected quoted field content; got: {text:?}"
    );

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn csv_preview_reports_64kib_truncation_for_large_file_with_few_rows() {
    let root = temp_path("csv-byte-truncated");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("big.csv");

    // Write a CSV with 5 data rows, each row padded to make the file > 64 KiB.
    // The header + 5 fat rows fit above 64 KiB so read_text_preview truncates
    // before row 50, yet the row count never hits the MAX_PREVIEW_ROWS cap.
    let padding = "x".repeat(14_000);
    let mut file = fs::File::create(&path).expect("failed to create csv");
    writeln!(file, "label,value,notes").expect("write header");
    for i in 1..=5u32 {
        writeln!(file, "row{i},{i},{padding}").expect("write row");
    }
    drop(file);

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);
    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);
    // Footer must mention the 64 KiB cut, not a false row-cap message.
    assert!(
        text.iter().any(|l| l.contains("truncated at 64 KiB")),
        "expected '64 KiB' truncation note in footer; got: {text:?}"
    );
    assert!(
        !text.iter().any(|l| l.contains("more rows in file")),
        "must not claim 'more rows in file' when the cut was at 64 KiB; got: {text:?}"
    );

    fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn csv_preview_reports_row_cap_truncation_for_file_with_many_short_rows() {
    let root = temp_path("csv-row-truncated");
    fs::create_dir_all(&root).expect("failed to create temp root");
    let path = root.join("many.csv");

    // Write a CSV with 60 short rows — all fit within 64 KiB, but our cap is 50.
    let mut file = fs::File::create(&path).expect("failed to create csv");
    writeln!(file, "id,value").expect("write header");
    for i in 1..=60u32 {
        writeln!(file, "{i},{}", i * 10).expect("write row");
    }
    drop(file);

    let entry = file_entry(path.clone());
    let preview = build_preview(&entry);
    let text: Vec<String> = preview.lines().iter().map(line_text).collect();

    assert_eq!(preview.kind, PreviewKind::Data);
    // Footer must mention row cap, not 64 KiB.
    assert!(
        text.iter().any(|l| l.contains("more rows in file")),
        "expected 'more rows in file' note; got: {text:?}"
    );
    assert!(
        !text.iter().any(|l| l.contains("64 KiB")),
        "must not claim 64 KiB truncation when file fits in read window; got: {text:?}"
    );

    fs::remove_dir_all(root).expect("failed to remove temp root");
}