Skip to content

Commit 9b7a2f5

Browse files
feat(database-ui): have db studio init a react ssr template for studio project
1 parent ed4cbe5 commit 9b7a2f5

File tree

8 files changed

+485
-24
lines changed

8 files changed

+485
-24
lines changed

packages/cli/src/main/kotlin/elide/tool/cli/cmd/db/DbStudioCommand.kt

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@ import io.micronaut.core.annotation.ReflectiveAccess
1818
import picocli.CommandLine.Command
1919
import picocli.CommandLine.Option
2020
import picocli.CommandLine.Parameters
21+
import java.nio.file.FileSystems
2122
import java.nio.file.Files
2223
import java.nio.file.Path
24+
import java.nio.file.StandardCopyOption
25+
import kotlin.io.path.exists
26+
import kotlin.io.path.isDirectory
27+
import kotlin.io.path.readText
28+
import kotlin.io.path.writeText
2329
import elide.tool.cli.AbstractSubcommand
2430
import elide.tool.cli.CommandContext
2531
import elide.tool.cli.CommandResult
2632
import elide.tool.cli.ToolState
27-
import elide.tool.exec.SubprocessRunner.delegateTask
28-
import elide.tool.exec.SubprocessRunner.subprocess
29-
import elide.tool.exec.which
3033

