Skip to content

Commit 6f2b4a9

Browse files
feat(app): add workspace browser bootstrap (#4)
* feat(app): add workspace browser bootstrap * Add treq tui command and OpenTUI/Solid bootstrap runtime * Implement workspace SDK + store-derived file tree (expand/collapse, selection, scroll-to-selection) * Add theme + header/footer layout * Add request panel with per-file loading + stable keyed rendering (prevents stale/stacked rows) * Introduce context providers (SDKProvider, StoreProvider, ExitProvider) and graceful exit handling * Fix app build pipeline: use OpenTUI Solid Bun plugin; default to single-platform builds for dev/test; keep full-matrix build for publish (build:all) * feat(app): add TUI dialog system with command palette and debug console * Dialog context with stack-based modal management * Command palette (ctrl+p) with fuzzy search using fuzzysort * Debug console (ctrl+`) for viewing application logs * Keybind context for configurable keyboard shortcuts * Log context for structured application logging * Debounced file loading (50ms) during rapid navigation * Improved file tree scroll behavior * Increased render FPS from 30 to 60 * Simplified footer with status display
1 parent 1eff323 commit 6f2b4a9

30 files changed

+2326
-5
lines changed

bun.lock

Lines changed: 365 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/app/bunfig.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
preload = ["@opentui/solid/preload"]

packages/app/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@
4444
"README.md"
4545
],
4646
"scripts": {
47-
"build": "bun run script/build.ts",
47+
"build": "bun run script/build.ts --single",
4848
"build:single": "bun run script/build.ts --single",
49+
"build:all": "bun run script/build.ts",
4950
"dev": "bun run src/index.ts",
5051
"clean": "rm -rf dist",
5152
"publish:cli": "bun run script/publish.ts",
@@ -65,8 +66,12 @@
6566
"@clack/prompts": "^0.10.0",
6667
"@hono/zod-openapi": "^1.2.0",
6768
"@hono/zod-validator": "^0.7.6",
69+
"@opentui/core": "^0.1.74",
70+
"@opentui/solid": "^0.1.74",
6871
"@t-req/core": "^0.1.0",
72+
"fuzzysort": "^3.1.0",
6973
"hono": "^4.11.4",
74+
"solid-js": "^1.9.0",
7075
"yargs": "^17.7.2",
7176
"zod": "^4.3.5"
7277
},

packages/app/script/build.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env bun
22

33
import * as path from 'node:path';
4+
import solidPlugin from '@opentui/solid/bun-plugin';
45
import { $ } from 'bun';
56
import pkg from '../package.json';
67

@@ -19,6 +20,7 @@ const targets = [
1920

2021
// Check for --single flag to build only current platform
2122
const singlePlatform = process.argv.includes('--single');
23+
const skipInstall = process.argv.includes('--skip-install');
2224

2325
await $`rm -rf dist`;
2426

@@ -35,6 +37,18 @@ if (singlePlatform && filteredTargets.length === 0) {
3537
process.exit(1);
3638
}
3739

40+
// OpenTUI relies on platform-specific packages like @opentui/core-darwin-x64.
41+
// For cross-target builds, ensure all variants are installed
42+
// For single-platform builds (dev/test), skip the expensive cross-install.
43+
if (!skipInstall && !singlePlatform) {
44+
const opentuiCoreVersion = pkg.dependencies['@opentui/core'];
45+
if (typeof opentuiCoreVersion !== 'string' || opentuiCoreVersion.length === 0) {
46+
console.error('Missing @opentui/core dependency version in package.json');
47+
process.exit(1);
48+
}
49+
await $`bun install --os="*" --cpu="*" @opentui/core@${opentuiCoreVersion}`;
50+
}
51+
3852
for (const { os, arch, bunTarget } of filteredTargets) {
3953
const name = `app-${os}-${arch}`;
4054
const isWindows = os === 'windows';
@@ -46,7 +60,28 @@ for (const { os, arch, bunTarget } of filteredTargets) {
4660
const outfile = path.join(dir, 'dist', name, 'bin', binaryName);
4761
const entrypoint = path.join(dir, 'src', 'index.ts');
4862

49-
await $`bun build --compile --target=${bunTarget} --outfile=${outfile} ${entrypoint}`;
63+
const result = await Bun.build({
64+
entrypoints: [entrypoint],
65+
plugins: [solidPlugin],
66+
tsconfig: path.join(dir, 'tsconfig.json'),
67+
compile: {
68+
target: bunTarget,
69+
outfile,
70+
// Match OpenCode defaults: avoid config/env autoload surprises during compile.
71+
autoloadBunfig: false,
72+
autoloadDotenv: false,
73+
autoloadTsconfig: true,
74+
autoloadPackageJson: true
75+
}
76+
});
77+
78+
if (!result.success) {
79+
console.error(`Build failed for ${name}:`);
80+
for (const log of result.logs) {
81+
console.error(log);
82+
}
83+
process.exit(1);
84+
}
5085

5186
// Write platform package.json
5287
const platformPkg = {

packages/app/script/publish.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ console.log();
2929

3030
// Step 1: Build all platform binaries
3131
console.log('Step 1: Building all platform binaries...');
32-
await $`bun run build`;
32+
await $`bun run build:all`;
3333
console.log();
3434

3535
// Step 2: Smoke test current platform binary

packages/app/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { hideBin } from 'yargs/helpers';
33
import { initCommand } from './cmd/init';
44
import { runCommand } from './cmd/run';
55
import { serveCommand } from './cmd/serve';
6+
import { tuiCommand } from './cmd/tui';
67

78
export function cli(args: string[]): void {
89
yargs(hideBin(['node', 'cli', ...args]))
@@ -11,6 +12,7 @@ export function cli(args: string[]): void {
1112
.command(initCommand)
1213
.command(runCommand)
1314
.command(serveCommand)
15+
.command(tuiCommand)
1416
.demandCommand(1, 'You need to specify a command')
1517
.strict()
1618
.help()

packages/app/src/cmd/tui.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { CommandModule } from 'yargs';
2+
3+
interface TuiOptions {
4+
server: string;
5+
token?: string;
6+
}
7+
8+
export const tuiCommand: CommandModule<object, TuiOptions> = {
9+
command: 'tui',
10+
describe: 'Start interactive TUI for browsing workspace',
11+
builder: {
12+
server: {
13+
type: 'string',
14+
alias: 's',
15+
default: 'http://localhost:4096',
16+
describe: 'Server URL to connect to'
17+
},
18+
token: {
19+
type: 'string',
20+
alias: 't',
21+
describe: 'Bearer token for authentication'
22+
}
23+
},
24+
handler: async (argv) => {
25+
const { startTui } = await import('../tui');
26+
await startTui({ serverUrl: argv.server, token: argv.token });
27+
}
28+
};

packages/app/src/tui/app.tsx

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { useKeyboard } from '@opentui/solid';
2+
import { createEffect, createMemo, createSignal, on, onCleanup, untrack } from 'solid-js';
3+
import { theme, rgba } from './theme';
4+
import { CommandDialog } from './components/command-dialog';
5+
import { DebugConsoleDialog } from './components/debug-console-dialog';
6+
import { FileTree } from './components/file-tree';
7+
import { RequestList } from './components/request-list';
8+
import { useDialog, useExit, useKeybind, useSDK, useStore } from './context';
9+
import { normalizeKey } from './util/normalize-key';
10+
import { getStatusDisplay } from './util/status-display';
11+
12+
export function App() {
13+
const sdk = useSDK();
14+
const store = useStore();
15+
const exit = useExit();
16+
const dialog = useDialog();
17+
const keybind = useKeybind();
18+
19+
// Track in-flight fetch paths to prevent duplicate requests (reactive for UI)
20+
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set());
21+
22+
// Keyboard handling
23+
useKeyboard((event) => {
24+
if (dialog.stack.length > 0) return;
25+
26+
if (keybind.match('debug_console', event)) {
27+
event.preventDefault();
28+
event.stopPropagation();
29+
dialog.replace(() => <DebugConsoleDialog />);
30+
return;
31+
}
32+
33+
if (keybind.match('command_list', event)) {
34+
event.preventDefault();
35+
event.stopPropagation();
36+
dialog.replace(() => <CommandDialog />);
37+
return;
38+
}
39+
40+
if (keybind.match('quit', event)) {
41+
event.preventDefault();
42+
event.stopPropagation();
43+
void exit();
44+
return;
45+
}
46+
47+
const key = normalizeKey(event);
48+
switch (key.name) {
49+
case 'j':
50+
case 'down':
51+
event.preventDefault();
52+
event.stopPropagation();
53+
store.selectNext();
54+
break;
55+
case 'k':
56+
case 'up':
57+
event.preventDefault();
58+
event.stopPropagation();
59+
store.selectPrevious();
60+
break;
61+
case 'return': {
62+
const selected = store.selectedNode();
63+
if (selected) {
64+
if (selected.node.isDir) {
65+
store.toggleDir(selected.node.path);
66+
} else {
67+
// Load requests for selected file
68+
loadRequestsForFile(selected.node.path);
69+
}
70+
}
71+
break;
72+
}
73+
case 'h':
74+
case 'left': {
75+
const selected = store.selectedNode();
76+
if (selected?.node.isDir && selected.isExpanded) {
77+
store.collapseDir(selected.node.path);
78+
}
79+
break;
80+
}
81+
case 'l':
82+
case 'right': {
83+
const selected = store.selectedNode();
84+
if (selected?.node.isDir && !selected.isExpanded) {
85+
store.expandDir(selected.node.path);
86+
}
87+
break;
88+
}
89+
}
90+
});
91+
92+
// Load requests when a file is selected
93+
async function loadRequestsForFile(path: string) {
94+
// Check if already loaded or currently loading
95+
if (store.requestsByPath()[path] || loadingPaths().has(path)) {
96+
return;
97+
}
98+
99+
setLoadingPaths((prev) => {
100+
const next = new Set(prev);
101+
next.add(path);
102+
return next;
103+
});
104+
try {
105+
const response = await sdk.listWorkspaceRequests(path);
106+
store.setRequestsForPath(path, response.requests);
107+
} catch (_e) {
108+
// Silently fail - requests panel will show empty
109+
// Set empty array to prevent retry on re-select
110+
store.setRequestsForPath(path, []);
111+
} finally {
112+
setLoadingPaths((prev) => {
113+
const next = new Set(prev);
114+
next.delete(path);
115+
return next;
116+
});
117+
}
118+
}
119+
120+
// Auto-load requests when selection changes to a file (debounced)
121+
// Uses on() to explicitly track only selectedNode, with a 50ms debounce
122+
// to prevent load during rapid j/k navigation
123+
let loadTimeout: ReturnType<typeof setTimeout> | undefined;
124+
125+
createEffect(
126+
on(
127+
() => store.selectedNode(),
128+
(selected) => {
129+
// Clear any pending load
130+
if (loadTimeout) {
131+
clearTimeout(loadTimeout);
132+
loadTimeout = undefined;
133+
}
134+
135+
if (selected && !selected.node.isDir) {
136+
// Debounce: wait 50ms before loading
137+
loadTimeout = setTimeout(() => {
138+
untrack(() => loadRequestsForFile(selected.node.path));
139+
}, 50);
140+
}
141+
}
142+
)
143+
);
144+
145+
onCleanup(() => {
146+
if (loadTimeout) {
147+
clearTimeout(loadTimeout);
148+
}
149+
});
150+
151+
// Get the selected file path for the request list
152+
const selectedFilePath = () => {
153+
const selected = store.selectedNode();
154+
return selected && !selected.node.isDir ? selected.node.path : undefined;
155+
};
156+
157+
const requestListLoading = createMemo(() => {
158+
const path = selectedFilePath();
159+
if (!path) return false;
160+
return loadingPaths().has(path);
161+
});
162+
163+
const statusDisplay = createMemo(() => getStatusDisplay(store.connectionStatus()));
164+
165+
return (
166+
<box
167+
flexDirection="column"
168+
width="100%"
169+
height="100%"
170+
backgroundColor={rgba(theme.background)}
171+
>
172+
<box flexGrow={1} flexDirection="row">
173+
<box width="50%" flexShrink={0} paddingRight={1}>
174+
<FileTree
175+
nodes={store.flattenedVisible()}
176+
selectedIndex={store.selectedIndex()}
177+
onSelect={store.setSelectedIndex}
178+
onToggle={store.toggleDir}
179+
/>
180+
</box>
181+
182+
<box width={1} flexShrink={0} backgroundColor={rgba(theme.borderSubtle)} />
183+
184+
<box flexGrow={1} flexShrink={0}>
185+
<RequestList
186+
requests={store.selectedFileRequests()}
187+
selectedFile={selectedFilePath()}
188+
isLoading={requestListLoading()}
189+
/>
190+
</box>
191+
</box>
192+
193+
<box height={1} paddingLeft={2} paddingRight={2} flexDirection="row" justifyContent="space-between">
194+
<text fg={rgba(theme.text)}>t-req 🦖</text>
195+
<box flexDirection="row" gap={2}>
196+
<box flexDirection="row">
197+
<text fg={rgba(theme.text)}>{keybind.print('command_list')}</text>
198+
<text fg={rgba(theme.textMuted)}> commands</text>
199+
</box>
200+
<box flexDirection="row" gap={1}>
201+
<text fg={rgba(statusDisplay().color)}>{statusDisplay().icon}</text>
202+
<text fg={rgba(theme.textMuted)}>{statusDisplay().text}</text>
203+
</box>
204+
</box>
205+
</box>
206+
</box>
207+
);
208+
}

0 commit comments

Comments
 (0)