Skip to content

Commit f1ea23d

Browse files
feat(database-ui): discover db files on disk and select between them
1 parent 9b7a2f5 commit f1ea23d

File tree

8 files changed

+542
-70
lines changed

8 files changed

+542
-70
lines changed

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

Lines changed: 133 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,19 @@ import java.nio.file.FileSystems
2222
import java.nio.file.Files
2323
import java.nio.file.Path
2424
import java.nio.file.StandardCopyOption
25+
import java.nio.file.attribute.BasicFileAttributes
2526
import kotlin.io.path.exists
27+
import kotlin.io.path.fileSize
28+
import kotlin.io.path.getLastModifiedTime
2629
import kotlin.io.path.isDirectory
30+
import kotlin.io.path.isRegularFile
31+
import kotlin.io.path.listDirectoryEntries
32+
import kotlin.io.path.name
2733
import kotlin.io.path.readText
2834
import kotlin.io.path.writeText
35+
import kotlinx.serialization.Serializable
36+
import kotlinx.serialization.encodeToString
37+
import kotlinx.serialization.json.Json
2938
import elide.tool.cli.AbstractSubcommand
3039
import elide.tool.cli.CommandContext
3140
import elide.tool.cli.CommandResult
@@ -45,6 +54,15 @@ internal class DbCommand : AbstractSubcommand<ToolState, CommandContext>() {
4554
}
4655
}
4756

57+
@Serializable
58+
data class DiscoveredDatabase(
59+
val path: String,
60+
val name: String,
61+
val size: Long,
62+
val lastModified: Long,
63+
val isLocal: Boolean = false, // Whether it's in the current working directory
64+
)
65+
4866
@Command(
4967
name = "studio",
5068
description = ["Launch database UI for SQLite databases"],
@@ -58,6 +76,86 @@ internal class DbStudioCommand : AbstractSubcommand<ToolState, CommandContext>()
5876
private const val STUDIO_RESOURCE_PATH = "db-studio"
5977
private const val STUDIO_OUTPUT_DIR = ".db-studio"
6078
private const val STUDIO_INDEX_FILE = "index.tsx"
79+
private val SQLITE_EXTENSIONS = setOf(".db", ".sqlite", ".sqlite3", ".db3")
80+
}
81+
82+
private fun discoverDatabases(): List<DiscoveredDatabase> {
83+
val databases = mutableListOf<DiscoveredDatabase>()
84+
85+
val cwd = Path.of(System.getProperty("user.dir"))
86+
87+
searchDirectory(cwd, databases, depth = 0, maxDepth = 0, isLocal = true)
88+
89+
try {
90+
cwd.listDirectoryEntries()
91+
.filter { it.isDirectory() }
92+
.forEach { subDir ->
93+
searchDirectory(subDir, databases, depth = 1, maxDepth = 1, isLocal = true)
94+
}
95+
} catch (e: Exception) {
96+
97+
}
98+
99+
val userHome = Path.of(System.getProperty("user.home"))
100+
val osName = System.getProperty("os.name").lowercase()
101+
102+
val userDataDirs = when {
103+
osName.contains("mac") -> listOf(
104+
userHome.resolve("Library/Application Support")
105+
)
106+
osName.contains("win") -> listOf(
107+
Path.of(System.getenv("APPDATA") ?: userHome.resolve("AppData/Roaming").toString())
108+
)
109+
else -> listOf( // Linux/Unix
110+
userHome.resolve(".local/share")
111+
)
112+
}
113+
114+
userDataDirs.forEach { dir ->
115+
if (dir.exists() && dir.isDirectory()) {
116+
searchDirectory(dir, databases, depth = 0, maxDepth = 1, isLocal = false)
117+
}
118+
}
119+
120+
return databases.sortedWith(
121+
compareByDescending<DiscoveredDatabase> { it.isLocal }
122+
.thenByDescending { it.lastModified }
123+
)
124+
}
125+
126+
private fun searchDirectory(
127+
dir: Path,
128+
databases: MutableList<DiscoveredDatabase>,
129+
depth: Int,
130+
maxDepth: Int,
131+
isLocal: Boolean
132+
) {
133+
try {
134+
dir.listDirectoryEntries().forEach { file ->
135+
when {
136+
file.isRegularFile() && SQLITE_EXTENSIONS.any { file.name.endsWith(it, ignoreCase = true) } -> {
137+
try {
138+
databases.add(
139+
DiscoveredDatabase(
140+
path = file.toAbsolutePath().toString(),
141+
name = file.name,
142+
size = file.fileSize(),
143+
lastModified = file.getLastModifiedTime().toMillis(),
144+
isLocal = isLocal,
145+
)
146+
)
147+
} catch (e: Exception) {
148+
// Silently ignore files we can't read
149+
}
150+
}
151+
file.isDirectory() && depth < maxDepth -> {
152+
searchDirectory(file, databases, depth + 1, maxDepth, isLocal)
153+
}
154+
}
155+
}
156+
} catch (e: Exception) {
157+
// Silently ignore permission errors
158+
}
61159
}
62160