3134
@Command(
3235
name = "db",
@@ -38,7 +41,7 @@ import elide.tool.exec.which
3841
@ReflectiveAccess
3942
internal class DbCommand : AbstractSubcommand<ToolState, CommandContext>() {
4043
override suspend fun CommandContext.invoke(state: ToolContext<ToolState>): CommandResult {
41-
return err("`elide db` requires a subcommand (try: studio)")
44+
return CommandResult.err(message = "`elide db` requires a subcommand (try: studio)")
4245
}
4346
}
4447

@@ -51,6 +54,42 @@ internal class DbCommand : AbstractSubcommand<ToolState, CommandContext>() {
5154
@ReflectiveAccess
5255
internal class DbStudioCommand : AbstractSubcommand<ToolState, CommandContext>() {
5356

57+
private companion object {
58+
private const val STUDIO_RESOURCE_PATH = "db-studio"
59+
private const val STUDIO_OUTPUT_DIR = ".db-studio"
60+
private const val STUDIO_INDEX_FILE = "index.tsx"
61+
}
62+
63+
private fun copyResourceDirectory(resourcePath: String, targetDir: Path) {
64+
val resourceUrl = this::class.java.classLoader.getResource(resourcePath)
65+
?: error("Resource not found: $resourcePath")
66+
67+
val uri = resourceUrl.toURI()
68+
val fileSystem = when (uri.scheme) {
69+
"jar" -> FileSystems.newFileSystem(uri, emptyMap<String, Any>())
70+
else -> null
71+
}
72+
73+
fileSystem.use {
74+
val sourcePath = fileSystem?.getPath(resourcePath) ?: Path.of(uri)
75+
76+
Files.walk(sourcePath).use { stream ->
77+
stream.forEach { source ->
78+
val relative = sourcePath.relativize(source)
79+
val target = targetDir.resolve(relative.toString())
80+
81+
when {
82+
source.isDirectory() -> Files.createDirectories(target)
83+
else -> {
84+
Files.createDirectories(target.parent)
85+
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING)
86+
}
87+
}
88+
}
89+
}
90+
}
91+
}
92+
5493
@Parameters(
5594
index = "0",
5695
description = ["Path to SQLite database file"],
@@ -66,39 +105,37 @@ internal class DbStudioCommand : AbstractSubcommand<ToolState, CommandContext>()
66105
)
67106
internal var port: Int = 4983
68107

69-
@Option(
70-
names = ["--host"],
71-
description = ["Host to bind the database UI to"],
72-
defaultValue = "localhost",
73-
)
74108
internal var host: String = "localhost"
75109

76110
override suspend fun CommandContext.invoke(state: ToolContext<ToolState>): CommandResult {
77-
val dbPath = databasePath ?: return err("Database path is required")
78-
111+
val dbPath = databasePath ?: return CommandResult.err(message = "Database path is required")
79112
val dbFile = Path.of(dbPath)
80-
if (!Files.exists(dbFile)) {
81-
return err("Database file not found: $dbPath")
113+
114+
if (!dbFile.exists()) {
115+
return CommandResult.err(message = "Database file not found: $dbPath")
82116
}
83117

84118
val absoluteDbPath = dbFile.toAbsolutePath().toString()
119+
val outputDir = Path.of(STUDIO_OUTPUT_DIR)
85120

86-
val npxPath = which(Path.of("npx")) ?: return err("npx not found. Please install Node.js.")
121+
copyResourceDirectory(STUDIO_RESOURCE_PATH, outputDir)
87122

88-
val task = subprocess(npxPath) {
89-
args.add("@outerbase/studio")
90-
args.add("--port")
91-
args.add(port.toString())
92-
args.add(absoluteDbPath)
93-
}
123+
val indexFile = outputDir.resolve(STUDIO_INDEX_FILE)
124+
val processedContent = indexFile.readText()
125+
.replace("__DB_PATH__", absoluteDbPath)
126+
.replace("__PORT__", port.toString())
127+
128+
indexFile.writeText(processedContent)
94129

95130
output {
96-
appendLine("Starting database UI on http://$host:$port")
97-
appendLine("Database: $absoluteDbPath")
131+
appendLine("Generated database studio in: ${outputDir.toAbsolutePath()}")
132+
appendLine()
133+
appendLine("To start the database UI, run:")
134+
appendLine("elide serve $STUDIO_OUTPUT_DIR/$STUDIO_INDEX_FILE")
98135
appendLine()
99-
appendLine("Press Ctrl+C to stop the server")
136+
appendLine("Then open: http://$host:$port")
100137
}
101138

102-
return delegateTask(task)
139+
return CommandResult.success()
103140
}
104141
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { DatabaseStudio } from "./components/DatabaseStudio.tsx";
2+
import { TableDetail, type TableRow } from "./components/TableDetail.tsx";
3+
4+
export type AppProps = {
5+
title: string;
6+
children: any;
7+
}
8+
9+
export function App({ title, children }: AppProps) {
10+
return (
11+
<html lang="en">
12+
<head>
13+
<meta charSet="UTF-8" />
14+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
15+
<title>{title}</title>
16+
<style>{`
17+
* { margin: 0; padding: 0; box-sizing: border-box; }
18+
:root {
19+
--bg-dark: #0f0f0f; --bg-sidebar: #1a1a1a; --bg-main: #0f0f0f;
20+
--bg-hover: #252525; --text-primary: #e5e7eb; --text-muted: #9ca3af;
21+
--border-color: #2a2a2a; --accent: #404040;
22+
}
23+
body {
24+
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif;
25+
background: var(--bg-main); min-height: 100vh; color: var(--text-primary);
26+
line-height: 1.6; overflow: hidden;
27+
}
28+
.app-layout { display: flex; height: 100vh; }
29+
.sidebar {
30+
width: 280px; background: var(--bg-sidebar); border-right: 1px solid var(--border-color);
31+
display: flex; flex-direction: column; overflow: hidden;
32+
}
33+
.sidebar-header {
34+
padding: 1.5rem 1rem; border-bottom: 1px solid var(--border-color);
35+
display: flex; align-items: center; gap: 0.75rem;
36+
}
37+
.sidebar-header svg {
38+
width: 20px; height: 20px; flex-shrink: 0;
39+
}
40+
.sidebar-title { font-size: 1.1rem; font-weight: 700; color: var(--text-primary); }
41+
.sidebar-section { padding: 1rem 0.5rem; flex: 1; overflow-y: auto; }
42+
.section-label {
43+
padding: 0.5rem 0.75rem; font-size: 0.75rem; font-weight: 600;
44+
color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em;
45+
}
46+
.table-list { list-style: none; }
47+
.table-item { margin: 0.25rem 0; }
48+
.table-link {
49+
display: flex; align-items: center; gap: 0.75rem; padding: 0.65rem 0.75rem;
50+
color: var(--text-primary); text-decoration: none; border-radius: 6px;
51+
transition: all 0.15s ease; font-size: 0.9rem;
52+
}
53+
.table-link:hover { background: var(--bg-hover); color: white; }
54+
.table-link.active { background: var(--accent); color: white; }
55+
.table-icon { width: 16px; height: 16px; color: var(--text-muted); flex-shrink: 0; }
56+
.table-link:hover .table-icon, .table-link.active .table-icon { color: white; }
57+
.welcome-container {
58+
flex: 1; display: flex; align-items: center; justify-content: center;
59+
background: var(--bg-main);
60+
}
61+
.welcome-content { text-align: center; max-width: 500px; padding: 2rem; }
62+
.welcome-content svg { margin: 0 auto 1.5rem; width: 64px; height: 64px; }
63+
.welcome-content h1 {
64+
font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem; color: var(--text-primary);
65+
}
66+
.welcome-subtitle { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 2rem; }
67+
.welcome-stats { display: flex; justify-content: center; gap: 2rem; margin-bottom: 2rem; }
68+
.stat { text-align: center; }
69+
.stat-value {
70+
font-size: 2.5rem; font-weight: 700; color: var(--text-primary);
71+
line-height: 1; margin-bottom: 0.5rem;
72+
}
73+
.stat-label {
74+
font-size: 0.85rem; color: var(--text-muted);
75+
text-transform: uppercase; letter-spacing: 0.05em;
76+
}
77+
.db-path-display {
78+
background: var(--bg-sidebar); border: 1px solid var(--border-color);
79+
border-radius: 8px; padding: 1rem; font-family: Monaco, monospace;
80+
font-size: 0.85rem; color: var(--text-muted); word-break: break-all;
81+
display: flex; align-items: center; gap: 0.5rem;
82+
}
83+
.db-path-label { font-size: 1.25rem; flex-shrink: 0; }
84+
.db-path-value { flex: 1; }
85+
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
86+
.toolbar {
87+
background: var(--bg-sidebar); border-bottom: 1px solid var(--border-color);
88+
padding: 1rem 1.5rem; display: flex; align-items: center; justify-content: space-between;
89+
}
90+
.toolbar-left { display: flex; align-items: center; gap: 1rem; }
91+
.table-name-display {
92+
font-size: 1.25rem; font-weight: 700; color: var(--text-primary);
93+
font-family: Monaco, monospace;
94+
}
95+
.row-count {
96+
font-size: 0.85rem; color: var(--text-muted);
97+
padding: 0.25rem 0.75rem; background: var(--bg-dark); border-radius: 4px;
98+
}
99+
.table-wrapper { flex: 1; overflow: auto; background: var(--bg-main); }
100+
.data-table-container { min-width: 100%; display: inline-block; }
101+
.data-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
102+
.data-table thead { background: var(--bg-sidebar); position: sticky; top: 0; z-index: 10; }
103+
.data-table th {
104+
padding: 0.75rem 1rem; text-align: left; font-weight: 600; color: var(--text-muted);
105+
border-bottom: 1px solid var(--border-color); font-family: Monaco, monospace;
106+
font-size: 0.8rem; letter-spacing: 0.05em;
107+
}
108+
.data-table td {
109+
padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-color);
110+
color: var(--text-primary); font-family: Monaco, monospace;
111+
font-size: 0.85rem; white-space: nowrap;
112+
}
113+
.data-table tbody tr:hover { background: var(--bg-hover); }
114+
.data-table td.null { color: var(--text-muted); font-style: italic; }
115+
.empty-state { text-align: center; padding: 4rem 2rem; color: var(--text-muted); }
116+
.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; }
117+
`}</style>
118+
</head>
119+
<body>
120+
{children}
121+
</body>
122+
</html>
123+
);
124+
}
125+
126+
export type HomeViewProps = {
127+
dbPath: string;
128+
tables: string[];
129+
}
130+
131+
export function HomeView({ dbPath, tables }: HomeViewProps) {
132+
return (
133+
<App title="Database Studio · Elide">
134+
<DatabaseStudio dbPath={dbPath} tables={tables} />
135+
</App>
136+
);
137+
}
138+
139+
export type TableViewProps = {
140+
dbPath: string;
141+
tableName: string;
142+
columns: string[];
143+
rows: TableRow[];
144+
totalRows: number;
145+
allTables: string[];
146+
}
147+
148+
export function TableView({ dbPath, tableName, columns, rows, totalRows, allTables }: TableViewProps) {
149+
return (
150+
<App title={`${tableName} · Database Studio · Elide`}>
151+
<TableDetail
152+
dbPath={dbPath}
153+
tableName={tableName}
154+
columns={columns}
155+
rows={rows}
156+
totalRows={totalRows}
157+
allTables={allTables}
158+
/>
159+
</App>
160+
);
161+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Sidebar } from "./Sidebar.tsx";
2+
import { WelcomeView } from "./WelcomeView.tsx";
3+
4+
export type DatabaseStudioProps = {
5+
dbPath: string;
6+
tables: string[];
7+
}
8+
9+
export function DatabaseStudio({ dbPath, tables }: DatabaseStudioProps) {
10+
return (
11+
<div className="app-layout">
12+
<Sidebar tables={tables} />
13+
<WelcomeView dbPath={dbPath} tables={tables} />
14+
</div>
15+
);
16+
}

packages/cli/src/main/resources/db-studio/components/ElideLogoGray.tsx

Lines changed: 9 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { ElideLogoGray } from "./ElideLogoGray.tsx";
2+
3+
export type SidebarProps = {
4+
tables: string[];
5+
activeTable?: string;
6+
}
7+
8+
export function Sidebar({ tables, activeTable }: SidebarProps) {
9+
return (
10+
<div className="sidebar">
11+
<div className="sidebar-header">
12+
<ElideLogoGray />
13+
<span className="sidebar-title">Database Studio</span>
14+
</div>
15+
<div className="sidebar-section">
16+
{tables.length > 0 ? (
17+
<>
18+
<div className="section-label">{tables.length} {tables.length === 1 ? 'table' : 'tables'}</div>
19+
<ul className="table-list">
20+
{tables.map((table) => (
21+
<li key={table} className="table-item">
22+
<a
23+
href={`/table/${table}`}
24+
className={table === activeTable ? 'table-link active' : 'table-link'}
25+
>
26+
<svg className="table-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
27+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
28+
</svg>
29+
<span>{table}</span>
30+
</a>
31+
</li>
32+
))}
33+
</ul>
34+
</>
35+
) : (
36+
<div className="empty-state">
37+
<div className="empty-icon">📋</div>
38+
<div>No tables found</div>
39+
</div>
40+
)}
41+
</div>
42+
</div>
43+
);
44+
}

0 commit comments

Comments
 (0)