mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
feat(cli): tree-picker UI for database scope selection (#81)
* refactor(cli): extract generic tree picker from Notion-specific modules Rename notion-page-picker-tree → tree-picker-state and notion-page-picker-tui → tree-picker-tui, removing Notion-specific naming so the tree picker can be reused for database scope selection. Update notion-page-picker to consume the new generic interfaces. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(cli): add database tree picker for schema and table scope selection Replace inline multiselect prompts in setup-databases with a new database-tree-picker that uses the generic tree picker TUI. This gives database scope selection the same grouped tree UI as the Notion page picker, combining schema and table selection into a single step. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c2750dd797
commit
dabd640cad
11 changed files with 1299 additions and 834 deletions
188
packages/cli/src/database-tree-picker.test.ts
Normal file
188
packages/cli/src/database-tree-picker.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
pickDatabaseScope,
|
||||
type DatabaseTreePickerRenderer,
|
||||
type PickDatabaseScopeArgs,
|
||||
} from './database-tree-picker.js';
|
||||
import type { TreePickerChrome, TreePickerResult } from './tree-picker-tui.js';
|
||||
import type { PickerState } from './tree-picker-state.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { isTTY: true, write: (chunk: string) => { stdout += chunk; } },
|
||||
stderr: { write: (chunk: string) => { stderr += chunk; } },
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function captureRenderer(): {
|
||||
renderer: DatabaseTreePickerRenderer;
|
||||
capture: { chrome?: TreePickerChrome; state?: PickerState };
|
||||
setResult: (result: TreePickerResult) => void;
|
||||
} {
|
||||
const capture: { chrome?: TreePickerChrome; state?: PickerState } = {};
|
||||
let nextResult: TreePickerResult = { kind: 'quit' };
|
||||
const renderer: DatabaseTreePickerRenderer = vi.fn(async (chrome, state) => {
|
||||
capture.chrome = chrome;
|
||||
capture.state = state;
|
||||
return nextResult;
|
||||
});
|
||||
return {
|
||||
renderer,
|
||||
capture,
|
||||
setResult: (result) => {
|
||||
nextResult = result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const discovered = [
|
||||
{ schema: 'analytics', name: 'customers', kind: 'table' as const },
|
||||
{ schema: 'analytics', name: 'orders', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'events', kind: 'view' as const },
|
||||
{ schema: 'public', name: 'sessions', kind: 'table' as const },
|
||||
];
|
||||
|
||||
function baseArgs(overrides: Partial<PickDatabaseScopeArgs> = {}): PickDatabaseScopeArgs {
|
||||
return {
|
||||
connectionId: 'warehouse',
|
||||
schemaNoun: 'schema',
|
||||
schemaNounPlural: 'schemas',
|
||||
discovered,
|
||||
existing: { enabledTables: [] },
|
||||
defaultSchemas: ['analytics'],
|
||||
supportsSchemaScope: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('pickDatabaseScope', () => {
|
||||
it('builds a 2-level tree (schemas as parents, tables as children) and uses save-empty action', async () => {
|
||||
const { renderer, capture, setResult } = captureRenderer();
|
||||
setResult({ kind: 'quit' });
|
||||
|
||||
await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
|
||||
|
||||
expect(capture.state?.skipEmptyAction).toBe('save-empty');
|
||||
const schemaIds = capture.state?.tree.filter((n) => n.parentId === null).map((n) => n.id);
|
||||
const tableIds = capture.state?.tree.filter((n) => n.parentId !== null).map((n) => n.id);
|
||||
expect((schemaIds ?? []).sort()).toEqual(['analytics', 'public']);
|
||||
expect((tableIds ?? []).sort()).toEqual([
|
||||
'analytics.customers',
|
||||
'analytics.orders',
|
||||
'public.events',
|
||||
'public.sessions',
|
||||
]);
|
||||
expect(capture.state?.byId.get('public.events')?.title).toBe('events (view)');
|
||||
});
|
||||
|
||||
it('pre-checks default schemas at the parent level when no existing selection', async () => {
|
||||
const { renderer, capture, setResult } = captureRenderer();
|
||||
setResult({ kind: 'quit' });
|
||||
|
||||
await pickDatabaseScope(baseArgs({ defaultSchemas: ['analytics'] }), makeIo().io, renderer);
|
||||
|
||||
expect([...(capture.state?.checked ?? [])]).toEqual(['analytics']);
|
||||
});
|
||||
|
||||
it('collapses an existing full-schema selection back into the parent check', async () => {
|
||||
const { renderer, capture, setResult } = captureRenderer();
|
||||
setResult({ kind: 'quit' });
|
||||
|
||||
await pickDatabaseScope(
|
||||
baseArgs({ existing: { enabledTables: ['analytics.customers', 'analytics.orders'] } }),
|
||||
makeIo().io,
|
||||
renderer,
|
||||
);
|
||||
|
||||
expect([...(capture.state?.checked ?? [])]).toEqual(['analytics']);
|
||||
});
|
||||
|
||||
it('keeps a partial existing selection at the leaf level', async () => {
|
||||
const { renderer, capture, setResult } = captureRenderer();
|
||||
setResult({ kind: 'quit' });
|
||||
|
||||
await pickDatabaseScope(
|
||||
baseArgs({ existing: { enabledTables: ['analytics.customers'] } }),
|
||||
makeIo().io,
|
||||
renderer,
|
||||
);
|
||||
|
||||
expect([...(capture.state?.checked ?? [])]).toEqual(['analytics.customers']);
|
||||
});
|
||||
|
||||
it('expands a selected schema parent into all its tables and derives activeSchemas', async () => {
|
||||
const { renderer, setResult } = captureRenderer();
|
||||
setResult({ kind: 'save', selectedIds: ['analytics'] });
|
||||
|
||||
const result = await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'selected',
|
||||
activeSchemas: ['analytics'],
|
||||
enabledTables: ['analytics.customers', 'analytics.orders'],
|
||||
});
|
||||
});
|
||||
|
||||
it('combines parent and individual leaf selections without duplicate tables', async () => {
|
||||
const { renderer, setResult } = captureRenderer();
|
||||
setResult({ kind: 'save', selectedIds: ['analytics', 'public.events'] });
|
||||
|
||||
const result = await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'selected',
|
||||
activeSchemas: ['analytics', 'public'],
|
||||
enabledTables: ['analytics.customers', 'analytics.orders', 'public.events'],
|
||||
});
|
||||
});
|
||||
|
||||
it('treats empty save as enable-all', async () => {
|
||||
const { renderer, setResult } = captureRenderer();
|
||||
setResult({ kind: 'save', selectedIds: [] });
|
||||
|
||||
const result = await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'selected',
|
||||
activeSchemas: ['analytics', 'public'],
|
||||
enabledTables: [
|
||||
'analytics.customers',
|
||||
'analytics.orders',
|
||||
'public.events',
|
||||
'public.sessions',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits activeSchemas when the driver does not support a schema scope', async () => {
|
||||
const { renderer, setResult } = captureRenderer();
|
||||
setResult({ kind: 'save', selectedIds: ['analytics'] });
|
||||
|
||||
const result = await pickDatabaseScope(
|
||||
baseArgs({ supportsSchemaScope: false }),
|
||||
makeIo().io,
|
||||
renderer,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'selected',
|
||||
activeSchemas: [],
|
||||
enabledTables: ['analytics.customers', 'analytics.orders'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns back when the picker quits', async () => {
|
||||
const { renderer, setResult } = captureRenderer();
|
||||
setResult({ kind: 'quit' });
|
||||
|
||||
const result = await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
|
||||
|
||||
expect(result).toEqual({ kind: 'back' });
|
||||
});
|
||||
});
|
||||
210
packages/cli/src/database-tree-picker.ts
Normal file
210
packages/cli/src/database-tree-picker.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import type { KtxTableListEntry } from '@ktx/context/scan';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
import {
|
||||
buildInitialState,
|
||||
buildPickerTree,
|
||||
type PickerState,
|
||||
type TreePickerNode,
|
||||
type TreePickerNodeInput,
|
||||
} from './tree-picker-state.js';
|
||||
import {
|
||||
renderTreePickerTui,
|
||||
type TreePickerChrome,
|
||||
type TreePickerResult,
|
||||
type TreePickerTuiIo,
|
||||
} from './tree-picker-tui.js';
|
||||
|
||||
profileMark('module:database-tree-picker');
|
||||
|
||||
const DATABASE_SCRIPTED_MODE_HINT =
|
||||
'Database picker requires a TTY. Use --no-input and the relevant flags for scripted mode.';
|
||||
|
||||
export type DatabaseTreePickerRenderer = (
|
||||
chrome: TreePickerChrome,
|
||||
initialState: PickerState,
|
||||
io: TreePickerTuiIo,
|
||||
) => Promise<TreePickerResult>;
|
||||
|
||||
function defaultRenderer(
|
||||
chrome: TreePickerChrome,
|
||||
initialState: PickerState,
|
||||
io: TreePickerTuiIo,
|
||||
): Promise<TreePickerResult> {
|
||||
return renderTreePickerTui({ chrome, initialState }, io, { scriptedModeHint: DATABASE_SCRIPTED_MODE_HINT });
|
||||
}
|
||||
|
||||
export type DatabaseScopePickResult =
|
||||
| { kind: 'selected'; activeSchemas: string[]; enabledTables: string[] }
|
||||
| { kind: 'back' };
|
||||
|
||||
export interface PickDatabaseScopeArgs {
|
||||
connectionId: string;
|
||||
schemaNoun: string;
|
||||
schemaNounPlural: string;
|
||||
discovered: readonly KtxTableListEntry[];
|
||||
existing: { enabledTables: readonly string[] };
|
||||
defaultSchemas: readonly string[];
|
||||
supportsSchemaScope: boolean;
|
||||
}
|
||||
|
||||
function qualifiedTableId(entry: KtxTableListEntry): string {
|
||||
return `${entry.schema}.${entry.name}`;
|
||||
}
|
||||
|
||||
function tableTitle(entry: KtxTableListEntry): string {
|
||||
return entry.kind === 'view' ? `${entry.name} (view)` : entry.name;
|
||||
}
|
||||
|
||||
function buildTreeInputs(discovered: readonly KtxTableListEntry[]): {
|
||||
inputs: TreePickerNodeInput[];
|
||||
schemaIds: string[];
|
||||
allTables: string[];
|
||||
} {
|
||||
const schemaSeen = new Set<string>();
|
||||
const schemaIds: string[] = [];
|
||||
for (const entry of discovered) {
|
||||
if (!schemaSeen.has(entry.schema)) {
|
||||
schemaSeen.add(entry.schema);
|
||||
schemaIds.push(entry.schema);
|
||||
}
|
||||
}
|
||||
const inputs: TreePickerNodeInput[] = [];
|
||||
for (const schema of schemaIds) {
|
||||
inputs.push({ id: schema, title: schema, archived: false, parentId: null });
|
||||
}
|
||||
for (const entry of discovered) {
|
||||
inputs.push({
|
||||
id: qualifiedTableId(entry),
|
||||
title: tableTitle(entry),
|
||||
archived: false,
|
||||
parentId: entry.schema,
|
||||
});
|
||||
}
|
||||
return { inputs, schemaIds, allTables: discovered.map(qualifiedTableId) };
|
||||
}
|
||||
|
||||
function initialSelectionForExisting(
|
||||
existing: readonly string[],
|
||||
byId: Map<string, TreePickerNode>,
|
||||
): string[] {
|
||||
const tableIds = new Set(
|
||||
[...byId.values()].filter((node) => node.parentId !== null).map((node) => node.id),
|
||||
);
|
||||
const existingTables = new Set(existing.filter((id) => tableIds.has(id)));
|
||||
const schemaChildren = new Map<string, string[]>();
|
||||
for (const node of byId.values()) {
|
||||
if (node.parentId === null && node.childIds.length > 0) {
|
||||
schemaChildren.set(node.id, [...node.childIds]);
|
||||
}
|
||||
}
|
||||
const result: string[] = [];
|
||||
for (const [schema, children] of schemaChildren) {
|
||||
const allChecked = children.length > 0 && children.every((childId) => existingTables.has(childId));
|
||||
if (allChecked) {
|
||||
result.push(schema);
|
||||
for (const childId of children) {
|
||||
existingTables.delete(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of existingTables) {
|
||||
result.push(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function initialSelectionFromDefaults(
|
||||
defaultSchemas: readonly string[],
|
||||
schemaIds: readonly string[],
|
||||
): string[] {
|
||||
const valid = new Set(schemaIds);
|
||||
const filtered = defaultSchemas.filter((s) => valid.has(s));
|
||||
return filtered.length > 0 ? filtered : [...schemaIds];
|
||||
}
|
||||
|
||||
function expandSelectedToTables(
|
||||
selectedIds: readonly string[],
|
||||
byId: Map<string, TreePickerNode>,
|
||||
): string[] {
|
||||
const expanded: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of selectedIds) {
|
||||
const node = byId.get(id);
|
||||
if (!node) continue;
|
||||
if (node.childIds.length === 0) {
|
||||
if (node.parentId !== null && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
expanded.push(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const childId of node.childIds) {
|
||||
if (!seen.has(childId)) {
|
||||
seen.add(childId);
|
||||
expanded.push(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
function schemasFromEnabledTables(enabledTables: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const qualified of enabledTables) {
|
||||
const schema = qualified.split('.')[0] ?? '';
|
||||
if (schema.length === 0 || seen.has(schema)) continue;
|
||||
seen.add(schema);
|
||||
result.push(schema);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function pickDatabaseScope(
|
||||
args: PickDatabaseScopeArgs,
|
||||
io: KtxCliIo,
|
||||
render: DatabaseTreePickerRenderer = defaultRenderer,
|
||||
): Promise<DatabaseScopePickResult> {
|
||||
const { inputs, schemaIds, allTables } = buildTreeInputs(args.discovered);
|
||||
const tree = buildPickerTree(inputs);
|
||||
const byId = new Map(tree.map((node) => [node.id, node]));
|
||||
const tableCount = allTables.length;
|
||||
const schemaCount = schemaIds.length;
|
||||
|
||||
const initialSelection =
|
||||
args.existing.enabledTables.length > 0
|
||||
? initialSelectionForExisting(args.existing.enabledTables, byId)
|
||||
: initialSelectionFromDefaults(args.defaultSchemas, schemaIds);
|
||||
|
||||
const initialState = buildInitialState({
|
||||
tree,
|
||||
existingSelectedIds: initialSelection,
|
||||
skipEmptyAction: 'save-empty',
|
||||
});
|
||||
|
||||
const schemaWordPlural = schemaCount === 1 ? args.schemaNoun : args.schemaNounPlural;
|
||||
const subtitleLines = [
|
||||
`Connection: ${args.connectionId}`,
|
||||
`Found ${tableCount} ${tableCount === 1 ? 'table' : 'tables'} across ${schemaCount} ${schemaWordPlural}.`,
|
||||
`Toggle a ${args.schemaNoun} to enable all of its tables, or expand to pick individual tables.`,
|
||||
];
|
||||
|
||||
const chrome: TreePickerChrome = {
|
||||
title: `Choose tables to enable for ${args.connectionId}`,
|
||||
subtitleLines,
|
||||
skipEmptyMessage:
|
||||
'Nothing selected. Enable all tables? Press Enter to enable all or Escape to go back.',
|
||||
};
|
||||
|
||||
const result = await render(chrome, initialState, io as TreePickerTuiIo);
|
||||
if (result.kind === 'quit') {
|
||||
return { kind: 'back' };
|
||||
}
|
||||
|
||||
const enabledTables =
|
||||
result.selectedIds.length === 0 ? allTables : expandSelectedToTables(result.selectedIds, byId);
|
||||
const activeSchemas = args.supportsSchemaScope ? schemasFromEnabledTables(enabledTables) : [];
|
||||
|
||||
return { kind: 'selected', activeSchemas, enabledTables };
|
||||
}
|
||||
|
|
@ -1,392 +0,0 @@
|
|||
/* @jsxImportSource react */
|
||||
import { render as renderInkTest } from 'ink-testing-library';
|
||||
import { type ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './notion-page-picker-tree.js';
|
||||
import {
|
||||
NotionPickerApp,
|
||||
notionPickerCommandForInkInput,
|
||||
renderNotionPickerTui,
|
||||
resolveNotionPickerWidth,
|
||||
sanitizeNotionPickerTuiError,
|
||||
windowItems,
|
||||
windowOffset,
|
||||
type NotionPickerInkInstance,
|
||||
type NotionPickerInkRenderOptions,
|
||||
} from './notion-page-picker-tui.js';
|
||||
|
||||
const IDS = {
|
||||
engineering: '11111111-1111-1111-1111-111111111111',
|
||||
architecture: '22222222-2222-2222-2222-222222222222',
|
||||
marketing: '33333333-3333-3333-3333-333333333333',
|
||||
finance: '44444444-4444-4444-4444-444444444444',
|
||||
ops: '55555555-5555-5555-5555-555555555555',
|
||||
sales: '66666666-6666-6666-6666-666666666666',
|
||||
support: '77777777-7777-7777-7777-777777777777',
|
||||
product: '88888888-8888-8888-8888-888888888888',
|
||||
design: '99999999-9999-9999-9999-999999999999',
|
||||
};
|
||||
|
||||
function pages(): NotionPickerPageInput[] {
|
||||
return [
|
||||
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
|
||||
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
];
|
||||
}
|
||||
|
||||
function manyPages(): NotionPickerPageInput[] {
|
||||
return [
|
||||
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
|
||||
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
{ id: IDS.finance, title: 'Finance', archived: false, parentId: null },
|
||||
{ id: IDS.ops, title: 'Operations', archived: false, parentId: null },
|
||||
{ id: IDS.sales, title: 'Sales', archived: false, parentId: null },
|
||||
{ id: IDS.support, title: 'Support', archived: false, parentId: null },
|
||||
{ id: IDS.product, title: 'Product', archived: false, parentId: null },
|
||||
{ id: IDS.design, title: 'Design', archived: false, parentId: null },
|
||||
];
|
||||
}
|
||||
|
||||
function state(mode: 'all_accessible' | 'selected_roots' = 'selected_roots') {
|
||||
return buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: mode,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForInkInput(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
function fakeInkInstance(): NotionPickerInkInstance {
|
||||
return {
|
||||
rerender: vi.fn(),
|
||||
unmount: vi.fn(),
|
||||
waitUntilExit: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrameWrap(frame: string | undefined): string {
|
||||
return frame?.replace(/\n/g, ' ').replace(/│ /g, '').replace(/ +/g, ' ') ?? '';
|
||||
}
|
||||
|
||||
describe('notionPickerCommandForInkInput', () => {
|
||||
it('maps browse, search, and confirm input to reducer commands', () => {
|
||||
expect(notionPickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down');
|
||||
expect(notionPickerCommandForInkInput('', { upArrow: true }, state().search, null)).toBe('cursor-up');
|
||||
expect(notionPickerCommandForInkInput('', { rightArrow: true }, state().search, null)).toBe('cursor-right');
|
||||
expect(notionPickerCommandForInkInput('', { leftArrow: true }, state().search, null)).toBe('cursor-left');
|
||||
expect(notionPickerCommandForInkInput(' ', {}, state().search, null)).toBe('toggle-check');
|
||||
expect(notionPickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start');
|
||||
expect(notionPickerCommandForInkInput('a', {}, state().search, null)).toBe('select-all-visible');
|
||||
expect(notionPickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none');
|
||||
expect(notionPickerCommandForInkInput('', { return: true }, state().search, null)).toBe('save-request');
|
||||
expect(notionPickerCommandForInkInput('', { escape: true }, state().search, null)).toBe('quit');
|
||||
expect(notionPickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit');
|
||||
expect(notionPickerCommandForInkInput('s', {}, state().search, null)).toBeNull();
|
||||
expect(notionPickerCommandForInkInput('q', {}, state().search, null)).toBeNull();
|
||||
|
||||
expect(notionPickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({
|
||||
type: 'search-input',
|
||||
value: 'x',
|
||||
});
|
||||
expect(notionPickerCommandForInkInput('', { backspace: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-backspace',
|
||||
);
|
||||
expect(notionPickerCommandForInkInput('', { return: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-submit',
|
||||
);
|
||||
expect(notionPickerCommandForInkInput('', { escape: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-cancel',
|
||||
);
|
||||
|
||||
expect(notionPickerCommandForInkInput('y', {}, state().search, 'mode-switch')).toBe('save-confirm');
|
||||
expect(notionPickerCommandForInkInput('', { return: true }, state().search, 'mode-switch')).toBe('save-confirm');
|
||||
expect(notionPickerCommandForInkInput('n', {}, state().search, 'mode-switch')).toBe('save-cancel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('window helpers', () => {
|
||||
it('centers the selected row and returns the visible slice', () => {
|
||||
expect(windowOffset(20, 10, 5)).toBe(8);
|
||||
expect(windowItems(['a', 'b', 'c', 'd', 'e'], 3, 3)).toEqual({ items: ['c', 'd', 'e'], offset: 2 });
|
||||
});
|
||||
|
||||
it('clamps picker width to the design rule', () => {
|
||||
expect(resolveNotionPickerWidth(200)).toBe(120);
|
||||
expect(resolveNotionPickerWidth(100)).toBe(96);
|
||||
expect(resolveNotionPickerWidth(50)).toBe(60);
|
||||
expect(resolveNotionPickerWidth(undefined)).toBe(96);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotionPickerApp', () => {
|
||||
it('renders spec banners, row glyphs, search visibility, and hint text', () => {
|
||||
const initialState = {
|
||||
...state('all_accessible'),
|
||||
preLoadWarnings: ['1 stored root_page_ids no longer visible'],
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={5000}
|
||||
currentCrawlMode="all_accessible"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Select Notion pages to ingest');
|
||||
expect(frame).toContain('Workspace: Design Workspace');
|
||||
expect(frame).toContain('5000-page cap reached - some pages not shown');
|
||||
expect(frame).toContain('1 stored root_page_ids no longer visible - they will be removed if you save');
|
||||
expect(frame).toContain('◻ Engineering Docs ▸ (1)');
|
||||
expect(frame).toContain('◻ Marketing');
|
||||
expect(frame).not.toContain('Search ready: -');
|
||||
expect(normalizeFrameWrap(frame)).toContain(
|
||||
'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders partial discovery warnings without stale-root save suffix', () => {
|
||||
const initialState = {
|
||||
...state(),
|
||||
preLoadWarnings: ['Notion search stopped early: rate limit after first page'],
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Notion search stopped early: rate limit after first page');
|
||||
expect(frame).not.toContain(
|
||||
'Notion search stopped early: rate limit after first page - they will be removed if you save',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders checked parents and locked descendants with the locked design glyphs', () => {
|
||||
const initialState = {
|
||||
...state(),
|
||||
checked: new Set([IDS.engineering]),
|
||||
expanded: new Set([IDS.engineering]),
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('◼ Engineering Docs ▾');
|
||||
expect(frame).toContain(' ◼ Architecture');
|
||||
});
|
||||
|
||||
it('supports keyboard selection, all_accessible confirmation, and save callback', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={state('all_accessible')}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="all_accessible"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write(' ');
|
||||
await waitForInkInput();
|
||||
expect(lastFrame()).toContain('◼ Engineering Docs');
|
||||
|
||||
stdin.write('\r');
|
||||
await waitForInkInput();
|
||||
expect(normalizeFrameWrap(lastFrame())).toContain(
|
||||
'Switch crawl_mode from all_accessible to selected_roots? Will limit ingest to 1 selected page. Press Enter to confirm or Escape to go back.',
|
||||
);
|
||||
|
||||
stdin.write('y');
|
||||
await waitForInkInput();
|
||||
expect(onExit).toHaveBeenCalledWith({ kind: 'save', rootPageIds: [IDS.engineering] });
|
||||
});
|
||||
|
||||
it('prompts skip-empty confirmation on empty submit and dismisses on cancel', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={state()}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write('\r');
|
||||
await waitForInkInput();
|
||||
expect(normalizeFrameWrap(lastFrame())).toContain(
|
||||
'Nothing selected. Skip this step? Press Enter to skip or Escape to go back.',
|
||||
);
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
|
||||
stdin.write('n');
|
||||
await waitForInkInput();
|
||||
expect(lastFrame()).not.toContain('Nothing selected. Skip this step?');
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
|
||||
stdin.write('\r');
|
||||
await waitForInkInput();
|
||||
expect(lastFrame()).toContain('Nothing selected. Skip this step?');
|
||||
|
||||
stdin.write('\r');
|
||||
await waitForInkInput();
|
||||
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
|
||||
});
|
||||
|
||||
it('renders row-window overflow indicators when the visible list is clipped', async () => {
|
||||
const onExit = vi.fn();
|
||||
const initialState = buildInitialState({
|
||||
tree: buildPickerTree(manyPages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
initialState.expanded = new Set([IDS.engineering]);
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={13}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('↓ 4 more');
|
||||
|
||||
stdin.write('\u001B[B');
|
||||
stdin.write('\u001B[B');
|
||||
stdin.write('\u001B[B');
|
||||
stdin.write('\u001B[B');
|
||||
await waitForInkInput();
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('↑ ');
|
||||
expect(frame).toContain('↓ ');
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns quit without saving', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={state()}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write('\u0003');
|
||||
await waitForInkInput();
|
||||
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderNotionPickerTui', () => {
|
||||
it('returns the app result from the Ink runtime', async () => {
|
||||
const io = {
|
||||
stdin: { isTTY: true, setRawMode: vi.fn() },
|
||||
stdout: { isTTY: true, columns: 100, rows: 24, write: vi.fn() },
|
||||
stderr: { write: vi.fn() },
|
||||
};
|
||||
const renderInk = vi.fn((_tree: ReactNode, _options: NotionPickerInkRenderOptions) => fakeInkInstance());
|
||||
|
||||
await expect(
|
||||
renderNotionPickerTui(
|
||||
{
|
||||
initialState: state(),
|
||||
connectionId: 'notion-main',
|
||||
workspaceLabel: 'Design Workspace',
|
||||
cappedAtCount: null,
|
||||
currentCrawlMode: 'selected_roots',
|
||||
},
|
||||
io,
|
||||
{ renderInk },
|
||||
),
|
||||
).resolves.toEqual({ kind: 'quit' });
|
||||
expect(renderInk).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('sanitizes render errors and tells the user to use no-input mode', async () => {
|
||||
expect(sanitizeNotionPickerTuiError(new Error('token=secret https://api.notion.com/v1/search'))).toBe(
|
||||
'[redacted] [redacted-url]',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to quit with a scripted-mode hint when Ink cannot initialize', async () => {
|
||||
let stderr = '';
|
||||
const io = {
|
||||
stdin: { isTTY: false, setRawMode: vi.fn() },
|
||||
stdout: { isTTY: false, columns: 100, rows: 24, write: vi.fn() },
|
||||
stderr: {
|
||||
write(chunk: string) {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
renderNotionPickerTui(
|
||||
{
|
||||
initialState: state(),
|
||||
connectionId: 'notion-main',
|
||||
workspaceLabel: 'Design Workspace',
|
||||
cappedAtCount: null,
|
||||
currentCrawlMode: 'selected_roots',
|
||||
},
|
||||
io,
|
||||
{
|
||||
renderInk: vi.fn(() => {
|
||||
throw new Error('token=secret');
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ kind: 'quit' });
|
||||
expect(stderr).toContain('Use --no-input --notion-root-page-id <UUID> for scripted mode');
|
||||
expect(stderr).not.toContain('secret');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { PickerState } from './tree-picker-state.js';
|
||||
import type { TreePickerChrome, TreePickerResult, TreePickerTuiIo } from './tree-picker-tui.js';
|
||||
import {
|
||||
discoverNotionPickerPages,
|
||||
notionPickerPageFromSearchResult,
|
||||
|
|
@ -6,8 +8,6 @@ import {
|
|||
pickNotionRootPages,
|
||||
resolveNotionWorkspaceLabel,
|
||||
type NotionPickerApi,
|
||||
type PickerRenderInput,
|
||||
type PickerRenderResult,
|
||||
} from './notion-page-picker.js';
|
||||
|
||||
function makeIo() {
|
||||
|
|
@ -162,20 +162,27 @@ describe('Notion page picker helpers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
type RenderPickerArgs = [TreePickerChrome, PickerState, TreePickerTuiIo];
|
||||
|
||||
describe('pickNotionRootPages', () => {
|
||||
it('discovers visible pages, warns about stale roots, renders the TUI, and returns selected roots', async () => {
|
||||
const api = fakeNotionApi([
|
||||
notionPage(PAGE_IDS.engineering, 'Engineering'),
|
||||
notionPage(PAGE_IDS.architecture, 'Architecture', PAGE_IDS.engineering),
|
||||
]);
|
||||
const renderPicker = vi.fn(async (input: PickerRenderInput): Promise<PickerRenderResult> => {
|
||||
expect(input.connectionId).toBe('notion-main');
|
||||
expect(input.workspaceLabel).toBe('Design Workspace');
|
||||
expect(input.currentCrawlMode).toBe('all_accessible');
|
||||
expect(input.cappedAtCount).toBeNull();
|
||||
expect(input.initialState.preLoadWarnings).toEqual(['1 stored root_page_ids no longer visible']);
|
||||
return { kind: 'save', rootPageIds: [PAGE_IDS.engineering] };
|
||||
});
|
||||
const renderPicker = vi.fn(
|
||||
async (chrome: TreePickerChrome, state: PickerState): Promise<TreePickerResult> => {
|
||||
expect(chrome.title).toBe('Select Notion pages to ingest');
|
||||
expect(chrome.subtitleLines).toEqual(['Workspace: Design Workspace']);
|
||||
expect(chrome.warningLines ?? []).toEqual([]);
|
||||
expect(chrome.confirmSaveMessage).toBeTypeOf('function');
|
||||
expect(state.requireConfirmOnSave).toBe(true);
|
||||
expect(state.preLoadWarnings).toEqual([
|
||||
'1 stored root_page_ids no longer visible - they will be removed if you save',
|
||||
]);
|
||||
return { kind: 'save', selectedIds: [PAGE_IDS.engineering] };
|
||||
},
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
|
|
@ -223,7 +230,7 @@ describe('pickNotionRootPages', () => {
|
|||
makeIo().io,
|
||||
{
|
||||
createNotionApi,
|
||||
renderPicker: vi.fn(async (): Promise<PickerRenderResult> => ({ kind: 'quit' })),
|
||||
renderPicker: vi.fn(async (): Promise<TreePickerResult> => ({ kind: 'quit' })),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ kind: 'back' });
|
||||
|
|
@ -243,11 +250,13 @@ describe('pickNotionRootPages', () => {
|
|||
.mockRejectedValueOnce(new Error('rate limit after first page')),
|
||||
retrieveBotUser: vi.fn(async () => ({ name: 'Notion bot', bot: { workspace_name: 'Design Workspace' } })),
|
||||
};
|
||||
let renderInput: PickerRenderInput | undefined;
|
||||
const renderPicker = vi.fn(async (input: PickerRenderInput): Promise<PickerRenderResult> => {
|
||||
renderInput = input;
|
||||
return { kind: 'quit' };
|
||||
});
|
||||
let captured: RenderPickerArgs | undefined;
|
||||
const renderPicker = vi.fn(
|
||||
async (chrome: TreePickerChrome, state: PickerState, io: TreePickerTuiIo): Promise<TreePickerResult> => {
|
||||
captured = [chrome, state, io];
|
||||
return { kind: 'quit' };
|
||||
},
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
|
|
@ -271,11 +280,12 @@ describe('pickNotionRootPages', () => {
|
|||
).resolves.toEqual({ kind: 'back' });
|
||||
|
||||
expect(renderPicker).toHaveBeenCalledOnce();
|
||||
if (!renderInput) {
|
||||
if (!captured) {
|
||||
throw new Error('renderPicker was not called');
|
||||
}
|
||||
expect(renderInput.initialState.preLoadWarnings).toEqual(['Notion search stopped early: rate limit after first page']);
|
||||
expect(renderInput.initialState.tree.map((node) => node.title)).toEqual(['Engineering']);
|
||||
const [, state] = captured;
|
||||
expect(state.preLoadWarnings).toEqual(['Notion search stopped early: rate limit after first page']);
|
||||
expect(state.tree.map((node) => node.title)).toEqual(['Engineering']);
|
||||
expect(io.stderr()).toContain('Notion search stopped early: rate limit after first page');
|
||||
});
|
||||
|
||||
|
|
@ -300,7 +310,7 @@ describe('pickNotionRootPages', () => {
|
|||
}),
|
||||
retrieveBotUser: vi.fn(async () => ({ name: 'Notion bot' })),
|
||||
})),
|
||||
renderPicker: vi.fn(async (): Promise<PickerRenderResult> => ({ kind: 'quit' })),
|
||||
renderPicker: vi.fn(async (): Promise<TreePickerResult> => ({ kind: 'quit' })),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ kind: 'unavailable', message: 'Notion API unavailable' });
|
||||
|
|
|
|||
|
|
@ -3,13 +3,19 @@ import { type NotionApi, type NotionBotInfo, NotionClient } from '@ktx/context/i
|
|||
import type { KtxProjectConnectionConfig } from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './notion-page-picker-tree.js';
|
||||
import {
|
||||
type NotionPickerTuiIo,
|
||||
type PickerRenderInput,
|
||||
type PickerRenderResult,
|
||||
renderNotionPickerTui,
|
||||
} from './notion-page-picker-tui.js';
|
||||
buildInitialState,
|
||||
buildPickerTree,
|
||||
flattenSelection,
|
||||
type PickerState,
|
||||
type TreePickerNodeInput,
|
||||
} from './tree-picker-state.js';
|
||||
import {
|
||||
renderTreePickerTui,
|
||||
type TreePickerChrome,
|
||||
type TreePickerResult,
|
||||
type TreePickerTuiIo,
|
||||
} from './tree-picker-tui.js';
|
||||
|
||||
profileMark('module:notion-page-picker');
|
||||
|
||||
|
|
@ -19,8 +25,6 @@ export interface PickNotionRootPagesArgs {
|
|||
}
|
||||
|
||||
export type NotionPickerApi = Pick<NotionApi, 'search' | 'retrieveBotUser'>;
|
||||
export type { PickerRenderInput, PickerRenderResult };
|
||||
|
||||
export type NotionRootPagePickResult =
|
||||
| { kind: 'selected'; rootPageIds: string[] }
|
||||
| { kind: 'back' }
|
||||
|
|
@ -29,10 +33,16 @@ export type NotionRootPagePickResult =
|
|||
export interface NotionRootPagePickerDeps {
|
||||
env?: Record<string, string | undefined>;
|
||||
createNotionApi?: (authToken: string) => NotionPickerApi;
|
||||
renderPicker?: (input: PickerRenderInput, io: NotionPickerTuiIo) => Promise<PickerRenderResult>;
|
||||
renderPicker?: (
|
||||
chrome: TreePickerChrome,
|
||||
initialState: PickerState,
|
||||
io: TreePickerTuiIo,
|
||||
) => Promise<TreePickerResult>;
|
||||
}
|
||||
|
||||
const NOTION_PICKER_PAGE_CAP = 5000;
|
||||
const NOTION_SCRIPTED_MODE_HINT =
|
||||
'Notion picker requires a TTY. Use --no-input --notion-root-page-id <UUID> for scripted mode.';
|
||||
|
||||
function assertSafeNotionPickerConnectionId(connectionId: string): void {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) {
|
||||
|
|
@ -50,6 +60,14 @@ export function normalizeNotionPageId(value: string): string {
|
|||
return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(16, 20)}-${lower.slice(20)}`;
|
||||
}
|
||||
|
||||
function tryNormalizeNotionPageId(value: string): string | null {
|
||||
try {
|
||||
return normalizeNotionPageId(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
|
@ -88,7 +106,7 @@ function extractParentPageId(page: Record<string, unknown>): string | null {
|
|||
return normalizeNotionPageId(parent.page_id);
|
||||
}
|
||||
|
||||
export function notionPickerPageFromSearchResult(result: Record<string, unknown>): NotionPickerPageInput {
|
||||
export function notionPickerPageFromSearchResult(result: Record<string, unknown>): TreePickerNodeInput {
|
||||
const id = typeof result.id === 'string' ? normalizeNotionPageId(result.id) : '';
|
||||
if (!id) {
|
||||
throw new Error('Notion page search result is missing id');
|
||||
|
|
@ -104,9 +122,9 @@ export function notionPickerPageFromSearchResult(result: Record<string, unknown>
|
|||
export async function discoverNotionPickerPages(
|
||||
api: NotionPickerApi,
|
||||
options: { cap?: number } = {},
|
||||
): Promise<{ pages: NotionPickerPageInput[]; cappedAtCount: number | null; warnings: string[] }> {
|
||||
): Promise<{ pages: TreePickerNodeInput[]; cappedAtCount: number | null; warnings: string[] }> {
|
||||
const cap = options.cap ?? NOTION_PICKER_PAGE_CAP;
|
||||
const pages: NotionPickerPageInput[] = [];
|
||||
const pages: TreePickerNodeInput[] = [];
|
||||
const warnings: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
|
||||
|
|
@ -171,6 +189,33 @@ function notionCrawlMode(connection: KtxProjectConnectionConfig): 'all_accessibl
|
|||
return connection.crawl_mode === 'all_accessible' ? 'all_accessible' : 'selected_roots';
|
||||
}
|
||||
|
||||
function selectedPageCountText(count: number): string {
|
||||
return `${count} selected ${count === 1 ? 'page' : 'pages'}`;
|
||||
}
|
||||
|
||||
function notionChrome(args: {
|
||||
workspaceLabel: string;
|
||||
cappedAtCount: number | null;
|
||||
currentCrawlMode: 'all_accessible' | 'selected_roots';
|
||||
}): TreePickerChrome {
|
||||
const warningLines: string[] = [];
|
||||
if (args.cappedAtCount) {
|
||||
warningLines.push(`${args.cappedAtCount}-page cap reached - some pages not shown`);
|
||||
}
|
||||
return {
|
||||
title: 'Select Notion pages to ingest',
|
||||
subtitleLines: [`Workspace: ${args.workspaceLabel}`],
|
||||
warningLines,
|
||||
confirmSaveMessage:
|
||||
args.currentCrawlMode === 'all_accessible'
|
||||
? (state) =>
|
||||
`Switch crawl_mode from all_accessible to selected_roots? Will limit ingest to ${selectedPageCountText(
|
||||
flattenSelection(state.checked, state.byId).length,
|
||||
)}. Press Enter to confirm or Escape to go back.`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pickNotionRootPages(
|
||||
args: PickNotionRootPagesArgs,
|
||||
io: KtxCliIo = process,
|
||||
|
|
@ -190,10 +235,14 @@ export async function pickNotionRootPages(
|
|||
const api = deps.createNotionApi ? deps.createNotionApi(authToken) : new NotionClient(authToken);
|
||||
const discovery = await discoverNotionPickerPages(api);
|
||||
const tree = buildPickerTree(discovery.pages);
|
||||
const normalizedExistingIds = stringArray(args.connection.root_page_ids)
|
||||
.map((raw) => tryNormalizeNotionPageId(raw))
|
||||
.filter((id): id is string => id !== null);
|
||||
const initialState = buildInitialState({
|
||||
tree,
|
||||
existingRootPageIds: stringArray(args.connection.root_page_ids),
|
||||
currentCrawlMode: crawlMode,
|
||||
existingSelectedIds: normalizedExistingIds,
|
||||
requireConfirmOnSave: crawlMode === 'all_accessible',
|
||||
staleWarning: (count) => `${count} stored root_page_ids no longer visible - they will be removed if you save`,
|
||||
});
|
||||
const preLoadWarnings = [...discovery.warnings, ...initialState.preLoadWarnings];
|
||||
const renderState =
|
||||
|
|
@ -207,23 +256,25 @@ export async function pickNotionRootPages(
|
|||
io.stderr.write(`${warning}\n`);
|
||||
}
|
||||
const workspaceLabel = await resolveNotionWorkspaceLabel(api, args.connectionId);
|
||||
const result = await (deps.renderPicker ?? renderNotionPickerTui)(
|
||||
{
|
||||
initialState: renderState,
|
||||
connectionId: args.connectionId,
|
||||
workspaceLabel,
|
||||
cappedAtCount: discovery.cappedAtCount,
|
||||
currentCrawlMode: crawlMode,
|
||||
},
|
||||
io as NotionPickerTuiIo,
|
||||
);
|
||||
const chrome = notionChrome({
|
||||
workspaceLabel,
|
||||
cappedAtCount: discovery.cappedAtCount,
|
||||
currentCrawlMode: crawlMode,
|
||||
});
|
||||
const renderPicker =
|
||||
deps.renderPicker ??
|
||||
((chromeArg, state, ioArg) =>
|
||||
renderTreePickerTui({ chrome: chromeArg, initialState: state }, ioArg, {
|
||||
scriptedModeHint: NOTION_SCRIPTED_MODE_HINT,
|
||||
}));
|
||||
const result = await renderPicker(chrome, renderState, io as TreePickerTuiIo);
|
||||
if (result.kind === 'quit') {
|
||||
return { kind: 'back' };
|
||||
}
|
||||
if (result.rootPageIds.length === 0) {
|
||||
if (result.selectedIds.length === 0) {
|
||||
return { kind: 'unavailable', message: 'Notion picker did not return any selected pages.' };
|
||||
}
|
||||
return { kind: 'selected', rootPageIds: result.rootPageIds };
|
||||
return { kind: 'selected', rootPageIds: result.selectedIds };
|
||||
} catch (error) {
|
||||
return { kind: 'unavailable', message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,15 @@ import { initKtxProject, parseKtxProjectConfig, readKtxSetupState, writeKtxSetup
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type KtxSetupDatabaseDriver,
|
||||
type KtxSetupDatabasesDeps,
|
||||
type KtxSetupDatabasesPromptAdapter,
|
||||
runKtxSetupDatabasesStep,
|
||||
} from './setup-databases.js';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import type {
|
||||
DatabaseScopePickResult,
|
||||
PickDatabaseScopeArgs,
|
||||
} from './database-tree-picker.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
|
|
@ -32,6 +37,43 @@ function makeIo() {
|
|||
};
|
||||
}
|
||||
|
||||
type ScopePick =
|
||||
| 'back'
|
||||
| 'enable-all'
|
||||
| { schemas: string[]; tables: string[] };
|
||||
|
||||
interface PickerStubs {
|
||||
pickDatabaseScope: KtxSetupDatabasesDeps['pickDatabaseScope'];
|
||||
scopeCalls: PickDatabaseScopeArgs[];
|
||||
}
|
||||
|
||||
function makePickerStubs(options: { scopes?: ScopePick[] } = {}): PickerStubs {
|
||||
const queue: ScopePick[] = [...(options.scopes ?? [])];
|
||||
const scopeCalls: PickDatabaseScopeArgs[] = [];
|
||||
return {
|
||||
scopeCalls,
|
||||
pickDatabaseScope: vi.fn(async (args: PickDatabaseScopeArgs): Promise<DatabaseScopePickResult> => {
|
||||
scopeCalls.push(args);
|
||||
const next = queue.shift();
|
||||
if (next === undefined || next === 'enable-all') {
|
||||
const enabledTables = args.discovered.map((t) => `${t.schema}.${t.name}`);
|
||||
const activeSchemas = args.supportsSchemaScope
|
||||
? Array.from(new Set(args.discovered.map((t) => t.schema)))
|
||||
: [];
|
||||
return { kind: 'selected', activeSchemas, enabledTables };
|
||||
}
|
||||
if (next === 'back') {
|
||||
return { kind: 'back' };
|
||||
}
|
||||
return {
|
||||
kind: 'selected',
|
||||
activeSchemas: args.supportsSchemaScope ? next.schemas : [],
|
||||
enabledTables: next.tables,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makePromptAdapter(options: {
|
||||
multiselectValues?: string[][];
|
||||
selectValues?: string[];
|
||||
|
|
@ -819,7 +861,6 @@ describe('setup databases step', () => {
|
|||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({
|
||||
textValues: ['env:DATABASE_URL'],
|
||||
multiselectValues: [['analytics']],
|
||||
});
|
||||
let primaryMenuCount = 0;
|
||||
vi.mocked(prompts.select).mockImplementation(async (options) => {
|
||||
|
|
@ -835,11 +876,21 @@ describe('setup databases step', () => {
|
|||
const scanConnection = vi.fn(async () => 0);
|
||||
const listSchemas = vi.fn(async () => ['analytics', 'public']);
|
||||
const listTables = vi.fn(async () => [{ schema: 'analytics', name: 'customers', kind: 'table' as const }]);
|
||||
const pickers = makePickerStubs({
|
||||
scopes: [{ schemas: ['analytics'], tables: ['analytics.customers'] }],
|
||||
});
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
makeIo().io,
|
||||
{ prompts, testConnection, scanConnection, listSchemas, listTables },
|
||||
{
|
||||
prompts,
|
||||
testConnection,
|
||||
scanConnection,
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
|
||||
|
|
@ -848,7 +899,7 @@ describe('setup databases step', () => {
|
|||
placeholder: 'env:DATABASE_URL',
|
||||
initialValue: 'env:DATABASE_URL',
|
||||
});
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse');
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse', ['analytics', 'public']);
|
||||
expect(testConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
|
||||
expect(scanConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
|
|
@ -882,7 +933,6 @@ describe('setup databases step', () => {
|
|||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({
|
||||
textValues: ['env:DATABASE_URL'],
|
||||
multiselectValues: [['public'], ['public.customers', 'public.orders']],
|
||||
});
|
||||
let primaryMenuCount = 0;
|
||||
vi.mocked(prompts.select).mockImplementation(async (options) => {
|
||||
|
|
@ -892,7 +942,6 @@ describe('setup databases step', () => {
|
|||
}
|
||||
if (options.message === 'Primary source to edit') return 'warehouse';
|
||||
if (options.message === 'How do you want to connect to PostgreSQL?') return 'url';
|
||||
if (options.message.startsWith('Tables found in selected schemas')) return 'customize';
|
||||
return 'back';
|
||||
});
|
||||
const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
|
||||
|
|
@ -901,6 +950,9 @@ describe('setup databases step', () => {
|
|||
{ schema: 'public', name: 'orders', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'products', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({
|
||||
scopes: [{ schemas: ['public'], tables: ['public.customers', 'public.orders'] }],
|
||||
});
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
|
|
@ -911,29 +963,17 @@ describe('setup databases step', () => {
|
|||
scanConnection: vi.fn(async () => 0),
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
|
||||
expect(prompts.multiselect).toHaveBeenNthCalledWith(1, {
|
||||
message: expect.stringContaining('PostgreSQL schemas to scan'),
|
||||
options: [
|
||||
{ value: 'orbit_analytics', label: 'orbit_analytics' },
|
||||
{ value: 'orbit_raw', label: 'orbit_raw' },
|
||||
{ value: 'public', label: 'public' },
|
||||
],
|
||||
initialValues: ['public'],
|
||||
required: true,
|
||||
});
|
||||
expect(prompts.multiselect).toHaveBeenNthCalledWith(2, {
|
||||
message: expect.stringContaining('Tables to enable for warehouse'),
|
||||
options: [
|
||||
{ value: 'public.customers', label: 'public.customers' },
|
||||
{ value: 'public.orders', label: 'public.orders' },
|
||||
{ value: 'public.products', label: 'public.products' },
|
||||
],
|
||||
initialValues: ['public.customers', 'public.orders'],
|
||||
required: true,
|
||||
expect(pickers.scopeCalls).toHaveLength(1);
|
||||
expect(pickers.scopeCalls[0]).toMatchObject({
|
||||
connectionId: 'warehouse',
|
||||
schemaNoun: 'schema',
|
||||
supportsSchemaScope: true,
|
||||
existing: { enabledTables: ['public.customers', 'public.orders'] },
|
||||
});
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections.warehouse).toMatchObject({
|
||||
|
|
@ -965,7 +1005,6 @@ describe('setup databases step', () => {
|
|||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({
|
||||
textValues: ['env:DATABASE_URL'],
|
||||
multiselectValues: [['back']],
|
||||
});
|
||||
let primaryMenuCount = 0;
|
||||
vi.mocked(prompts.select).mockImplementation(async (options) => {
|
||||
|
|
@ -980,19 +1019,29 @@ describe('setup databases step', () => {
|
|||
const testConnection = vi.fn(async () => 0);
|
||||
const scanConnection = vi.fn(async () => 0);
|
||||
const listSchemas = vi.fn(async () => ['analytics', 'public']);
|
||||
const listTables = vi.fn(async () => [{ schema: 'analytics', name: 'customers', kind: 'table' as const }]);
|
||||
const listTables = vi.fn(async () => [
|
||||
{ schema: 'analytics', name: 'customers', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'orders', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({ scopes: ['back'] });
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
makeIo().io,
|
||||
{ prompts, testConnection, scanConnection, listSchemas, listTables },
|
||||
{
|
||||
prompts,
|
||||
testConnection,
|
||||
scanConnection,
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
|
||||
expect(primaryMenuCount).toBe(2);
|
||||
expect(testConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
|
||||
expect(scanConnection).not.toHaveBeenCalled();
|
||||
expect(listTables).not.toHaveBeenCalled();
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections.warehouse).toMatchObject({
|
||||
url: 'env:DATABASE_URL',
|
||||
|
|
@ -1031,7 +1080,6 @@ describe('setup databases step', () => {
|
|||
}
|
||||
if (options.message === 'Primary source to edit') return 'warehouse';
|
||||
if (options.message === 'How do you want to connect to PostgreSQL?') return 'url';
|
||||
if (options.message.startsWith('Tables found in selected schemas')) return 'back';
|
||||
return 'back';
|
||||
});
|
||||
const testConnection = vi.fn(async () => 0);
|
||||
|
|
@ -1041,16 +1089,24 @@ describe('setup databases step', () => {
|
|||
{ schema: 'public', name: 'customers', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'orders', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({ scopes: ['back'] });
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
makeIo().io,
|
||||
{ prompts, testConnection, scanConnection, listSchemas, listTables },
|
||||
{
|
||||
prompts,
|
||||
testConnection,
|
||||
scanConnection,
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
|
||||
expect(primaryMenuCount).toBe(2);
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse');
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse', ['public']);
|
||||
expect(scanConnection).not.toHaveBeenCalled();
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections.warehouse).toMatchObject({
|
||||
|
|
@ -1083,19 +1139,18 @@ describe('setup databases step', () => {
|
|||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({
|
||||
textValues: ['env:DATABASE_URL'],
|
||||
multiselectValues: [['public']],
|
||||
});
|
||||
vi.mocked(prompts.select).mockImplementation(async (options) => {
|
||||
if (options.message === 'Primary sources already configured: warehouse\nWhat would you like to do?') return 'edit';
|
||||
if (options.message === 'Primary source to edit') return 'warehouse';
|
||||
if (options.message === 'How do you want to connect to PostgreSQL?') return 'url';
|
||||
if (options.message.startsWith('Tables found in selected schemas')) return 'all';
|
||||
return 'back';
|
||||
});
|
||||
const listTables = vi.fn(async () => [
|
||||
{ schema: 'public', name: 'customers', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'orders', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({ scopes: ['enable-all'] });
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
|
|
@ -1105,6 +1160,7 @@ describe('setup databases step', () => {
|
|||
testConnection: vi.fn(async () => 0),
|
||||
scanConnection: vi.fn(async () => 1),
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -1390,7 +1446,6 @@ describe('setup databases step', () => {
|
|||
const prompts = makePromptAdapter({
|
||||
selectValues: ['url'],
|
||||
textValues: ['', 'env:DATABASE_URL'],
|
||||
multiselectValues: [['orbit_analytics', 'orbit_raw']],
|
||||
});
|
||||
const testConnection = vi.fn(async () => 0);
|
||||
const scanConnection = vi.fn(async asyncScanProjectDir => {
|
||||
|
|
@ -1401,6 +1456,19 @@ describe('setup databases step', () => {
|
|||
return 0;
|
||||
});
|
||||
const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
|
||||
const listTables = vi.fn(async () => [
|
||||
{ schema: 'orbit_analytics', name: 'events', kind: 'table' as const },
|
||||
{ schema: 'orbit_raw', name: 'inputs', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'misc', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({
|
||||
scopes: [
|
||||
{
|
||||
schemas: ['orbit_analytics', 'orbit_raw'],
|
||||
tables: ['orbit_analytics.events', 'orbit_raw.inputs'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{
|
||||
|
|
@ -1411,20 +1479,24 @@ describe('setup databases step', () => {
|
|||
skipDatabases: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts, testConnection, scanConnection, listSchemas },
|
||||
{
|
||||
prompts,
|
||||
testConnection,
|
||||
scanConnection,
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(listSchemas).toHaveBeenCalledWith(tempDir, 'postgres-warehouse');
|
||||
expect(prompts.multiselect).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('PostgreSQL schemas to scan'),
|
||||
options: [
|
||||
{ value: 'orbit_analytics', label: 'orbit_analytics' },
|
||||
{ value: 'orbit_raw', label: 'orbit_raw' },
|
||||
{ value: 'public', label: 'public' },
|
||||
],
|
||||
initialValues: ['orbit_analytics', 'orbit_raw'],
|
||||
required: true,
|
||||
expect(pickers.scopeCalls).toHaveLength(1);
|
||||
expect(pickers.scopeCalls[0]).toMatchObject({
|
||||
connectionId: 'postgres-warehouse',
|
||||
schemaNoun: 'schema',
|
||||
schemaNounPlural: 'schemas',
|
||||
defaultSchemas: ['orbit_analytics', 'orbit_raw'],
|
||||
});
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections['postgres-warehouse']).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ import {
|
|||
import type { KtxTableListEntry } from '@ktx/context/scan';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { runKtxConnection } from './connection.js';
|
||||
import {
|
||||
pickDatabaseScope as defaultPickDatabaseScope,
|
||||
type DatabaseScopePickResult,
|
||||
type PickDatabaseScopeArgs,
|
||||
} from './database-tree-picker.js';
|
||||
import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { runKtxScan } from './scan.js';
|
||||
import { writeProjectLocalSecretReference } from './setup-secrets.js';
|
||||
|
|
@ -90,7 +95,8 @@ export interface KtxSetupDatabasesDeps {
|
|||
scanConnection?: (projectDir: string, connectionId: string, io: KtxCliIo) => Promise<number>;
|
||||
rebuildNativeSqlite?: (io: KtxCliIo) => Promise<number>;
|
||||
listSchemas?: (projectDir: string, connectionId: string) => Promise<string[]>;
|
||||
listTables?: (projectDir: string, connectionId: string) => Promise<KtxTableListEntry[]>;
|
||||
listTables?: (projectDir: string, connectionId: string, schemas?: string[]) => Promise<KtxTableListEntry[]>;
|
||||
pickDatabaseScope?: (args: PickDatabaseScopeArgs, io: KtxCliIo) => Promise<DatabaseScopePickResult>;
|
||||
historicSqlProbe?: KtxSetupHistoricSqlProbe;
|
||||
}
|
||||
|
||||
|
|
@ -363,11 +369,15 @@ function configuredSchemas(connection: KtxProjectConnectionConfig | undefined, d
|
|||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
async function defaultListTables(projectDir: string, connectionId: string): Promise<KtxTableListEntry[]> {
|
||||
async function defaultListTables(
|
||||
projectDir: string,
|
||||
connectionId: string,
|
||||
schemasOverride?: string[],
|
||||
): Promise<KtxTableListEntry[]> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const connection = project.config.connections[connectionId];
|
||||
const driver = normalizeDriver(connection?.driver);
|
||||
const schemas = driver ? configuredSchemas(connection, driver) : undefined;
|
||||
const schemas = schemasOverride ?? (driver ? configuredSchemas(connection, driver) : undefined);
|
||||
|
||||
if (driver === 'postgres') {
|
||||
const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('@ktx/connector-postgres');
|
||||
|
|
@ -1271,145 +1281,98 @@ async function writeScopeConfig(input: {
|
|||
});
|
||||
}
|
||||
|
||||
async function clearScopeConfig(projectDir: string, connectionId: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection) return;
|
||||
const driver = normalizeDriver(connection.driver);
|
||||
if (!driver) return;
|
||||
const spec = SCOPE_DISCOVERY_SPECS[driver];
|
||||
if (!spec) return;
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(connection).filter(
|
||||
([key]) => key !== spec.configArrayField && key !== spec.configSingleField && key !== 'enabled_tables',
|
||||
),
|
||||
) as KtxProjectConnectionConfig;
|
||||
await writeConnectionConfig({ projectDir, connectionId, connection: cleaned });
|
||||
}
|
||||
|
||||
async function maybeConfigureSchemaScope(input: {
|
||||
async function maybeConfigureDatabaseScope(input: {
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
args: KtxSetupDatabasesArgs;
|
||||
prompts: KtxSetupDatabasesPromptAdapter;
|
||||
deps: KtxSetupDatabasesDeps;
|
||||
io: KtxCliIo;
|
||||
forcePrompt?: boolean;
|
||||
}): Promise<ConnectionSetupStatus> {
|
||||
const project = await loadKtxProject({ projectDir: input.projectDir });
|
||||
const connection = project.config.connections[input.connectionId];
|
||||
const driver = normalizeDriver(connection?.driver);
|
||||
if (!driver) return 'ready';
|
||||
|
||||
const spec = SCOPE_DISCOVERY_SPECS[driver];
|
||||
if (!spec) return 'ready';
|
||||
|
||||
const arrayVal = connection?.[spec.configArrayField];
|
||||
if (Array.isArray(arrayVal) && arrayVal.length > 0 && input.forcePrompt !== true) {
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
if (input.args.databaseSchemas.length > 0) {
|
||||
await writeScopeConfig({
|
||||
projectDir: input.projectDir,
|
||||
connectionId: input.connectionId,
|
||||
values: input.args.databaseSchemas,
|
||||
spec,
|
||||
});
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
writeSetupSection(input.io, `Discovering ${spec.promptLabel.toLowerCase()}`, [
|
||||
`Connecting to ${input.connectionId}…`,
|
||||
]);
|
||||
|
||||
let discovered: string[];
|
||||
try {
|
||||
discovered = unique(
|
||||
await (input.deps.listSchemas ?? defaultListSchemas)(input.projectDir, input.connectionId),
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
input.io.stderr.write(
|
||||
input.forcePrompt === true
|
||||
? `Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; edit was not saved. ` +
|
||||
`Pass --database-schema to set it explicitly. ${detail}\n`
|
||||
: `Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; continuing with existing ${spec.noun} scope. ` +
|
||||
`Pass --database-schema to set it explicitly. ${detail}\n`,
|
||||
);
|
||||
return input.forcePrompt === true ? 'failed' : 'ready';
|
||||
}
|
||||
if (discovered.length === 0) {
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
let selected: string[];
|
||||
if (input.args.inputMode === 'disabled' || discovered.length === 1) {
|
||||
const preconfigured = configuredScopeValues(connection, spec).filter((v) => discovered.includes(v));
|
||||
selected = preconfigured.length > 0 ? preconfigured : discovered;
|
||||
} else {
|
||||
const preconfigured = configuredScopeValues(connection, spec).filter((v) => discovered.includes(v));
|
||||
const initialValues = preconfigured.length > 0 ? preconfigured : spec.defaultSelection(discovered);
|
||||
const choices = await input.prompts.multiselect({
|
||||
message: withMultiselectNavigation(
|
||||
`${spec.promptLabel} to scan\n` +
|
||||
`KTX found multiple ${spec.nounPlural}. Select every ${spec.noun} agents should use.`,
|
||||
),
|
||||
options: discovered.map((v) => ({ value: v, label: v })),
|
||||
initialValues,
|
||||
required: true,
|
||||
});
|
||||
if (choices.includes('back')) {
|
||||
return 'back';
|
||||
}
|
||||
selected = choices.length > 0 ? choices : initialValues;
|
||||
}
|
||||
|
||||
await writeScopeConfig({
|
||||
projectDir: input.projectDir,
|
||||
connectionId: input.connectionId,
|
||||
values: selected,
|
||||
spec,
|
||||
});
|
||||
const capitalNounPlural = spec.nounPlural[0]!.toUpperCase() + spec.nounPlural.slice(1);
|
||||
writeSetupSection(input.io, `${capitalNounPlural} saved for ${input.connectionId}`, [
|
||||
`✓ ${selected.join(', ')}`,
|
||||
]);
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
async function maybeConfigureTableScope(input: {
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
args: KtxSetupDatabasesArgs;
|
||||
prompts: KtxSetupDatabasesPromptAdapter;
|
||||
io: KtxCliIo;
|
||||
deps: KtxSetupDatabasesDeps;
|
||||
forcePrompt?: boolean;
|
||||
}): Promise<ConnectionSetupStatus> {
|
||||
const project = await loadKtxProject({ projectDir: input.projectDir });
|
||||
const connection = project.config.connections[input.connectionId];
|
||||
const driver = normalizeDriver(connection?.driver);
|
||||
if (!driver || driver === 'sqlite') return 'ready';
|
||||
|
||||
const spec = SCOPE_DISCOVERY_SPECS[driver];
|
||||
const existingTables = connection?.enabled_tables;
|
||||
if (Array.isArray(existingTables) && existingTables.length > 0 && input.forcePrompt !== true) {
|
||||
const hasExistingTables = Array.isArray(existingTables) && existingTables.length > 0;
|
||||
const existingScope = spec ? configuredScopeValues(connection, spec) : [];
|
||||
const hasExistingScope = !spec || existingScope.length > 0;
|
||||
|
||||
if (hasExistingTables && hasExistingScope && input.forcePrompt !== true) {
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
const cliSchemas = input.args.databaseSchemas;
|
||||
|
||||
if (input.args.inputMode === 'disabled') {
|
||||
if (spec) {
|
||||
let scopeToWrite: string[] = cliSchemas;
|
||||
if (scopeToWrite.length === 0) {
|
||||
try {
|
||||
scopeToWrite = unique(
|
||||
await (input.deps.listSchemas ?? defaultListSchemas)(input.projectDir, input.connectionId),
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
input.io.stderr.write(
|
||||
`Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${detail}\n`,
|
||||
);
|
||||
return 'ready';
|
||||
}
|
||||
}
|
||||
if (scopeToWrite.length > 0) {
|
||||
await writeScopeConfig({
|
||||
projectDir: input.projectDir,
|
||||
connectionId: input.connectionId,
|
||||
values: scopeToWrite,
|
||||
spec,
|
||||
});
|
||||
const capitalNounPlural = spec.nounPlural[0]!.toUpperCase() + spec.nounPlural.slice(1);
|
||||
writeSetupSection(input.io, `${capitalNounPlural} saved for ${input.connectionId}`, [
|
||||
`✓ ${scopeToWrite.join(', ')}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
if (spec && cliSchemas.length > 0) {
|
||||
await writeScopeConfig({
|
||||
projectDir: input.projectDir,
|
||||
connectionId: input.connectionId,
|
||||
values: cliSchemas,
|
||||
spec,
|
||||
});
|
||||
}
|
||||
|
||||
writeSetupSection(input.io, 'Discovering tables', [
|
||||
`Connecting to ${input.connectionId}…`,
|
||||
]);
|
||||
|
||||
const schemasFilter = await (async (): Promise<string[]> => {
|
||||
if (cliSchemas.length > 0) return cliSchemas;
|
||||
if (!spec) return [];
|
||||
try {
|
||||
return unique(
|
||||
await (input.deps.listSchemas ?? defaultListSchemas)(input.projectDir, input.connectionId),
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
input.io.stderr.write(
|
||||
`Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${detail}\n`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
let discovered: KtxTableListEntry[];
|
||||
try {
|
||||
discovered = await (input.deps.listTables ?? defaultListTables)(
|
||||
input.projectDir,
|
||||
input.connectionId,
|
||||
schemasFilter.length > 0 ? schemasFilter : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -1429,84 +1392,72 @@ async function maybeConfigureTableScope(input: {
|
|||
}
|
||||
|
||||
const allQualified = discovered.map((t) => `${t.schema}.${t.name}`);
|
||||
const schemasInDiscovery = unique(discovered.map((t) => t.schema));
|
||||
|
||||
const defaultSchemas = (() => {
|
||||
if (cliSchemas.length > 0) return cliSchemas;
|
||||
if (!spec) return schemasInDiscovery;
|
||||
return spec.defaultSelection(schemasInDiscovery);
|
||||
})();
|
||||
|
||||
const existingEnabled =
|
||||
hasExistingTables && input.forcePrompt === true
|
||||
? (existingTables ?? []).filter(
|
||||
(table): table is string => typeof table === 'string' && allQualified.includes(table),
|
||||
)
|
||||
: [];
|
||||
|
||||
let activeSchemas: string[];
|
||||
let enabledTables: string[];
|
||||
|
||||
if (discovered.length === 1) {
|
||||
await writeConnectionConfig({
|
||||
projectDir: input.projectDir,
|
||||
connectionId: input.connectionId,
|
||||
connection: { ...connection!, enabled_tables: allQualified },
|
||||
});
|
||||
writeSetupSection(input.io, `Tables enabled for ${input.connectionId}`, [
|
||||
`✓ ${allQualified[0]}`,
|
||||
]);
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
const bySchema = new Map<string, KtxTableListEntry[]>();
|
||||
for (const entry of discovered) {
|
||||
const existing = bySchema.get(entry.schema) ?? [];
|
||||
existing.push(entry);
|
||||
bySchema.set(entry.schema, existing);
|
||||
}
|
||||
const schemaList = [...bySchema.keys()].sort();
|
||||
const schemaSummary = schemaList.map((s) => `${s} (${bySchema.get(s)!.length})`).join(', ');
|
||||
|
||||
let selected: string[] | null = null;
|
||||
|
||||
while (selected === null) {
|
||||
const action = await input.prompts.select({
|
||||
message: `Tables found in selected schemas\n` +
|
||||
`${discovered.length} tables across ${schemaList.length} ${schemaList.length === 1 ? 'schema' : 'schemas'}: ${schemaSummary}`,
|
||||
options: [
|
||||
{ value: 'all', label: 'Enable all tables' },
|
||||
{ value: 'customize', label: 'Customize which tables to enable' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
|
||||
if (action === 'back') {
|
||||
enabledTables = allQualified;
|
||||
activeSchemas = spec ? schemasInDiscovery : [];
|
||||
} else {
|
||||
const pickResult = await (input.deps.pickDatabaseScope ?? defaultPickDatabaseScope)(
|
||||
{
|
||||
connectionId: input.connectionId,
|
||||
schemaNoun: spec?.noun ?? 'schema',
|
||||
schemaNounPlural: spec?.nounPlural ?? 'schemas',
|
||||
discovered,
|
||||
existing: { enabledTables: existingEnabled },
|
||||
defaultSchemas,
|
||||
supportsSchemaScope: spec !== undefined,
|
||||
},
|
||||
input.io,
|
||||
);
|
||||
if (pickResult.kind === 'back') {
|
||||
return 'back';
|
||||
}
|
||||
|
||||
if (action === 'all') {
|
||||
selected = allQualified;
|
||||
} else {
|
||||
const choices = await input.prompts.multiselect({
|
||||
message: withMultiselectNavigation(
|
||||
`Tables to enable for ${input.connectionId}\n` +
|
||||
`Deselect any tables agents should not use.`,
|
||||
),
|
||||
options: discovered.map((t) => {
|
||||
const qualified = `${t.schema}.${t.name}`;
|
||||
const suffix = t.kind === 'view' ? ' (view)' : '';
|
||||
return { value: qualified, label: `${qualified}${suffix}` };
|
||||
}),
|
||||
initialValues:
|
||||
Array.isArray(existingTables) && input.forcePrompt === true
|
||||
? existingTables.filter((table): table is string => typeof table === 'string' && allQualified.includes(table))
|
||||
: allQualified,
|
||||
required: true,
|
||||
});
|
||||
|
||||
if (choices.includes('back')) {
|
||||
continue;
|
||||
}
|
||||
if (choices.length === 0) {
|
||||
input.io.stdout.write('│ KTX needs at least one table enabled. Select a table or press Escape to go back.\n');
|
||||
continue;
|
||||
}
|
||||
selected = choices;
|
||||
}
|
||||
enabledTables = pickResult.enabledTables;
|
||||
activeSchemas = pickResult.activeSchemas;
|
||||
}
|
||||
|
||||
if (spec) {
|
||||
await writeScopeConfig({
|
||||
projectDir: input.projectDir,
|
||||
connectionId: input.connectionId,
|
||||
values: activeSchemas,
|
||||
spec,
|
||||
});
|
||||
}
|
||||
const refreshedProject = await loadKtxProject({ projectDir: input.projectDir });
|
||||
const currentConnection = refreshedProject.config.connections[input.connectionId];
|
||||
if (!currentConnection) return 'ready';
|
||||
await writeConnectionConfig({
|
||||
projectDir: input.projectDir,
|
||||
connectionId: input.connectionId,
|
||||
connection: { ...connection!, enabled_tables: selected },
|
||||
connection: { ...currentConnection, enabled_tables: enabledTables },
|
||||
});
|
||||
|
||||
if (spec && activeSchemas.length > 0) {
|
||||
const capitalNounPlural = spec.nounPlural[0]!.toUpperCase() + spec.nounPlural.slice(1);
|
||||
writeSetupSection(input.io, `${capitalNounPlural} saved for ${input.connectionId}`, [
|
||||
`✓ ${activeSchemas.join(', ')}`,
|
||||
]);
|
||||
}
|
||||
writeSetupSection(input.io, `Tables enabled for ${input.connectionId}`, [
|
||||
`✓ ${selected.length}/${discovered.length} tables enabled`,
|
||||
`✓ ${enabledTables.length}/${discovered.length} tables enabled`,
|
||||
]);
|
||||
return 'ready';
|
||||
}
|
||||
|
|
@ -1638,26 +1589,9 @@ async function validateAndScanConnection(input: {
|
|||
const testLines = ['✓ Connection test passed', `Driver: ${driverDisplay}`];
|
||||
writeSetupSection(input.io, `Testing ${input.connectionId}`, testLines);
|
||||
|
||||
while (true) {
|
||||
const schemaStatus = await maybeConfigureSchemaScope({ ...input, forcePrompt: input.forceScopeAndTables });
|
||||
if (schemaStatus !== 'ready') {
|
||||
return schemaStatus;
|
||||
}
|
||||
|
||||
const tableStatus = await maybeConfigureTableScope({ ...input, forcePrompt: input.forceScopeAndTables });
|
||||
if (tableStatus === 'ready') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (input.forceScopeAndTables) {
|
||||
return tableStatus;
|
||||
}
|
||||
|
||||
if (tableStatus === 'failed') {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
await clearScopeConfig(input.projectDir, input.connectionId);
|
||||
const scopeStatus = await maybeConfigureDatabaseScope({ ...input, forcePrompt: input.forceScopeAndTables });
|
||||
if (scopeStatus !== 'ready') {
|
||||
return scopeStatus;
|
||||
}
|
||||
|
||||
await maybeRunHistoricSqlSetupProbe({
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import {
|
|||
selectNone,
|
||||
toggleChecked,
|
||||
visibleNodeIds,
|
||||
type NotionPickerPageInput,
|
||||
} from './notion-page-picker-tree.js';
|
||||
type TreePickerNodeInput,
|
||||
} from './tree-picker-state.js';
|
||||
|
||||
const IDS = {
|
||||
engineering: '11111111-1111-1111-1111-111111111111',
|
||||
|
|
@ -27,7 +27,7 @@ const IDS = {
|
|||
cycleB: '99999999-9999-9999-9999-999999999999',
|
||||
};
|
||||
|
||||
function pages(): NotionPickerPageInput[] {
|
||||
function pages(): TreePickerNodeInput[] {
|
||||
return [
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
{ id: IDS.onboarding, title: 'Onboarding', archived: false, parentId: IDS.engineering },
|
||||
|
|
@ -43,7 +43,7 @@ function pages(): NotionPickerPageInput[] {
|
|||
}
|
||||
|
||||
describe('buildPickerTree', () => {
|
||||
it('deduplicates pages, sorts siblings, preserves archived flags, roots orphans, and breaks cycles', () => {
|
||||
it('deduplicates nodes, sorts siblings, preserves archived flags, roots orphans, and breaks cycles', () => {
|
||||
const tree = buildPickerTree(pages());
|
||||
const byId = new Map(tree.map((node) => [node.id, node]));
|
||||
|
||||
|
|
@ -89,8 +89,7 @@ describe('selection invariants', () => {
|
|||
it('checking a parent locks descendants and keeps checked ids minimal', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
existingSelectedIds: [],
|
||||
});
|
||||
|
||||
const checkedParent = toggleChecked(state, IDS.engineering, 1000);
|
||||
|
|
@ -112,15 +111,11 @@ describe('selection invariants', () => {
|
|||
expect(canToggle(IDS.architecture, uncheckedParent)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('normalizes stored roots, reports stale roots, expands checked ancestors, and flattens descendants', () => {
|
||||
it('reports stale stored ids via the caller-supplied warning, expands checked ancestors, and flattens descendants', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [
|
||||
IDS.engineering.replaceAll('-', ''),
|
||||
IDS.architecture,
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
existingSelectedIds: [IDS.engineering, IDS.architecture, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'],
|
||||
staleWarning: (staleCount) => `${staleCount} stored root_page_ids no longer visible`,
|
||||
});
|
||||
|
||||
expect([...state.checked]).toEqual([IDS.engineering]);
|
||||
|
|
@ -129,14 +124,21 @@ describe('selection invariants', () => {
|
|||
expect(state.preLoadWarnings).toEqual(['1 stored root_page_ids no longer visible']);
|
||||
expect(flattenSelection(new Set([IDS.engineering, IDS.architecture]), state.byId)).toEqual([IDS.engineering]);
|
||||
});
|
||||
|
||||
it('falls back to a generic stale warning when no warning factory is supplied', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingSelectedIds: ['aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'],
|
||||
});
|
||||
expect(state.preLoadWarnings).toEqual(['1 stored selections no longer visible']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search and cursor movement', () => {
|
||||
it('filters by title and path while deriving auto-expanded ancestors', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
existingSelectedIds: [],
|
||||
});
|
||||
const searching = {
|
||||
...state,
|
||||
|
|
@ -153,8 +155,7 @@ describe('search and cursor movement', () => {
|
|||
it('moves the cursor through visible nodes and implements left/right tree semantics', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
existingSelectedIds: [],
|
||||
});
|
||||
|
||||
const atEngineering = {
|
||||
|
|
@ -175,8 +176,7 @@ describe('bulk actions and reducer effects', () => {
|
|||
it('selects only matching visible roots under search and clears selection', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [IDS.marketing],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
existingSelectedIds: [IDS.marketing],
|
||||
});
|
||||
const searching = {
|
||||
...state,
|
||||
|
|
@ -188,36 +188,35 @@ describe('bulk actions and reducer effects', () => {
|
|||
expect([...selectNone(selected).checked]).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns save immediately for selected_roots and requires confirmation for all_accessible', () => {
|
||||
const selectedRoots = toggleChecked(
|
||||
it('saves immediately when confirm is not required and prompts confirmation when requireConfirmOnSave is true', () => {
|
||||
const noConfirm = toggleChecked(
|
||||
buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
existingSelectedIds: [],
|
||||
}),
|
||||
IDS.marketing,
|
||||
1000,
|
||||
);
|
||||
expect(reducer(selectedRoots, 'save-request')).toEqual({
|
||||
next: selectedRoots,
|
||||
expect(reducer(noConfirm, 'save-request')).toEqual({
|
||||
next: noConfirm,
|
||||
effect: 'save',
|
||||
});
|
||||
|
||||
const allAccessible = {
|
||||
...selectedRoots,
|
||||
currentCrawlMode: 'all_accessible' as const,
|
||||
const confirmRequired = {
|
||||
...noConfirm,
|
||||
requireConfirmOnSave: true,
|
||||
};
|
||||
const confirm = reducer(allAccessible, 'save-request');
|
||||
const confirm = reducer(confirmRequired, 'save-request');
|
||||
expect(confirm).toEqual({
|
||||
next: { ...allAccessible, pendingConfirm: 'mode-switch' },
|
||||
next: { ...confirmRequired, pendingConfirm: 'save-confirm' },
|
||||
effect: null,
|
||||
});
|
||||
expect(reducer(confirm.next, 'save-cancel')).toEqual({
|
||||
next: { ...allAccessible, pendingConfirm: null },
|
||||
next: { ...confirmRequired, pendingConfirm: null },
|
||||
effect: null,
|
||||
});
|
||||
expect(reducer(confirm.next, 'save-confirm')).toEqual({
|
||||
next: { ...allAccessible, pendingConfirm: null },
|
||||
next: { ...confirmRequired, pendingConfirm: null },
|
||||
effect: 'save',
|
||||
});
|
||||
});
|
||||
|
|
@ -225,8 +224,7 @@ describe('bulk actions and reducer effects', () => {
|
|||
it('prompts skip-empty confirmation on empty save, updates search state, and quits without saving', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
existingSelectedIds: [],
|
||||
});
|
||||
|
||||
const emptySave = reducer(state, 'save-request');
|
||||
|
|
@ -254,16 +252,33 @@ describe('bulk actions and reducer effects', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('treats skip-empty confirmation as a save with empty selection when skipEmptyAction is save-empty', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingSelectedIds: [],
|
||||
skipEmptyAction: 'save-empty',
|
||||
});
|
||||
|
||||
const emptySave = reducer(state, 'save-request');
|
||||
expect(emptySave).toEqual({
|
||||
next: { ...state, pendingConfirm: 'skip-empty' },
|
||||
effect: null,
|
||||
});
|
||||
expect(reducer(emptySave.next, 'save-confirm')).toEqual({
|
||||
next: { ...state, pendingConfirm: null },
|
||||
effect: 'save',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears transient hints only when their expiry time has passed', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
existingSelectedIds: [],
|
||||
});
|
||||
const withHint = {
|
||||
...state,
|
||||
transientHint: {
|
||||
text: 'Select at least one page or press esc to cancel',
|
||||
text: 'Select at least one item or press esc to cancel',
|
||||
expiresAt: 11500,
|
||||
},
|
||||
};
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
export interface NotionPickerPageInput {
|
||||
export interface TreePickerNodeInput {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
archived?: boolean;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
interface NotionPickerNode {
|
||||
export interface TreePickerNode {
|
||||
id: string;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
|
|
@ -15,17 +15,22 @@ interface NotionPickerNode {
|
|||
path: string;
|
||||
}
|
||||
|
||||
type PendingConfirmKind = 'save-confirm' | 'skip-empty';
|
||||
|
||||
export type SkipEmptyAction = 'quit' | 'save-empty';
|
||||
|
||||
export interface PickerState {
|
||||
tree: NotionPickerNode[];
|
||||
byId: Map<string, NotionPickerNode>;
|
||||
tree: TreePickerNode[];
|
||||
byId: Map<string, TreePickerNode>;
|
||||
expanded: Set<string>;
|
||||
checked: Set<string>;
|
||||
cursorId: string;
|
||||
search: { editing: boolean; query: string };
|
||||
pendingConfirm: 'mode-switch' | 'skip-empty' | null;
|
||||
pendingConfirm: PendingConfirmKind | null;
|
||||
preLoadWarnings: string[];
|
||||
transientHint: { text: string; expiresAt: number } | null;
|
||||
currentCrawlMode: 'all_accessible' | 'selected_roots';
|
||||
requireConfirmOnSave: boolean;
|
||||
skipEmptyAction: SkipEmptyAction;
|
||||
}
|
||||
|
||||
export type PickerCommand =
|
||||
|
|
@ -65,25 +70,12 @@ const TRANSIENT_HINT_DURATION_MS = 2500;
|
|||
|
||||
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
|
||||
|
||||
function normalizePageId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const compact = trimmed.replace(/-/g, '');
|
||||
if (/^[0-9a-fA-F]{32}$/.test(compact)) {
|
||||
const lower = compact.toLowerCase();
|
||||
return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(
|
||||
16,
|
||||
20,
|
||||
)}-${lower.slice(20)}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function titleValue(value: string | null | undefined): string {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
return trimmed.length > 0 ? trimmed : 'Untitled';
|
||||
}
|
||||
|
||||
function sortedNodeIds(ids: string[], nodes: Map<string, MutableNode | NotionPickerNode>): string[] {
|
||||
function sortedNodeIds(ids: string[], nodes: Map<string, MutableNode | TreePickerNode>): string[] {
|
||||
return [...ids].sort((leftId, rightId) => {
|
||||
const left = nodes.get(leftId);
|
||||
const right = nodes.get(rightId);
|
||||
|
|
@ -107,7 +99,7 @@ export function clearExpiredTransientHint(state: PickerState, now = Date.now()):
|
|||
return cloneState(state, { transientHint: null });
|
||||
}
|
||||
|
||||
function ancestorsOf(nodeId: string, byId: Map<string, NotionPickerNode>): string[] {
|
||||
function ancestorsOf(nodeId: string, byId: Map<string, TreePickerNode>): string[] {
|
||||
const ancestors: string[] = [];
|
||||
let parentId = byId.get(nodeId)?.parentId ?? null;
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -119,7 +111,7 @@ function ancestorsOf(nodeId: string, byId: Map<string, NotionPickerNode>): strin
|
|||
return ancestors;
|
||||
}
|
||||
|
||||
function descendantsOf(nodeId: string, byId: Map<string, NotionPickerNode>): string[] {
|
||||
function descendantsOf(nodeId: string, byId: Map<string, TreePickerNode>): string[] {
|
||||
const result: string[] = [];
|
||||
const stack = [...(byId.get(nodeId)?.childIds ?? [])].reverse();
|
||||
while (stack.length > 0) {
|
||||
|
|
@ -152,18 +144,18 @@ function matchingIds(state: PickerState): Set<string> {
|
|||
);
|
||||
}
|
||||
|
||||
export function buildPickerTree(searchResults: NotionPickerPageInput[]): NotionPickerNode[] {
|
||||
export function buildPickerTree(inputs: TreePickerNodeInput[]): TreePickerNode[] {
|
||||
const nodes = new Map<string, MutableNode>();
|
||||
for (const result of searchResults) {
|
||||
const id = normalizePageId(result.id);
|
||||
if (nodes.has(id)) {
|
||||
for (const result of inputs) {
|
||||
const id = result.id.trim();
|
||||
if (id.length === 0 || nodes.has(id)) {
|
||||
continue;
|
||||
}
|
||||
nodes.set(id, {
|
||||
id,
|
||||
title: titleValue(result.title),
|
||||
archived: result.archived === true,
|
||||
parentId: result.parentId ? normalizePageId(result.parentId) : null,
|
||||
parentId: result.parentId ? result.parentId.trim() : null,
|
||||
childIds: [],
|
||||
});
|
||||
}
|
||||
|
|
@ -202,7 +194,7 @@ export function buildPickerTree(searchResults: NotionPickerPageInput[]): NotionP
|
|||
[...nodes.values()].filter((node) => node.parentId === null).map((node) => node.id),
|
||||
nodes,
|
||||
);
|
||||
const tree: NotionPickerNode[] = [];
|
||||
const tree: TreePickerNode[] = [];
|
||||
|
||||
function visit(nodeId: string, depth: number, pathPrefix: string[]): void {
|
||||
const raw = nodes.get(nodeId);
|
||||
|
|
@ -210,7 +202,7 @@ export function buildPickerTree(searchResults: NotionPickerPageInput[]): NotionP
|
|||
return;
|
||||
}
|
||||
const path = [...pathPrefix, raw.title].join(' / ');
|
||||
const node: NotionPickerNode = {
|
||||
const node: TreePickerNode = {
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
archived: raw.archived,
|
||||
|
|
@ -232,11 +224,11 @@ export function buildPickerTree(searchResults: NotionPickerPageInput[]): NotionP
|
|||
return tree;
|
||||
}
|
||||
|
||||
export function isAncestorChecked(nodeId: string, checked: Set<string>, byId: Map<string, NotionPickerNode>): boolean {
|
||||
export function isAncestorChecked(nodeId: string, checked: Set<string>, byId: Map<string, TreePickerNode>): boolean {
|
||||
return ancestorsOf(nodeId, byId).some((ancestorId) => checked.has(ancestorId));
|
||||
}
|
||||
|
||||
function checkedAncestor(nodeId: string, state: PickerState): NotionPickerNode | null {
|
||||
function checkedAncestor(nodeId: string, state: PickerState): TreePickerNode | null {
|
||||
for (const ancestorId of ancestorsOf(nodeId, state.byId)) {
|
||||
if (state.checked.has(ancestorId)) {
|
||||
return state.byId.get(ancestorId) ?? null;
|
||||
|
|
@ -247,7 +239,7 @@ function checkedAncestor(nodeId: string, state: PickerState): NotionPickerNode |
|
|||
|
||||
export function canToggle(nodeId: string, state: PickerState): { ok: true } | { ok: false; reason: string } {
|
||||
if (!state.byId.has(nodeId)) {
|
||||
return { ok: false, reason: 'Page not found' };
|
||||
return { ok: false, reason: 'Node not found' };
|
||||
}
|
||||
const ancestor = checkedAncestor(nodeId, state);
|
||||
if (ancestor) {
|
||||
|
|
@ -276,7 +268,7 @@ export function toggleChecked(state: PickerState, nodeId: string, now = Date.now
|
|||
return cloneState(state, { checked, transientHint: null });
|
||||
}
|
||||
|
||||
export function flattenSelection(checked: Set<string>, byId: Map<string, NotionPickerNode>): string[] {
|
||||
export function flattenSelection(checked: Set<string>, byId: Map<string, TreePickerNode>): string[] {
|
||||
const result: string[] = [];
|
||||
for (const node of byId.values()) {
|
||||
if (checked.has(node.id) && !isAncestorChecked(node.id, checked, byId)) {
|
||||
|
|
@ -402,16 +394,21 @@ export function moveCursor(state: PickerState, dir: 'up' | 'down' | 'left' | 'ri
|
|||
}
|
||||
|
||||
export function buildInitialState(args: {
|
||||
tree: NotionPickerNode[];
|
||||
existingRootPageIds: string[];
|
||||
currentCrawlMode?: 'all_accessible' | 'selected_roots';
|
||||
tree: TreePickerNode[];
|
||||
existingSelectedIds: string[];
|
||||
requireConfirmOnSave?: boolean;
|
||||
skipEmptyAction?: SkipEmptyAction;
|
||||
staleWarning?: (staleCount: number) => string;
|
||||
}): PickerState {
|
||||
const byId = new Map(args.tree.map((node) => [node.id, node]));
|
||||
const checked = new Set<string>();
|
||||
let staleCount = 0;
|
||||
|
||||
for (const rawId of args.existingRootPageIds) {
|
||||
const id = normalizePageId(rawId);
|
||||
for (const rawId of args.existingSelectedIds) {
|
||||
const id = rawId.trim();
|
||||
if (id.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (byId.has(id)) {
|
||||
checked.add(id);
|
||||
} else {
|
||||
|
|
@ -427,6 +424,12 @@ export function buildInitialState(args: {
|
|||
}
|
||||
}
|
||||
|
||||
const preLoadWarnings: string[] = [];
|
||||
if (staleCount > 0) {
|
||||
const warning = args.staleWarning ? args.staleWarning(staleCount) : `${staleCount} stored selections no longer visible`;
|
||||
preLoadWarnings.push(warning);
|
||||
}
|
||||
|
||||
return {
|
||||
tree: args.tree,
|
||||
byId,
|
||||
|
|
@ -435,16 +438,18 @@ export function buildInitialState(args: {
|
|||
cursorId: args.tree[0]?.id ?? '',
|
||||
search: { editing: false, query: '' },
|
||||
pendingConfirm: null,
|
||||
preLoadWarnings: staleCount > 0 ? [`${staleCount} stored root_page_ids no longer visible`] : [],
|
||||
preLoadWarnings,
|
||||
transientHint: null,
|
||||
currentCrawlMode: args.currentCrawlMode ?? 'selected_roots',
|
||||
requireConfirmOnSave: args.requireConfirmOnSave ?? false,
|
||||
skipEmptyAction: args.skipEmptyAction ?? 'quit',
|
||||
};
|
||||
}
|
||||
|
||||
export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()): { next: PickerState; effect: PickerEffect } {
|
||||
if (state.pendingConfirm) {
|
||||
if (cmd === 'save-confirm') {
|
||||
const effect: PickerEffect = state.pendingConfirm === 'skip-empty' ? 'quit-without-save' : 'save';
|
||||
const effect: PickerEffect =
|
||||
state.pendingConfirm === 'skip-empty' ? (state.skipEmptyAction === 'save-empty' ? 'save' : 'quit-without-save') : 'save';
|
||||
return { next: cloneState(state, { pendingConfirm: null }), effect };
|
||||
}
|
||||
if (cmd === 'save-cancel') {
|
||||
|
|
@ -501,8 +506,8 @@ export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()
|
|||
if (state.checked.size === 0) {
|
||||
return { next: cloneState(state, { pendingConfirm: 'skip-empty' }), effect: null };
|
||||
}
|
||||
if (state.currentCrawlMode === 'all_accessible') {
|
||||
return { next: cloneState(state, { pendingConfirm: 'mode-switch' }), effect: null };
|
||||
if (state.requireConfirmOnSave) {
|
||||
return { next: cloneState(state, { pendingConfirm: 'save-confirm' }), effect: null };
|
||||
}
|
||||
return { next: state, effect: 'save' };
|
||||
case 'save-confirm':
|
||||
361
packages/cli/src/tree-picker-tui.test.tsx
Normal file
361
packages/cli/src/tree-picker-tui.test.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
/* @jsxImportSource react */
|
||||
import { render as renderInkTest } from 'ink-testing-library';
|
||||
import { type ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { buildInitialState, buildPickerTree, type TreePickerNodeInput } from './tree-picker-state.js';
|
||||
import {
|
||||
TreePickerApp,
|
||||
renderTreePickerTui,
|
||||
resolveTreePickerWidth,
|
||||
sanitizeTreePickerTuiError,
|
||||
treePickerCommandForInkInput,
|
||||
windowItems,
|
||||
windowOffset,
|
||||
type TreePickerChrome,
|
||||
type TreePickerInkInstance,
|
||||
type TreePickerInkRenderOptions,
|
||||
} from './tree-picker-tui.js';
|
||||
|
||||
const IDS = {
|
||||
engineering: '11111111-1111-1111-1111-111111111111',
|
||||
architecture: '22222222-2222-2222-2222-222222222222',
|
||||
marketing: '33333333-3333-3333-3333-333333333333',
|
||||
finance: '44444444-4444-4444-4444-444444444444',
|
||||
ops: '55555555-5555-5555-5555-555555555555',
|
||||
sales: '66666666-6666-6666-6666-666666666666',
|
||||
support: '77777777-7777-7777-7777-777777777777',
|
||||
product: '88888888-8888-8888-8888-888888888888',
|
||||
design: '99999999-9999-9999-9999-999999999999',
|
||||
};
|
||||
|
||||
function pages(): TreePickerNodeInput[] {
|
||||
return [
|
||||
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
|
||||
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
];
|
||||
}
|
||||
|
||||
function manyPages(): TreePickerNodeInput[] {
|
||||
return [
|
||||
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
|
||||
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
{ id: IDS.finance, title: 'Finance', archived: false, parentId: null },
|
||||
{ id: IDS.ops, title: 'Operations', archived: false, parentId: null },
|
||||
{ id: IDS.sales, title: 'Sales', archived: false, parentId: null },
|
||||
{ id: IDS.support, title: 'Support', archived: false, parentId: null },
|
||||
{ id: IDS.product, title: 'Product', archived: false, parentId: null },
|
||||
{ id: IDS.design, title: 'Design', archived: false, parentId: null },
|
||||
];
|
||||
}
|
||||
|
||||
function state(options: { requireConfirmOnSave?: boolean } = {}) {
|
||||
return buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingSelectedIds: [],
|
||||
requireConfirmOnSave: options.requireConfirmOnSave ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
function chrome(overrides: Partial<TreePickerChrome> = {}): TreePickerChrome {
|
||||
return {
|
||||
title: 'Select items',
|
||||
subtitleLines: ['Source: Test'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForInkInput(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
function fakeInkInstance(): TreePickerInkInstance {
|
||||
return {
|
||||
rerender: vi.fn(),
|
||||
unmount: vi.fn(),
|
||||
waitUntilExit: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrameWrap(frame: string | undefined): string {
|
||||
return frame?.replace(/\n/g, ' ').replace(/│ /g, '').replace(/ +/g, ' ') ?? '';
|
||||
}
|
||||
|
||||
describe('treePickerCommandForInkInput', () => {
|
||||
it('maps browse, search, and confirm input to reducer commands', () => {
|
||||
expect(treePickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down');
|
||||
expect(treePickerCommandForInkInput('', { upArrow: true }, state().search, null)).toBe('cursor-up');
|
||||
expect(treePickerCommandForInkInput('', { rightArrow: true }, state().search, null)).toBe('cursor-right');
|
||||
expect(treePickerCommandForInkInput('', { leftArrow: true }, state().search, null)).toBe('cursor-left');
|
||||
expect(treePickerCommandForInkInput(' ', {}, state().search, null)).toBe('toggle-check');
|
||||
expect(treePickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start');
|
||||
expect(treePickerCommandForInkInput('a', {}, state().search, null)).toBe('select-all-visible');
|
||||
expect(treePickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none');
|
||||
expect(treePickerCommandForInkInput('', { return: true }, state().search, null)).toBe('save-request');
|
||||
expect(treePickerCommandForInkInput('', { escape: true }, state().search, null)).toBe('quit');
|
||||
expect(treePickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit');
|
||||
expect(treePickerCommandForInkInput('s', {}, state().search, null)).toBeNull();
|
||||
expect(treePickerCommandForInkInput('q', {}, state().search, null)).toBeNull();
|
||||
|
||||
expect(treePickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({
|
||||
type: 'search-input',
|
||||
value: 'x',
|
||||
});
|
||||
expect(treePickerCommandForInkInput('', { backspace: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-backspace',
|
||||
);
|
||||
expect(treePickerCommandForInkInput('', { return: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-submit',
|
||||
);
|
||||
expect(treePickerCommandForInkInput('', { escape: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-cancel',
|
||||
);
|
||||
|
||||
expect(treePickerCommandForInkInput('y', {}, state().search, 'save-confirm')).toBe('save-confirm');
|
||||
expect(treePickerCommandForInkInput('', { return: true }, state().search, 'save-confirm')).toBe('save-confirm');
|
||||
expect(treePickerCommandForInkInput('n', {}, state().search, 'save-confirm')).toBe('save-cancel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('window helpers', () => {
|
||||
it('centers the selected row and returns the visible slice', () => {
|
||||
expect(windowOffset(20, 10, 5)).toBe(8);
|
||||
expect(windowItems(['a', 'b', 'c', 'd', 'e'], 3, 3)).toEqual({ items: ['c', 'd', 'e'], offset: 2 });
|
||||
});
|
||||
|
||||
it('clamps picker width to the design rule', () => {
|
||||
expect(resolveTreePickerWidth(200)).toBe(120);
|
||||
expect(resolveTreePickerWidth(100)).toBe(96);
|
||||
expect(resolveTreePickerWidth(50)).toBe(60);
|
||||
expect(resolveTreePickerWidth(undefined)).toBe(96);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TreePickerApp', () => {
|
||||
it('renders chrome title, subtitle, warnings, help, and row glyphs', () => {
|
||||
const initialState = {
|
||||
...state(),
|
||||
preLoadWarnings: ['1 stale stored selections - they will be removed if you save'],
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<TreePickerApp
|
||||
initialState={initialState}
|
||||
chrome={chrome({
|
||||
title: 'Select fancy widgets',
|
||||
subtitleLines: ['Workspace: Design Workspace'],
|
||||
warningLines: ['5000-page cap reached - some pages not shown'],
|
||||
})}
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Select fancy widgets');
|
||||
expect(frame).toContain('Workspace: Design Workspace');
|
||||
expect(frame).toContain('5000-page cap reached - some pages not shown');
|
||||
expect(frame).toContain('1 stale stored selections - they will be removed if you save');
|
||||
expect(frame).toContain('◻ Engineering Docs ▸ (1)');
|
||||
expect(frame).toContain('◻ Marketing');
|
||||
expect(normalizeFrameWrap(frame)).toContain(
|
||||
'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders custom help text when supplied', () => {
|
||||
const { lastFrame } = renderInkTest(
|
||||
<TreePickerApp
|
||||
initialState={state()}
|
||||
chrome={chrome({ helpText: 'Bespoke instructions here.' })}
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame() ?? '').toContain('Bespoke instructions here.');
|
||||
});
|
||||
|
||||
it('renders checked parents and locked descendants with locked glyphs', () => {
|
||||
const initialState = {
|
||||
...state(),
|
||||
checked: new Set([IDS.engineering]),
|
||||
expanded: new Set([IDS.engineering]),
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<TreePickerApp
|
||||
initialState={initialState}
|
||||
chrome={chrome()}
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('◼ Engineering Docs ▾');
|
||||
expect(frame).toContain(' ◼ Architecture');
|
||||
});
|
||||
|
||||
it('supports keyboard selection, confirm-on-save, and save callback', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<TreePickerApp
|
||||
initialState={state({ requireConfirmOnSave: true })}
|
||||
chrome={chrome({
|
||||
confirmSaveMessage: (current) =>
|
||||
`Confirm: ${current.checked.size} item${current.checked.size === 1 ? '' : 's'}? Press Enter or Escape.`,
|
||||
})}
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write(' ');
|
||||
await waitForInkInput();
|
||||
expect(lastFrame()).toContain('◼ Engineering Docs');
|
||||
|
||||
stdin.write('\r');
|
||||
await waitForInkInput();
|
||||
expect(normalizeFrameWrap(lastFrame())).toContain('Confirm: 1 item? Press Enter or Escape.');
|
||||
|
||||
stdin.write('y');
|
||||
await waitForInkInput();
|
||||
expect(onExit).toHaveBeenCalledWith({ kind: 'save', selectedIds: [IDS.engineering] });
|
||||
});
|
||||
|
||||
it('uses the chrome-supplied skip-empty message and quits on confirm', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<TreePickerApp
|
||||
initialState={state()}
|
||||
chrome={chrome({ skipEmptyMessage: 'No selections. Skip or back?' })}
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write('\r');
|
||||
await waitForInkInput();
|
||||
expect(normalizeFrameWrap(lastFrame())).toContain('No selections. Skip or back?');
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
|
||||
stdin.write('n');
|
||||
await waitForInkInput();
|
||||
expect(lastFrame()).not.toContain('No selections. Skip or back?');
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
|
||||
stdin.write('\r');
|
||||
await waitForInkInput();
|
||||
expect(lastFrame()).toContain('No selections. Skip or back?');
|
||||
|
||||
stdin.write('\r');
|
||||
await waitForInkInput();
|
||||
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
|
||||
});
|
||||
|
||||
it('renders row-window overflow indicators when the visible list is clipped', async () => {
|
||||
const onExit = vi.fn();
|
||||
const initialState = buildInitialState({
|
||||
tree: buildPickerTree(manyPages()),
|
||||
existingSelectedIds: [],
|
||||
});
|
||||
initialState.expanded = new Set([IDS.engineering]);
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<TreePickerApp
|
||||
initialState={initialState}
|
||||
chrome={chrome()}
|
||||
terminalRows={13}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('↓ 4 more');
|
||||
|
||||
stdin.write('[B');
|
||||
stdin.write('[B');
|
||||
stdin.write('[B');
|
||||
stdin.write('[B');
|
||||
await waitForInkInput();
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('↑ ');
|
||||
expect(frame).toContain('↓ ');
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('quits without saving on Ctrl+C', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin } = renderInkTest(
|
||||
<TreePickerApp
|
||||
initialState={state()}
|
||||
chrome={chrome()}
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write('');
|
||||
await waitForInkInput();
|
||||
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTreePickerTui', () => {
|
||||
it('returns the app result from the Ink runtime', async () => {
|
||||
const io = {
|
||||
stdin: { isTTY: true, setRawMode: vi.fn() },
|
||||
stdout: { isTTY: true, columns: 100, rows: 24, write: vi.fn() },
|
||||
stderr: { write: vi.fn() },
|
||||
};
|
||||
const renderInk = vi.fn((_tree: ReactNode, _options: TreePickerInkRenderOptions) => fakeInkInstance());
|
||||
|
||||
await expect(
|
||||
renderTreePickerTui(
|
||||
{ initialState: state(), chrome: chrome() },
|
||||
io,
|
||||
{ renderInk },
|
||||
),
|
||||
).resolves.toEqual({ kind: 'quit' });
|
||||
expect(renderInk).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('sanitizes render errors and uses the supplied scripted-mode hint', async () => {
|
||||
expect(sanitizeTreePickerTuiError(new Error('token=secret https://api.example.com/v1/search'))).toBe(
|
||||
'[redacted] [redacted-url]',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to quit with the scripted-mode hint when Ink cannot initialize', async () => {
|
||||
let stderr = '';
|
||||
const io = {
|
||||
stdin: { isTTY: false, setRawMode: vi.fn() },
|
||||
stdout: { isTTY: false, columns: 100, rows: 24, write: vi.fn() },
|
||||
stderr: {
|
||||
write(chunk: string) {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
renderTreePickerTui(
|
||||
{ initialState: state(), chrome: chrome() },
|
||||
io,
|
||||
{
|
||||
renderInk: vi.fn(() => {
|
||||
throw new Error('token=secret');
|
||||
}),
|
||||
scriptedModeHint: 'Use --no-input --foo bar for scripted mode.',
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ kind: 'quit' });
|
||||
expect(stderr).toContain('Use --no-input --foo bar for scripted mode.');
|
||||
expect(stderr).not.toContain('secret');
|
||||
});
|
||||
});
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
visibleNodeIds,
|
||||
type PickerCommand,
|
||||
type PickerState,
|
||||
} from './notion-page-picker-tree.js';
|
||||
} from './tree-picker-state.js';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
|
||||
const COLOR_THEME = {
|
||||
|
|
@ -28,9 +28,15 @@ const NO_COLOR_THEME = {
|
|||
warning: 'white',
|
||||
} as const;
|
||||
|
||||
type NotionPickerTheme = Record<keyof typeof COLOR_THEME, string>;
|
||||
type TreePickerTheme = Record<keyof typeof COLOR_THEME, string>;
|
||||
|
||||
export interface NotionPickerTuiIo extends KtxCliIo {
|
||||
const DEFAULT_TREE_PICKER_HELP_TEXT =
|
||||
'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.';
|
||||
|
||||
const DEFAULT_SKIP_EMPTY_MESSAGE =
|
||||
'Nothing selected. Skip this step? Press Enter to skip or Escape to go back.';
|
||||
|
||||
export interface TreePickerTuiIo extends KtxCliIo {
|
||||
stdin?: { isTTY?: boolean; setRawMode?(value: boolean): void };
|
||||
stdout: KtxCliIo['stdout'] & { isTTY?: boolean; columns?: number; rows?: number };
|
||||
}
|
||||
|
|
@ -47,58 +53,54 @@ interface InkKey {
|
|||
delete?: boolean;
|
||||
}
|
||||
|
||||
export type PickerRenderResult = { kind: 'save'; rootPageIds: string[] } | { kind: 'quit' };
|
||||
export type TreePickerResult = { kind: 'save'; selectedIds: string[] } | { kind: 'quit' };
|
||||
|
||||
export interface PickerRenderInput {
|
||||
initialState: PickerState;
|
||||
connectionId: string;
|
||||
workspaceLabel: string;
|
||||
cappedAtCount: number | null;
|
||||
currentCrawlMode: 'all_accessible' | 'selected_roots';
|
||||
export interface TreePickerChrome {
|
||||
title: string;
|
||||
helpText?: string;
|
||||
subtitleLines?: readonly string[];
|
||||
warningLines?: readonly string[];
|
||||
confirmSaveMessage?: (state: PickerState) => string;
|
||||
skipEmptyMessage?: string;
|
||||
}
|
||||
|
||||
interface NotionPickerAppProps extends PickerRenderInput {
|
||||
export interface TreePickerRenderInput {
|
||||
initialState: PickerState;
|
||||
chrome: TreePickerChrome;
|
||||
}
|
||||
|
||||
interface TreePickerAppProps extends TreePickerRenderInput {
|
||||
terminalRows?: number;
|
||||
terminalWidth?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onExit(result: PickerRenderResult): void;
|
||||
onExit(result: TreePickerResult): void;
|
||||
}
|
||||
|
||||
export interface NotionPickerInkInstance {
|
||||
export interface TreePickerInkInstance {
|
||||
rerender(tree: ReactNode): void;
|
||||
unmount(): void;
|
||||
waitUntilExit(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface NotionPickerInkRenderOptions {
|
||||
stdin?: NotionPickerTuiIo['stdin'];
|
||||
stdout: NotionPickerTuiIo['stdout'];
|
||||
stderr: NotionPickerTuiIo['stderr'];
|
||||
export interface TreePickerInkRenderOptions {
|
||||
stdin?: TreePickerTuiIo['stdin'];
|
||||
stdout: TreePickerTuiIo['stdout'];
|
||||
stderr: TreePickerTuiIo['stderr'];
|
||||
exitOnCtrlC: boolean;
|
||||
patchConsole: boolean;
|
||||
maxFps: number;
|
||||
alternateScreen: boolean;
|
||||
}
|
||||
|
||||
function resolveTheme(env: NodeJS.ProcessEnv = process.env): NotionPickerTheme {
|
||||
function resolveTheme(env: NodeJS.ProcessEnv = process.env): TreePickerTheme {
|
||||
return env.NO_COLOR || env.TERM === 'dumb' ? NO_COLOR_THEME : COLOR_THEME;
|
||||
}
|
||||
|
||||
export function resolveNotionPickerWidth(columns: number | undefined): number {
|
||||
export function resolveTreePickerWidth(columns: number | undefined): number {
|
||||
const resolvedColumns = columns ?? 100;
|
||||
return Math.max(60, Math.min(120, resolvedColumns - 4));
|
||||
}
|
||||
|
||||
function staleWarningText(warning: string): string {
|
||||
return warning.includes('stored root_page_ids no longer visible')
|
||||
? `${warning} - they will be removed if you save`
|
||||
: warning;
|
||||
}
|
||||
|
||||
function selectedPageCountText(count: number): string {
|
||||
return `${count} selected ${count === 1 ? 'page' : 'pages'}`;
|
||||
}
|
||||
|
||||
function rowMatchesSearch(state: PickerState, nodeId: string): boolean {
|
||||
const query = state.search.query.trim().toLocaleLowerCase();
|
||||
if (!query) {
|
||||
|
|
@ -111,7 +113,7 @@ function rowMatchesSearch(state: PickerState, nodeId: string): boolean {
|
|||
return node.title.toLocaleLowerCase().includes(query) || node.path.toLocaleLowerCase().includes(query);
|
||||
}
|
||||
|
||||
export function sanitizeNotionPickerTuiError(error: unknown): string {
|
||||
export function sanitizeTreePickerTuiError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message
|
||||
.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted-url]')
|
||||
|
|
@ -134,7 +136,7 @@ function truncateText(value: string, width: number): string {
|
|||
return `${value.slice(0, width - 3)}...`;
|
||||
}
|
||||
|
||||
export function notionPickerCommandForInkInput(
|
||||
export function treePickerCommandForInkInput(
|
||||
input: string,
|
||||
key: InkKey,
|
||||
search: PickerState['search'],
|
||||
|
|
@ -152,7 +154,7 @@ export function notionPickerCommandForInkInput(
|
|||
if (key.backspace || key.delete) return 'search-backspace';
|
||||
if (key.downArrow) return 'cursor-down';
|
||||
if (key.upArrow) return 'cursor-up';
|
||||
if (input.length === 1 && input >= ' ' && input !== '\u007f') return { type: 'search-input', value: input };
|
||||
if (input.length === 1 && input >= ' ' && input !== '') return { type: 'search-input', value: input };
|
||||
return null;
|
||||
}
|
||||
if (key.ctrl === true && input === 'c') return 'quit';
|
||||
|
|
@ -169,7 +171,7 @@ export function notionPickerCommandForInkInput(
|
|||
return null;
|
||||
}
|
||||
|
||||
function PickerRow(props: { state: PickerState; nodeId: string; width: number; theme: NotionPickerTheme }): ReactNode {
|
||||
function PickerRow(props: { state: PickerState; nodeId: string; width: number; theme: TreePickerTheme }): ReactNode {
|
||||
const node = props.state.byId.get(props.nodeId);
|
||||
if (!node) return null;
|
||||
const focused = props.state.cursorId === node.id;
|
||||
|
|
@ -177,14 +179,14 @@ function PickerRow(props: { state: PickerState; nodeId: string; width: number; t
|
|||
const checked = props.state.checked.has(node.id);
|
||||
const isSelected = checked || locked;
|
||||
const glyph = isSelected ? '◼' : '◻';
|
||||
const glyphColor = locked ? props.theme.muted : checked ? props.theme.selected : props.theme.muted;
|
||||
const glyphColor = checked || locked ? props.theme.selected : props.theme.muted;
|
||||
const childAffordance =
|
||||
node.childIds.length > 0 ? (props.state.expanded.has(node.id) ? ' ▾' : ` ▸ (${node.childIds.length})`) : '';
|
||||
const indent = ' '.repeat(node.depth * 2);
|
||||
const titleColor = focused ? props.theme.text : props.theme.muted;
|
||||
const titleColor = focused ? props.theme.active : props.theme.text;
|
||||
const inverse = rowMatchesSearch(props.state, node.id);
|
||||
const prefixWidth = indent.length + 2;
|
||||
const title = truncateText(`${node.title}${childAffordance}`, Math.max(10, props.width - prefixWidth));
|
||||
const prefixWidth = indent.length + 2 + childAffordance.length;
|
||||
const title = truncateText(node.title, Math.max(10, props.width - prefixWidth));
|
||||
|
||||
return (
|
||||
<Text>
|
||||
|
|
@ -192,30 +194,32 @@ function PickerRow(props: { state: PickerState; nodeId: string; width: number; t
|
|||
{indent}
|
||||
{glyph}
|
||||
</Text>
|
||||
<Text color={titleColor} strikethrough={node.archived}>
|
||||
<Text color={titleColor} strikethrough={node.archived} bold={focused}>
|
||||
{' '}
|
||||
<Text inverse={inverse}>{title}</Text>
|
||||
</Text>
|
||||
{childAffordance.length > 0 ? <Text color={props.theme.muted}>{childAffordance}</Text> : null}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
|
||||
export function TreePickerApp(props: TreePickerAppProps): ReactNode {
|
||||
const app = useApp();
|
||||
const [state, setState] = useState(props.initialState);
|
||||
const stateRef = useRef(state);
|
||||
const theme = useMemo(() => resolveTheme(props.env), [props.env]);
|
||||
const visibleIds = visibleNodeIds(state);
|
||||
const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId));
|
||||
const reservedRows = state.pendingConfirm === 'mode-switch' ? 9 : 8;
|
||||
const reservedRows = state.pendingConfirm === 'save-confirm' ? 10 : 9;
|
||||
const visibleRows = Math.max(5, Math.min(12, (props.terminalRows ?? 24) - reservedRows));
|
||||
const rows = windowItems(visibleIds, selectedIndex, visibleRows);
|
||||
const hiddenAbove = rows.offset;
|
||||
const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length);
|
||||
const searchMatchCount = filterTree(state).visibleIds.size;
|
||||
const width = resolveNotionPickerWidth(props.terminalWidth);
|
||||
const width = resolveTreePickerWidth(props.terminalWidth);
|
||||
const showSearch = state.search.editing || state.search.query.trim().length > 0;
|
||||
const selectedCount = flattenSelection(state.checked, state.byId).length;
|
||||
const helpText = props.chrome.helpText ?? DEFAULT_TREE_PICKER_HELP_TEXT;
|
||||
const skipEmptyMessage = props.chrome.skipEmptyMessage ?? DEFAULT_SKIP_EMPTY_MESSAGE;
|
||||
|
||||
stateRef.current = state;
|
||||
|
||||
|
|
@ -244,7 +248,7 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
|
|||
}, [state.transientHint?.expiresAt]);
|
||||
|
||||
useInput((input, key) => {
|
||||
const command = notionPickerCommandForInkInput(input, key, stateRef.current.search, stateRef.current.pendingConfirm);
|
||||
const command = treePickerCommandForInkInput(input, key, stateRef.current.search, stateRef.current.pendingConfirm);
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -252,7 +256,7 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
|
|||
stateRef.current = next;
|
||||
setState(next);
|
||||
if (effect === 'save') {
|
||||
props.onExit({ kind: 'save', rootPageIds: flattenSelection(next.checked, next.byId) });
|
||||
props.onExit({ kind: 'save', selectedIds: flattenSelection(next.checked, next.byId) });
|
||||
app.exit();
|
||||
return;
|
||||
}
|
||||
|
|
@ -266,7 +270,7 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
|
|||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text color={theme.active}>◆</Text>
|
||||
<Text bold> Select Notion pages to ingest</Text>
|
||||
<Text bold> {props.chrome.title}</Text>
|
||||
</Text>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
|
|
@ -277,18 +281,21 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
|
|||
borderColor={theme.active}
|
||||
paddingLeft={1}
|
||||
>
|
||||
<Text color={theme.muted}>
|
||||
Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape
|
||||
to go back, or Ctrl+C to exit.
|
||||
</Text>
|
||||
<Text color={theme.muted}>{helpText}</Text>
|
||||
<Text> </Text>
|
||||
<Text color={theme.muted}>Workspace: {props.workspaceLabel}</Text>
|
||||
{props.cappedAtCount ? (
|
||||
<Text color={theme.warning}>{props.cappedAtCount}-page cap reached - some pages not shown</Text>
|
||||
) : null}
|
||||
{(props.chrome.subtitleLines ?? []).map((line, idx) => (
|
||||
<Text key={`subtitle-${idx}`} color={theme.muted}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
{(props.chrome.warningLines ?? []).map((line, idx) => (
|
||||
<Text key={`chromewarn-${idx}`} color={theme.warning}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
{state.preLoadWarnings.map((warning) => (
|
||||
<Text key={warning} color={theme.warning}>
|
||||
{staleWarningText(warning)}
|
||||
{warning}
|
||||
</Text>
|
||||
))}
|
||||
{showSearch ? (
|
||||
|
|
@ -301,20 +308,20 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
|
|||
<Text color={theme.muted}> ({searchMatchCount} matches)</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text> </Text>
|
||||
{hiddenAbove > 0 ? <Text color={theme.muted}>↑ {hiddenAbove} more</Text> : null}
|
||||
{rows.items.map((nodeId) => (
|
||||
<PickerRow key={nodeId} state={state} nodeId={nodeId} width={width} theme={theme} />
|
||||
))}
|
||||
{hiddenBelow > 0 ? <Text color={theme.muted}>↓ {hiddenBelow} more</Text> : null}
|
||||
{state.pendingConfirm === 'mode-switch' ? (
|
||||
{state.pendingConfirm === 'save-confirm' ? (
|
||||
<Text color={theme.warning}>
|
||||
Switch crawl_mode from all_accessible to selected_roots? Will limit ingest to{' '}
|
||||
{selectedPageCountText(selectedCount)}. Press Enter to confirm or Escape to go back.
|
||||
{props.chrome.confirmSaveMessage
|
||||
? props.chrome.confirmSaveMessage(state)
|
||||
: 'Confirm save? Press Enter to confirm or Escape to go back.'}
|
||||
</Text>
|
||||
) : null}
|
||||
{state.pendingConfirm === 'skip-empty' ? (
|
||||
<Text color={theme.warning}>Nothing selected. Skip this step? Press Enter to skip or Escape to go back.</Text>
|
||||
) : null}
|
||||
{state.pendingConfirm === 'skip-empty' ? <Text color={theme.warning}>{skipEmptyMessage}</Text> : null}
|
||||
{state.transientHint ? <Text color={theme.warning}>{state.transientHint.text}</Text> : null}
|
||||
</Box>
|
||||
<Text color={theme.active}>└</Text>
|
||||
|
|
@ -322,7 +329,7 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
|
|||
);
|
||||
}
|
||||
|
||||
function renderInk(tree: ReactNode, options: NotionPickerInkRenderOptions): NotionPickerInkInstance {
|
||||
function renderInk(tree: ReactNode, options: TreePickerInkRenderOptions): TreePickerInkInstance {
|
||||
return renderInkRuntime(tree, {
|
||||
stdin: options.stdin as NodeJS.ReadStream | undefined,
|
||||
stdout: options.stdout as NodeJS.WriteStream,
|
||||
|
|
@ -331,19 +338,24 @@ function renderInk(tree: ReactNode, options: NotionPickerInkRenderOptions): Noti
|
|||
patchConsole: options.patchConsole,
|
||||
maxFps: options.maxFps,
|
||||
alternateScreen: options.alternateScreen,
|
||||
}) as NotionPickerInkInstance;
|
||||
}) as TreePickerInkInstance;
|
||||
}
|
||||
|
||||
export async function renderNotionPickerTui(
|
||||
input: PickerRenderInput,
|
||||
io: NotionPickerTuiIo,
|
||||
options: { renderInk?: (tree: ReactNode, options: NotionPickerInkRenderOptions) => NotionPickerInkInstance } = {},
|
||||
): Promise<PickerRenderResult> {
|
||||
let result: PickerRenderResult = { kind: 'quit' };
|
||||
let instance: NotionPickerInkInstance | null = null;
|
||||
export interface RenderTreePickerOptions {
|
||||
renderInk?: (tree: ReactNode, options: TreePickerInkRenderOptions) => TreePickerInkInstance;
|
||||
scriptedModeHint?: string;
|
||||
}
|
||||
|
||||
export async function renderTreePickerTui(
|
||||
input: TreePickerRenderInput,
|
||||
io: TreePickerTuiIo,
|
||||
options: RenderTreePickerOptions = {},
|
||||
): Promise<TreePickerResult> {
|
||||
let result: TreePickerResult = { kind: 'quit' };
|
||||
let instance: TreePickerInkInstance | null = null;
|
||||
try {
|
||||
instance = (options.renderInk ?? renderInk)(
|
||||
<NotionPickerApp
|
||||
<TreePickerApp
|
||||
{...input}
|
||||
terminalRows={(io.stdout as { rows?: number }).rows ?? process.stdout.rows ?? 24}
|
||||
terminalWidth={io.stdout.columns ?? process.stdout.columns}
|
||||
|
|
@ -366,9 +378,8 @@ export async function renderNotionPickerTui(
|
|||
instance.unmount();
|
||||
return result;
|
||||
} catch (error) {
|
||||
io.stderr.write(
|
||||
`Notion picker requires a TTY. Use --no-input --notion-root-page-id <UUID> for scripted mode. ${sanitizeNotionPickerTuiError(error)}\n`,
|
||||
);
|
||||
const hint = options.scriptedModeHint ?? 'Picker requires a TTY.';
|
||||
io.stderr.write(`${hint} ${sanitizeTreePickerTuiError(error)}\n`);
|
||||
return { kind: 'quit' };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue