Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,8 @@ query root, and declared tables and columns through `GET /frontend/state`
(HTTP 503 when unavailable). The page needs no
runtime asset download; the option adds no SDK binding API. Without the flag,
the existing HTTP routes are unchanged. The page runs SQL through `POST
/query` (script at `/frontend.js`). Distribution validation is tracked in a
/query` (script at `/frontend.js`) and lists declared tables with a
quoted preview query beside the path-table guide. Distribution validation is tracked in a
subsequent frontend issue.

**Parity by construction, no drift; no SDK API change (#662).** Query is the
Expand Down
13 changes: 13 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,19 @@ returned, and errors. Values render as text (`NULL` is marked), and only the
first 1000 rows are displayed. An empty result says so and shows no column
names, since the response carries none.

Above the SQL box the page shows the query root and the path-table columns
(`path`, `basename`, `dir`, `ext`, `size`, `mtime`, `ctime`, plus `content`,
read only when named), with a button that loads the starter query. A config
with declared tables adds each table and its columns; choosing one loads
`SELECT * FROM "<table>" LIMIT 100` into the box without running it.
Declared-table globs anchor at their config file's directory, while
path-tables resolve under the query root. With no declared tables, including
a config of only functions or extensions, the page shows the path-table
start alone. A config that fails to load shows its diagnostic and how to
restart. To refresh, run the query again: path-tables re-read the filesystem,
and declared tables reflect the server's index. The page does not use
`/events`.

```bash
dirsql server --frontend
dirsql server --frontend -c .dirsql.toml
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest tests/e2e/cli_version_test.py tests/e2e/query_contract_test.py tests/e2e/fanout_test.py tests/e2e/extension_package_test.py -x -q",
"ran_at": 1791927030,
"exit_code": 0,
"commit": "59221551ed292a404b0342d8455c18d2a946d7d6",
"branch": "bot/1489-frontend-browse-1013"
}
1 change: 1 addition & 0 deletions packages/rust/changelog.d/2026-10-13-frontend-browse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Added** Table browsing and configless onboarding in the `dirsql server --frontend` explorer: the query root, the path-table columns and an editable path-table example, declared tables with their columns and a quoted `LIMIT 100` preview query per table, and config-load diagnostics with restart guidance.
21 changes: 21 additions & 0 deletions packages/rust/migrations.d/2026-10-13-frontend-browse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
### Frontend table browsing and onboarding

#### Summary

The `dirsql server --frontend` page now shows the query root, documents path-table columns, lists declared tables with their columns, and loads a quoted, bounded preview query when a table is chosen. A config that fails to load shows its diagnostic and how to restart. Nothing breaks.

#### Required changes

_None._

#### Deprecations removed

_None._

#### Behavior changes without code changes

_None._

#### Verification