63161
private fun copyResourceDirectory(resourcePath: String, targetDir: Path) {
@@ -108,22 +206,45 @@ internal class DbStudioCommand : AbstractSubcommand<ToolState, CommandContext>()
108206
internal var host: String = "localhost"
109207

110208
override suspend fun CommandContext.invoke(state: ToolContext<ToolState>): CommandResult {
111-
val dbPath = databasePath ?: return CommandResult.err(message = "Database path is required")
112-
val dbFile = Path.of(dbPath)
113-
114-
if (!dbFile.exists()) {
115-
return CommandResult.err(message = "Database file not found: $dbPath")
116-
}
117-
118-
val absoluteDbPath = dbFile.toAbsolutePath().toString()
119209
val outputDir = Path.of(STUDIO_OUTPUT_DIR)
120-
121210
copyResourceDirectory(STUDIO_RESOURCE_PATH, outputDir)
122211

123212
val indexFile = outputDir.resolve(STUDIO_INDEX_FILE)
124-
val processedContent = indexFile.readText()
125-
.replace("__DB_PATH__", absoluteDbPath)
126-
.replace("__PORT__", port.toString())
213+
val baseContent = indexFile.readText()
214+
215+
// Handle database selection mode vs direct database path
216+
val processedContent = if (databasePath == null) {
217+
// Discovery mode: find available databases
218+
val discovered = discoverDatabases()
219+
220+
if (discovered.isEmpty()) {
221+
return CommandResult.err(message = "No SQLite databases found in current directory or user data directories")
222+
}
223+
224+
val json = Json { prettyPrint = false }
225+
val databasesJson = json.encodeToString(discovered)
226+
227+
baseContent
228+
.replace("\"__DB_PATH__\"", "null")
229+
.replace("__PORT__", port.toString())
230+
.replace("\"__DATABASES__\"", databasesJson)
231+
.replace("__SELECTION_MODE__", "true")
232+
} else {
233+
// Direct path mode: use provided database
234+
val dbFile = Path.of(databasePath!!)
235+
236+
if (!dbFile.exists()) {
237+
return CommandResult.err(message = "Database file not found: $databasePath")
238+
}
239+
240+
val absoluteDbPath = dbFile.toAbsolutePath().toString()
241+
242+
baseContent
243+
.replace("\"__DB_PATH__\"", "\"$absoluteDbPath\"")
244+
.replace("__PORT__", port.toString())
245+
.replace("\"__DATABASES__\"", "[]")
246+
.replace("__SELECTION_MODE__", "false")
247+
}
127248

128249
indexFile.writeText(processedContent)
129250

packages/cli/src/main/resources/db-studio/App.tsx

Lines changed: 126 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DatabaseStudio } from "./components/DatabaseStudio.tsx";
2+
import { DatabaseSelector, type DiscoveredDatabase } from "./components/DatabaseSelector.tsx";
23
import { TableDetail, type TableRow } from "./components/TableDetail.tsx";
34

45
export type AppProps = {
@@ -25,7 +26,8 @@ export function App({ title, children }: AppProps) {
2526
background: var(--bg-main); min-height: 100vh; color: var(--text-primary);
2627
line-height: 1.6; overflow: hidden;
2728
}
28-
.app-layout { display: flex; height: 100vh; }
29+
.app-container { display: flex; flex-direction: column; height: 100vh; }
30+
.app-layout { display: flex; flex: 1; overflow: hidden; }
2931
.sidebar {
3032
width: 280px; background: var(--bg-sidebar); border-right: 1px solid var(--border-color);
3133
display: flex; flex-direction: column; overflow: hidden;
@@ -38,7 +40,34 @@ export function App({ title, children }: AppProps) {
3840
width: 20px; height: 20px; flex-shrink: 0;
3941
}
4042
.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; }
43+
.top-toolbar {
44+
background: var(--bg-sidebar); border-bottom: 1px solid var(--border-color);
45+
padding: 1rem 1.5rem; display: flex; align-items: center; justify-content: space-between;
46+
height: 60px; flex-shrink: 0;
47+
}
48+
.top-toolbar-left {
49+
display: flex; align-items: center; gap: 0.75rem;
50+
}
51+
.top-toolbar-left svg {
52+
width: 20px; height: 20px; flex-shrink: 0;
53+
}
54+
.top-toolbar-title { font-size: 1.1rem; font-weight: 700; color: var(--text-primary); }
55+
.top-toolbar-right { display: flex; align-items: center; }
56+
.back-button {
57+
display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem;
58+
background: var(--bg-hover); border: 1px solid var(--border-color);
59+
border-radius: 6px; color: var(--text-primary); text-decoration: none;
60+
font-size: 0.9rem; transition: all 0.15s ease;
61+
cursor: pointer;
62+
}
63+
.back-button:hover {
64+
background: var(--accent); border-color: var(--text-muted);
65+
}
66+
.back-icon {
67+
width: 18px; height: 18px; color: var(--text-muted); flex-shrink: 0;
68+
}
69+
.back-button:hover .back-icon { color: var(--text-primary); }
70+
.sidebar-section { padding: 1.5rem 0.5rem 1rem; flex: 1; overflow-y: auto; }
4271
.section-label {
4372
padding: 0.5rem 0.75rem; font-size: 0.75rem; font-weight: 600;
4473
color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em;
@@ -49,6 +78,7 @@ export function App({ title, children }: AppProps) {
4978
display: flex; align-items: center; gap: 0.75rem; padding: 0.65rem 0.75rem;
5079
color: var(--text-primary); text-decoration: none; border-radius: 6px;
5180
transition: all 0.15s ease; font-size: 0.9rem;
81+
cursor: pointer;
5282
}
5383
.table-link:hover { background: var(--bg-hover); color: white; }
5484
.table-link.active { background: var(--accent); color: white; }
@@ -114,6 +144,82 @@ export function App({ title, children }: AppProps) {
114144
.data-table td.null { color: var(--text-muted); font-style: italic; }
115145
.empty-state { text-align: center; padding: 4rem 2rem; color: var(--text-muted); }
116146
.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; }
147+
148+
/* Database Selector Styles */
149+
.selector-container {
150+
min-height: 100vh; display: flex; align-items: center; justify-content: center;
151+
background: var(--bg-main); padding: 2rem;
152+
}
153+
.selector-content { max-width: 1200px; width: 100%; }
154+
.selector-header { text-align: center; margin-bottom: 3rem; }
155+
.selector-logo {
156+
display: flex; justify-content: center; margin-bottom: 1.5rem;
157+
}
158+
.selector-logo svg {
159+
width: 64px; height: 64px;
160+
}
161+
.selector-header h1 {
162+
font-size: 2.5rem; font-weight: 700; color: var(--text-primary);
163+
margin-bottom: 0.75rem;
164+
}
165+
.selector-subtitle {
166+
font-size: 1.1rem; color: var(--text-primary);
167+
margin-bottom: 0.5rem;
168+
}
169+
.selector-count {
170+
font-size: 0.9rem; color: var(--text-muted);
171+
}
172+
.database-grid {
173+
display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
174+
gap: 1rem; margin-top: 2rem;
175+
}
176+
.database-card {
177+
background: var(--bg-sidebar); border: 1px solid var(--border-color);
178+
border-radius: 12px; padding: 1.5rem; display: flex; align-items: center;
179+
gap: 1rem; cursor: pointer; transition: all 0.2s ease;
180+
text-align: left; width: 100%; text-decoration: none;
181+
cursor: pointer;
182+
}
183+
.database-card:hover {
184+
background: var(--bg-hover); border-color: var(--text-muted);
185+
transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
186+
}
187+
.database-icon {
188+
width: 48px; height: 48px; flex-shrink: 0;
189+
background: var(--accent); border-radius: 8px;
190+
display: flex; align-items: center; justify-content: center;
191+
}
192+
.database-icon svg { width: 28px; height: 28px; color: var(--text-primary); }
193+
.database-info { flex: 1; min-width: 0; }
194+
.database-name {
195+
font-size: 1.1rem; font-weight: 600; color: var(--text-primary);
196+
margin-bottom: 0.25rem; font-family: Monaco, monospace;
197+
display: flex; align-items: center; gap: 0.5rem;
198+
}
199+
.local-badge {
200+
font-size: 0.7rem; font-weight: 600; color: #10b981;
201+
background: rgba(16, 185, 129, 0.15); border: 1px solid rgba(16, 185, 129, 0.3);
202+
padding: 0.15rem 0.5rem; border-radius: 4px;
203+
text-transform: uppercase; letter-spacing: 0.05em;
204+
}
205+
.database-path {
206+
font-size: 0.85rem; color: var(--text-muted);
207+
font-family: Monaco, monospace; white-space: nowrap;
208+
overflow: hidden; text-overflow: ellipsis;
209+
}
210+
.database-meta {
211+
display: flex; align-items: center; gap: 0.5rem;
212+
margin-top: 0.5rem; font-size: 0.8rem; color: var(--text-muted);
213+
}
214+
.meta-divider { opacity: 0.5; }
215+
.database-arrow {
216+
width: 24px; height: 24px; flex-shrink: 0;
217+
color: var(--text-muted); transition: transform 0.2s ease;
218+
}
219+
.database-card:hover .database-arrow {
220+
transform: translateX(4px); color: var(--text-primary);
221+
}
222+
.database-arrow svg { width: 100%; height: 100%; }
117223
`}</style>
118224
</head>
119225
<body>
@@ -126,12 +232,25 @@ export function App({ title, children }: AppProps) {
126232
export type HomeViewProps = {
127233
dbPath: string;
128234
tables: string[];
235+
dbIndex?: number;
129236
}
130237

131-
export function HomeView({ dbPath, tables }: HomeViewProps) {
238+
export function HomeView({ dbPath, tables, dbIndex }: HomeViewProps) {
132239
return (
133240
<App title="Database Studio · Elide">
134-
<DatabaseStudio dbPath={dbPath} tables={tables} />
241+
<DatabaseStudio dbPath={dbPath} tables={tables} dbIndex={dbIndex} />
242+
</App>
243+
);
244+
}
245+
246+
export type SelectionViewProps = {
247+
databases: DiscoveredDatabase[];
248+
}
249+
250+
export function SelectionView({ databases }: SelectionViewProps) {
251+
return (
252+
<App title="Select Database · Database Studio · Elide">
253+
<DatabaseSelector databases={databases} />
135254
</App>
136255
);
137256
}
@@ -143,9 +262,10 @@ export type TableViewProps = {
143262
rows: TableRow[];
144263
totalRows: number;
145264
allTables: string[];
265+
dbIndex?: number;
146266
}
147267

148-
export function TableView({ dbPath, tableName, columns, rows, totalRows, allTables }: TableViewProps) {
268+
export function TableView({ dbPath, tableName, columns, rows, totalRows, allTables, dbIndex }: TableViewProps) {
149269
return (
150270
<App title={`${tableName} · Database Studio · Elide`}>
151271
<TableDetail
@@ -155,6 +275,7 @@ export function TableView({ dbPath, tableName, columns, rows, totalRows, allTabl
155275
rows={rows}
156276
totalRows={totalRows}
157277
allTables={allTables}
278+
dbIndex={dbIndex}
158279
/>
159280
</App>
160281
);

0 commit comments

Comments
 (0)