Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat(devtools): initial setup #3036

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"example": "run () { nuxi dev examples/$*; }; run",
"docs": "nuxi dev docs",
"docs:build": "nuxi build docs",
"dev": "nuxi dev playground",
"devtools": "nuxi dev src/devtools/client --port 3300",
"dev": "pnpm dev:prepare && nuxi dev playground",
"dev:build": "nuxi build playground",
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
"release": "npm run lint && npm run test && npm run prepack && release-it",
Expand All @@ -49,6 +50,8 @@
"verify": "npm run dev:prepare && npm run lint && npm run test && npm run typecheck"
},
"dependencies": {
"@nuxt/devtools-kit": "^1.7.0",
"@nuxt/devtools-ui-kit": "^1.7.0",
"@nuxt/kit": "^3.15.2",
"@nuxtjs/mdc": "^0.13.1",
"@shikijs/langs": "^2.1.0",
Expand Down
23 changes: 23 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import { resolve } from 'node:path'
import { defineNuxtModule } from '@nuxt/kit'
import { startSubprocess } from '@nuxt/devtools-kit'
import { DEVTOOLS_UI_PORT } from '../src/constants'

export default defineNuxtConfig({
modules: [
'@nuxt/ui-pro',
'@nuxt/content',
'@nuxthub/core',
defineNuxtModule({
setup(_, nuxt) {
if (!nuxt.options.dev)
return

startSubprocess(
{
command: 'npx',
args: ['nuxi', 'dev', '--port', DEVTOOLS_UI_PORT.toString()],
cwd: resolve(__dirname, '../src/devtools/client'),
},
{
id: 'nuxt-devtools-content:client',
name: 'Nuxt DevTools Content Client',
},
)
},
}),
],
content: {
build: {
Expand Down
1,419 changes: 1,264 additions & 155 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const DEVTOOLS_UI_PATH = '/__nuxt-devtools-content-module'
export const DEVTOOLS_UI_PORT = 3300
export const DEVTOOLS_RPC_NAMESPACE = 'nuxt-devtools-content-module'

export const DEVTOOLS_MODULE_NAME = 'content'
export const DEVTOOLS_MODULE_KEY = 'content'
export const DEVTOOLS_MODULE_TITLE = 'Content'
export const DEVTOOLS_MODULE_ICON = 'i-carbon-db2-buffer-pool'
15 changes: 15 additions & 0 deletions src/devtools/client/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<template>
<Html>
<Body h-screen>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</Body>
</Html>
</template>

<style>
#__nuxt {
height: 100%;
}
</style>
245 changes: 245 additions & 0 deletions src/devtools/client/components/Details.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { computedAsync, useVirtualList } from '@vueuse/core'
import { computed, watch, ref, shallowRef, onMounted } from 'vue'
import { rpc } from '../composables/rpc'

const loading = ref(true)
const sortColumn = ref<string | null>(null)
const sortOrder = ref<'asc' | 'desc'>('asc')
// TDOO: add more advanced filtering, add debounce and ...
const filters = ref<Record<string, string>>({})

const props = defineProps<{
table: string
}>()

const columns = shallowRef([])

const rows = computedAsync(() => {
loading.value = true
return rpc.value?.sqliteTable(props.table).then((data) => {
columns.value = Object.keys(data[0])
loading.value = false
return data
})
})

const filteredRows = computed(() => {
if (!rows.value) return []

// Apply filters
let result = rows.value.filter((row) => {
return Object.entries(filters.value).every(([key, value]) => {
return row[key]?.toString().toLowerCase().includes(value.toLowerCase())
})
})

// Apply sorting
if (sortColumn.value) {
result = [...result].sort((a, b) => {
const aVal = a[sortColumn.value!]
const bVal = b[sortColumn.value!]

if (aVal < bVal) return sortOrder.value === 'asc' ? -1 : 1
if (aVal > bVal) return sortOrder.value === 'asc' ? 1 : -1
return 0
})
}

return result
})

const { list: virtualRows, containerProps, wrapperProps, scrollTo } = useVirtualList(filteredRows, {
itemHeight: 37,
})

function toggleSort(column: string) {
if (sortColumn.value === column) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
}
else {
sortColumn.value = column
sortOrder.value = 'asc'
}
}

function updateFilter(column: string, value: string) {
filters.value[column] = value
}

watch(() => props.table, () => {
sortColumn.value = null
sortOrder.value = 'asc'
filters.value = {}
scrollTo(0)
})

function copyAS(format: 'json') {
const data = filteredRows.value
if (format === 'json') {
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
}
}

const pageSize = 19
const currentPage = ref(1)
const totalPages = computed(() => Math.ceil(filteredRows.value.length / pageSize) || 1)

function scrollToPage(page: number) {
if (page > 0 && page <= totalPages.value) {
console.log('next page: ', page)
const index = (page - 1) * pageSize
scrollTo(index)
currentPage.value = page
}
}

const dataBody = ref()

onMounted(() => {
containerProps.ref.value.addEventListener('scroll', onContainerScroll)
})

function onContainerScroll() {
if (!containerProps.ref.value) return

const scrollTop = containerProps.ref.value.scrollTop // Total scroll offset
const itemHeight = 37 // Each item's height
const topIndex = Math.floor(scrollTop / itemHeight) // Index of the first visible item
const newPage = Math.ceil((topIndex + 1) / pageSize) // Calculate the page (1-based)
console.log('ScrollTop:', scrollTop)
console.log('Top Index:', topIndex)
console.log('New Page:', newPage)

// Update currentPage only if it has changed
if (newPage !== currentPage.value) {
currentPage.value = newPage
}
}
</script>

<template>
<div class="flex flex-col h-full">
<div class="sticky top-0 z-10 bg-neutral-900 border-b border-gray-400/20 p-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm">
Rows: {{ rows?.length }}
<span v-if="Object.keys(filters).length">
/ {{ filteredRows.length }}
</span>
</span>
</div>
<div class="flex items-center gap-4">
<!-- TODO -->
<NButton n="primary sm">
TABLE INFO
</NButton>
</div>
</div>

<div
class="flex flex-col h-full relative"
:class="loading ? 'overflow-hidden' : 'overflow-auto'"
>
<NLoading
v-if="loading"
class="absolute z-50"
>
Loading ...
</NLoading>
<div v-bind="containerProps">
<!-- Header -->
<div class="sticky top-0">
<div class="flex">
<TableDataCell
data="#"
size="sm"
class="text-center bg-neutral-950/50 backdrop-blur"
/>
<TableDataCell
v-for="column in columns"
:key="column"
class="bg-neutral-950/50 backdrop-blur"
>
<div class="inline-flex items-center gap-2 sticky left-4">
{{ column }}
<NTextInput
v-model="filters[column]"
n="xs"
type="text"
placeholder="Filter..."
@input="updateFilter(column, filters[column])"
/>
<NButton
n="blue"
class="h-full"
:icon="sortColumn === column
? (sortOrder === 'asc' ? 'carbon-chevron-up' : 'carbon-chevron-down')
: 'carbon-chevron-down'"
@click="toggleSort(column)"
/>
</div>
</TableDataCell>
</div>
</div>
<!-- Body -->
<div
v-bind="wrapperProps"
ref="dataBody"
>
<div
v-for="{ data, index } in virtualRows"
:key="index"
class="flex items-stretch"
style="height: 37px;"
>
<TableDataCell
:data="(index + 1).toString()"
size="sm"
class="text-center"
/>
<TableDataCell
v-for="column in columns"
:key="column"
:data="data[column]"
/>
</div>
</div>
</div>
</div>

<div class="border-t border-gray-400/20 p-2 flex items-center justify-between text-sm">
<div class="flex items-center gap-4">
<!-- TODO: complete pagination: disable, input, ... -->
<div class="flex items-center gap-1">
<NButton
class="h-full"
icon="carbon-chevron-left"
n="primary"
@click="scrollToPage(currentPage - 1)"
/>
<NButton disabled>
{{ currentPage }} / {{ totalPages }}
</NButton>
<NButton
class="h-full"
icon="carbon-chevron-right"
n="primary"
@click="scrollToPage(currentPage + 1)"
/>
</div>
</div>
<div class="flex items-center gap-2">
<span>Copy as</span>
<!-- TODO: add more options -->
<NButton
n="blue"
class="text-sm"
@click="copyAS('json')"
>
JSON
</NButton>
</div>
</div>
</div>
</template>
67 changes: 67 additions & 0 deletions src/devtools/client/components/Sidebar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useSqliteTables } from '../composables/state'

const selectedTable = defineModel<string>()

const search = ref('')

const tables = useSqliteTables()

const filteredTables = computed(() => {
if (tables.value) {
handleTableSelect(tables.value[0].name)
}
if (!search.value)
return tables.value
return tables.value
?.filter(c => c.name.toLowerCase().includes(search.value.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name))
})

// const selectedTable = ref('')

function handleTableSelect(table: string) {
selectedTable.value = table
}
</script>

<template>
<div class="p-4">
<NNavbar
v-model:search="search"
:placeholder="`${tables?.length ?? '-'} tables in total`"
no-padding
class="p-2"
>
<template #actions>
<div
class="flex items-center gap-2"
>
<!-- TODO -->
<NButton
n="blue"
class="w-full"
icon="carbon-reset"
title="Refresh"
/>
</div>
</template>
</NNavbar>
<div class="font-medium text-xs text-neutral-500 my-2 pt-4">
TABLES
</div>
<div class="space-y-2">
<div
v-for="table in filteredTables"
:key="table.name"
class="flex items-center gap-2 p-2 hover:bg-neutral-800 rounded cursor-pointer text-sm"
:class="{ 'bg-neutral-800': selectedTable === table.name }"
@click="handleTableSelect(table.name)"
>
<NIcon icon="carbon-chevron-right" />
<span>{{ table.name }}</span>
</div>
</div>
</div>
</template>
Loading
Loading