Run `dirsql server --frontend --port 0 -c .dirsql.toml` in a directory with a config, open the printed URL, and choose a table. The SQL box reads `SELECT * FROM "<table>" LIMIT 100`; press Run to see its rows.
30 changes: 4 additions & 26 deletions packages/rust/src/cli/frontend.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
p { line-height: 1.6; color: #4a5d51; max-width: 42rem; }
.panel { margin-top: 2.5rem; padding: 1.5rem; border: 1px solid #d9e1da; border-radius: 12px; background: #fff; box-shadow: 0 3px 12px #17302609; }
.panel h2 { margin: 0 0 .5rem; font-size: 1rem; }
.panel p { margin: 0; }
.panel p { margin: 0 0 .6rem; }
.panel h2:not(:first-child) { margin-top: 1.2rem; }
.panel ul { margin: .2rem 0 .8rem; padding-left: 1.2rem; line-height: 1.5; color: #4a5d51; }
.panel button.choice { margin: .2rem 0; padding: .25rem .6rem; background: #eef2ee; color: #173026; border: 1px solid #b9c7bd; font: .9rem/1.4 ui-monospace, monospace; }
form { margin-top: 2rem; }
label { display: block; font-weight: 600; margin-bottom: .4rem; }
textarea { width: 100%; min-height: 7rem; padding: .7rem; border: 1px solid #b9c7bd; border-radius: 8px; font: .95rem/1.5 ui-monospace, monospace; resize: vertical; }
Expand Down Expand Up @@ -51,30 +54,5 @@ <h2 id="state-title">Connecting to dirsql…</h2>
<section id="result" aria-live="polite" aria-label="Query result"></section>
</main>
<script type="module" src="/frontend.js"></script>
<script>
(async () => {
const panel = document.getElementById('state');
const title = document.getElementById('state-title');
const message = document.getElementById('state-message');
try {
const response = await fetch('/frontend/state');
const state = await response.json();
if (state.error) {
panel.classList.add('error');
title.textContent = 'Configuration could not load';
message.textContent = state.error + ' Fix the configuration and restart dirsql server.';
} else if (response.ok) {
title.textContent = 'Ready to explore';
message.textContent = 'The SQL explorer is connected to this directory.';
} else {
throw new Error('Unexpected server response');
}
} catch (error) {
panel.classList.add('error');
title.textContent = 'Could not connect';
message.textContent = error.message;
}
})();
</script>
</body>
</html>
136 changes: 136 additions & 0 deletions packages/rust/src/cli/frontend.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,147 @@ export function renderResult(doc, container, state) {
container.replaceChildren(...(state.rowCount ? [note, tableOf(doc, state)] : [note]));
}

export async function loadState(fetch) {
try {
const response = await fetch('/frontend/state');
if (!response.ok) {
const { message } = await failureOf(response);
return { status: response.status === 503 ? 'unavailable' : 'failed', message };
}
const { root, tables } = await response.json();
return { status: 'ready', root, tables };
} catch (error) {
return { status: 'failed', message: error.message };
}
}

export function previewQuery(table) {
return `SELECT * FROM "${table.replaceAll('"', '""')}" LIMIT 100`;
}

const PATH_COLUMNS = [
['path', 'path relative to the query root'],
['basename', 'filename with extension'],
['dir', 'parent directory'],
['ext', 'extension without the dot'],
['size', 'size in bytes'],
['mtime', 'modification time, Unix seconds'],
['ctime', 'creation/change time, Unix seconds'],
];

function node(doc, tag, ...children) {
const el = doc.createElement(tag);
el.append(...children);
return el;
}

const code = (doc, text) => node(doc, 'code', text);

function choice(doc, label, sql, onChoose) {
const button = node(doc, 'button', label);
button.setAttribute('type', 'button');
button.className = 'choice';
button.addEventListener('click', () => onChoose(sql));
return button;
}

function pathTableGuide(doc, onChoose) {
return [
node(doc, 'h2', 'Path-tables'),
node(
doc,
'p',
'Query files with no configuration: write a quoted path or glob where a table name goes, such as ',
code(doc, "'./'"),
' (files directly in the query root) or ',
code(doc, "'./**/*.md'"),
' (Markdown files at any depth). Edit the path in the SQL to choose files.',
),
choice(doc, STARTER_QUERY, STARTER_QUERY, onChoose),
node(doc, 'ul', ...PATH_COLUMNS.map(([name, meaning]) => node(doc, 'li', code(doc, name), ` ${meaning}`))),
node(
doc,
'p',
code(doc, 'content'),
" holds the file's text. It is left out of SELECT * and read only when you name it, as in ",
code(doc, "SELECT path, content FROM './*.md' LIMIT 10"),
'.',
),
];
}

function tableCatalog(doc, tables, onChoose) {
if (!tables.length) return [node(doc, 'h2', 'Declared tables'), node(doc, 'p', 'No declared tables.')];
return [
node(doc, 'h2', 'Declared tables'),
node(
doc,
'p',
"Declared-table globs are anchored at their config file's directory; path-table queries use the query root. Choose a table to load a preview query, then run it.",
),
...tables.map((table) =>
node(
doc,
'div',
choice(doc, table.name, previewQuery(table.name), onChoose),
node(
doc,
'ul',
...table.columns.map((column) => node(doc, 'li', code(doc, column.name), column.type ? ` ${column.type}` : '')),
),
),
),
];
}

export function renderState(doc, container, state, onChoose) {
if (state.status === 'ready') {
container.className = 'panel';
container.replaceChildren(
node(doc, 'h2', 'Query root'),
node(doc, 'p', code(doc, state.root)),
...pathTableGuide(doc, onChoose),
...tableCatalog(doc, state.tables, onChoose),
node(
doc,
'p',
"Run again to refresh: path-tables read the filesystem on every query; declared tables reflect the server's index.",
),
);
return;
}
const title = node(doc, 'h2', state.status === 'unavailable' ? 'Configuration could not load' : 'Could not connect');
title.setAttribute('role', 'alert');
container.className = 'panel error';
if (state.status === 'unavailable') {
container.replaceChildren(
title,
node(doc, 'pre', state.message),
node(
doc,
'p',
'Fix the configuration, stop this server (Ctrl+C), and restart it with ',
code(doc, 'dirsql server --frontend -c <config>'),
'.',
),
);
return;
}
container.replaceChildren(title, node(doc, 'p', `Could not load the server state: ${state.message}`));
}

if (typeof document !== 'undefined') {
const sql = document.getElementById('sql');
const run = document.getElementById('run');
const result = document.getElementById('result');
const panel = document.getElementById('state');
sql.value = STARTER_QUERY;
loadState((url) => fetch(url)).then((state) =>
renderState(document, panel, state, (query) => {
sql.value = query;
sql.focus();
}),
);
const runner = createQueryRunner({
fetch: (url, init) => fetch(url, init),
now: () => performance.now(),
Expand Down
21 changes: 21 additions & 0 deletions packages/rust/tests/cli_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,27 @@ fn frontend_page_has_query_controls_and_serves_its_script() {
kill_and_wait(server);
}

#[test]
fn frontend_script_offers_table_browsing_and_path_onboarding() {
let root = TempDir::new().unwrap();
let port = free_port();
let server = spawn_dirsql_with_args(root.path(), port, &["--frontend"]);
wait_until_ready(port, Duration::from_secs(5));
let script = Client::new()
.get(format!("http://localhost:{port}/frontend.js"))
.send()
.unwrap()
.text()
.unwrap();
for name in ["loadState", "renderState", "previewQuery"] {
assert!(
script.contains(name),
"{name} missing from the frontend script"
);
}
kill_and_wait(server);
}

#[test]
fn frontend_still_loads_and_reports_an_invalid_config() {
let root = TempDir::new().unwrap();
Expand Down
Loading
Loading